From 381e24ede3c786893e216f4398258519e51399f7 Mon Sep 17 00:00:00 2001 From: Emma Mulitz Date: Fri, 8 May 2026 10:25:38 -0400 Subject: [PATCH 1/2] fix(registry): require declared agent type + auto-backfill primary_brand_domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Prevents the "registered agent invisible in /api/registry/operator" failure mode where a profile had a typed agent in dashboard but registry lookup returned member: null, agents: []. - save_agent (Addie): require type input enum (8 values, no 'unknown'), persist on insert and update on re-save. Behaviors.md intake now asks for type before save_agent — replaces the old "do not ask about agent type" rule. - POST /api/me/agents: 400 on missing/invalid/'unknown' type. PATCH validates type when present (omission preserves existing). - Mutation helper backfills primary_brand_domain atomically when null AND every agent agrees on the same hostname (after stripping www.). Conflicts are skipped — picking one would mis-key registry lookups. - Operator endpoint drops the silent || "unknown" fallback; out-of-enum values still serialize as "unknown" to keep the schema contract but a warn-level log fires so corrupt rows are visible. - OpenAPI: new MemberAgentTypeInput enum (sans 'unknown') marks type required on POST input; descriptions corrected. --- ...type-required-and-brand-domain-backfill.md | 65 +++++++++++++ server/src/addie/mcp/member-tools.ts | 47 +++++++-- server/src/addie/rules/behaviors.md | 28 ++++-- server/src/routes/member-agents.ts | 95 +++++++++++++++++-- server/src/routes/registry-api.ts | 21 +++- server/src/schemas/member-agents-openapi.ts | 30 ++++-- 6 files changed, 252 insertions(+), 34 deletions(-) create mode 100644 .changeset/agent-type-required-and-brand-domain-backfill.md diff --git a/.changeset/agent-type-required-and-brand-domain-backfill.md b/.changeset/agent-type-required-and-brand-domain-backfill.md new file mode 100644 index 0000000000..69c3b7a0e1 --- /dev/null +++ b/.changeset/agent-type-required-and-brand-domain-backfill.md @@ -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` 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. diff --git a/server/src/addie/mcp/member-tools.ts b/server/src/addie/mcp/member-tools.ts index ff62600b58..a37b108c1d 100644 --- a/server/src/addie/mcp/member-tools.ts +++ b/server/src/addie/mcp/member-tools.ts @@ -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'; @@ -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 . "basic": auth_token must be the base64-encoded "user:password" string, sent as Authorization: Basic ' }, oauth_client_credentials: { @@ -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'], }, }, { @@ -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; + // 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 @@ -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) { diff --git a/server/src/addie/rules/behaviors.md b/server/src/addie/rules/behaviors.md index 6168a42b83..0955651d2c 100644 --- a/server/src/addie/rules/behaviors.md +++ b/server/src/addie/rules/behaviors.md @@ -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 `••••••••`. 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. diff --git a/server/src/routes/member-agents.ts b/server/src/routes/member-agents.ts index 2d664ee7a8..f1f1c97fcb 100644 --- a/server/src/routes/member-agents.ts +++ b/server/src/routes/member-agents.ts @@ -34,6 +34,7 @@ import { resolvePrimaryOrganization } from '../db/users-db.js'; import { resolveUserOrgMembership } from '../utils/resolve-user-org-membership.js'; import { getPool } from '../db/client.js'; import type { AgentConfig } from '../types.js'; +import { isValidAgentType } from '../types.js'; import { resolveAgentTypes, logResolvedTypeChanges } from './member-profiles.js'; import { ensureMemberProfileExists } from '../services/member-profile-autopublish.js'; import { performCreateOrganization } from '../services/organization-bootstrap.js'; @@ -63,6 +64,44 @@ export interface MemberAgentsRouterConfig { invalidateMemberContextCache: () => void; } +/** + * Extract the brand domain from an agent URL. Strips protocol, path, query, + * and a leading `www.` so the value matches how `extractDomain` in + * registry-api normalizes lookup queries. Returns null if the URL is + * unparseable. Used to backfill `member_profiles.primary_brand_domain` when + * an agent is registered against a profile that has no brand domain set — + * without this, `/api/registry/operator?domain=…` exact-match lookup misses + * the profile entirely (it keys off `primary_brand_domain`, not the agents + * JSONB), leaving the agent invisible to the public registry. + */ +function brandDomainFromAgentUrl(url: string): string | null { + try { + const h = new URL(url).hostname.toLowerCase(); + if (!h) return null; + return h.startsWith('www.') ? h.slice(4) : h; + } catch { + return null; + } +} + +/** + * Returns the unanimous brand-domain across all agent URLs in the array, or + * null if agents disagree (multi-domain rollup) or none have a parseable URL. + * "Unanimous" is the bar for auto-populating `primary_brand_domain` because + * a profile carries one canonical brand — silently picking one of N + * conflicting hostnames could mis-key registry lookups. + */ +function unanimousBrandDomain(agents: AgentConfig[]): string | null { + const hosts = new Set(); + for (const a of agents) { + if (!a || typeof a.url !== 'string') continue; + const h = brandDomainFromAgentUrl(a.url); + if (h) hosts.add(h); + } + if (hosts.size !== 1) return null; + return hosts.values().next().value ?? null; +} + /** * Decoded shape of `member_profiles.agents` JSONB. The column is JSONB but * pg sometimes hands it back as a string depending on driver settings. @@ -259,7 +298,7 @@ export function createMemberAgentsRouter(config: MemberAgentsRouterConfig): Rout try { await client.query('BEGIN'); const row = await client.query( - `SELECT id, agents + `SELECT id, agents, primary_brand_domain FROM member_profiles WHERE workos_organization_id = $1 FOR UPDATE`, @@ -277,6 +316,7 @@ export function createMemberAgentsRouter(config: MemberAgentsRouterConfig): Rout } const profileId = row.rows[0].id as string; + const currentBrandDomain = row.rows[0].primary_brand_domain as string | null; const existing = parseAgents(row.rows[0].agents); const result = await mutate(existing); if (result.kind === 'reject') { @@ -290,12 +330,33 @@ export function createMemberAgentsRouter(config: MemberAgentsRouterConfig): Rout const typed = (await resolveAgentTypes(gated)) as AgentConfig[]; await logResolvedTypeChanges(gated, typed, orgId); - await client.query( - `UPDATE member_profiles - SET agents = $1::jsonb, updated_at = NOW() - WHERE id = $2`, - [JSON.stringify(typed), profileId], - ); + // Backfill `primary_brand_domain` from the agents' URL hostnames when + // it's currently null AND every agent agrees on the same hostname. + // This keeps the public registry lookup + // (`/api/registry/operator?domain=…`, which keys off + // `primary_brand_domain`) discoverable for profiles that registered an + // agent before setting a brand domain. Conflicts (multiple distinct + // hostnames) are deliberately skipped — picking one would mis-key + // discovery. + let newBrandDomain: string | null = null; + if (!currentBrandDomain) { + newBrandDomain = unanimousBrandDomain(typed); + } + if (newBrandDomain) { + await client.query( + `UPDATE member_profiles + SET agents = $1::jsonb, primary_brand_domain = $2, updated_at = NOW() + WHERE id = $3`, + [JSON.stringify(typed), newBrandDomain, profileId], + ); + } else { + await client.query( + `UPDATE member_profiles + SET agents = $1::jsonb, updated_at = NOW() + WHERE id = $2`, + [JSON.stringify(typed), profileId], + ); + } await client.query('COMMIT'); invalidateMemberContextCache(); @@ -370,6 +431,15 @@ export function createMemberAgentsRouter(config: MemberAgentsRouterConfig): Rout if (!isParseableUrl(body.url)) { return res.status(400).json({ error: 'url must be a valid URL' }); } + // `type` is required from the caller — never inferred. 'unknown' is + // reserved for server-side smuggle protection (resolveAgentTypes), not + // for client input. The caller MUST declare what kind of agent this is. + if (typeof body.type !== 'string' || !isValidAgentType(body.type) || body.type === 'unknown') { + return res.status(400).json({ + error: 'type is required', + message: 'Specify one of: brand, rights, measurement, governance, creative, sales, buying, signals.', + }); + } const targetUrl = body.url; // Auto-bootstrap a private member profile if the caller's org doesn't @@ -437,6 +507,17 @@ export function createMemberAgentsRouter(config: MemberAgentsRouterConfig): Rout 'url cannot be changed via PATCH. DELETE the old entry and POST the new url.', }); } + // If `type` is being patched, it must be a valid declared type. 'unknown' + // is server-side-only. Omitting `type` from the patch is fine — the + // caller is updating other fields and leaving the existing type alone. + if (patch.type !== undefined) { + if (typeof patch.type !== 'string' || !isValidAgentType(patch.type) || patch.type === 'unknown') { + return res.status(400).json({ + error: 'invalid_type', + message: 'type must be one of: brand, rights, measurement, governance, creative, sales, buying, signals.', + }); + } + } const result = await applyMemberAgentMutation(orgId, (existing) => { const idx = existing.findIndex((a) => a.url === targetUrl); diff --git a/server/src/routes/registry-api.ts b/server/src/routes/registry-api.ts index b1eb9a7911..925aec6cda 100644 --- a/server/src/routes/registry-api.ts +++ b/server/src/routes/registry-api.ts @@ -5840,10 +5840,29 @@ export function createRegistryApiRouter(config: RegistryApiConfig): Router { const agents = await Promise.all( agentConfigs.map(async (ac) => { const auths = await federatedIndex.getAuthorizationsForAgent(ac.url); + // `type` is required at every write surface (POST/PATCH + // /api/me/agents and the `save_agent` MCP tool), so a missing or + // out-of-enum value here means corrupt data slipped past those + // gates (direct SQL, pre-validation row, etc.) — log it loud so + // it's caught instead of silently served as "unknown". + // `resolveAgentTypes` is the only path that may legitimately stamp + // `"unknown"` on a write (when smuggle-protection invalidates a + // declared type without a snapshot to override from); that case + // passes `isValidAgentType` and serves through cleanly. + let agentType: AgentType; + if (isValidAgentType(ac.type)) { + agentType = ac.type; + } else { + logger.warn( + { domain, url: ac.url, storedType: ac.type, profileSlug: profile?.slug }, + "operator lookup: agent has missing/invalid `type` — owner must re-declare via save_agent or PATCH /api/me/agents" + ); + agentType = "unknown"; + } return { url: ac.url, name: ac.name || displayName, - type: ac.type || "unknown", + type: agentType, authorized_by: auths.map(a => ({ publisher_domain: a.publisher_domain, authorized_for: a.authorized_for, diff --git a/server/src/schemas/member-agents-openapi.ts b/server/src/schemas/member-agents-openapi.ts index b53e89dabb..6cf2f44f13 100644 --- a/server/src/schemas/member-agents-openapi.ts +++ b/server/src/schemas/member-agents-openapi.ts @@ -38,7 +38,23 @@ const MemberAgentTypeSchema = z ]) .openapi('MemberAgentType', { description: - "Agent type. Resolved server-side from the agent's capability snapshot when one exists; the value submitted by the client is used only as a fallback when no snapshot is available, and is never trusted to override an inferred classification.", + "Agent type as stored on the registry. Server-side smuggle protection compares the caller's declaration against the capability snapshot (when one exists) and may stamp `unknown` if the snapshot contradicts the declaration without classifying it. `unknown` is reserved for that server-side outcome; clients cannot submit it.", + }); + +const MemberAgentTypeInputSchema = z + .enum([ + 'brand', + 'rights', + 'measurement', + 'governance', + 'creative', + 'sales', + 'buying', + 'signals', + ]) + .openapi('MemberAgentTypeInput', { + description: + "Agent type the caller declares. Required on register; smuggle-protection still cross-checks against the capability snapshot when one exists. The server never infers `type` — the owner declares what kind of agent this is.", }); const MemberAgentSchema = z @@ -57,23 +73,23 @@ const MemberAgentSchema = z const MemberAgentInputSchema = z .object({ url: z.string().url().openapi({ example: 'https://agent.example.com/mcp' }), + type: MemberAgentTypeInputSchema, name: z.string().optional(), visibility: MemberAgentVisibilitySchema.optional(), - type: MemberAgentTypeSchema.optional(), health_check_url: z.string().url().optional(), }) - .openapi('MemberAgentInput', { description: 'Request body for `POST /api/me/agents`.' }); + .openapi('MemberAgentInput', { description: 'Request body for `POST /api/me/agents`. `type` is required — the owner declares it; the server never infers.' }); const MemberAgentPatchSchema = z .object({ name: z.string().optional(), visibility: MemberAgentVisibilitySchema.optional(), - type: MemberAgentTypeSchema.optional(), + type: MemberAgentTypeInputSchema.optional(), health_check_url: z.string().url().optional(), }) .openapi('MemberAgentPatch', { description: - 'Request body for `PATCH /api/me/agents/{url}`. The `url` field cannot be changed via PATCH; re-register at the new URL and DELETE the old entry instead.', + 'Request body for `PATCH /api/me/agents/{url}`. The `url` field cannot be changed via PATCH; re-register at the new URL and DELETE the old entry instead. If `type` is omitted, the existing value is preserved.', }); const MemberAgentVisibilityWarningSchema = z @@ -158,7 +174,7 @@ registry.registerPath({ "- If the caller has zero org memberships, the server auto-creates an organization (corporate or personal workspace based on the user's email domain) and the response includes `org_auto_created: true`.", "- If the caller's org has no member profile, the server auto-creates a private profile (display name = organization name, `is_public: false`) and the response includes `profile_auto_created: true`.", "Both auto-bootstraps are best-effort fallbacks. To customize org name / company_type / revenue_tier, or to control profile slug / brand identity / tagline, call `POST /api/organizations` and `POST /api/me/member-profile` explicitly before registering the agent. Tier transitions never happen via this path — go through the billing flow.", - "The `type` field is resolved server-side from the agent's capability snapshot — a client cannot pin a misclassification (e.g. registering a sales agent as `buying`).", + "`type` is required and declared by the caller — the server does not infer it. Server-side smuggle protection still cross-checks the declared type against the agent's capability snapshot when one exists; if the snapshot contradicts the declaration without classifying it, the stored value is `unknown` and the dashboard surfaces the conflict for the owner to resolve.", '`visibility: "public"` requires Professional tier or higher and a `primary_brand_domain` set on the profile. Non-API-tier callers who request `public` will have the entry stored as `members_only` instead, and the response will include a `visibility_downgraded` warning describing the coercion.', ].join('\n\n'), tags: ['Member Agents'], @@ -179,7 +195,7 @@ registry.registerPath({ }, 400: { description: - 'Missing or invalid `url`, or the caller has memberships in other orgs but no primary org set — pass `?org=` to target one explicitly. (Fresh users with no memberships at all hit the org auto-bootstrap path and do not see this error.)', + 'Missing or invalid `url`, missing/invalid `type`, or the caller has memberships in other orgs but no primary org set — pass `?org=` to target one explicitly. (Fresh users with no memberships at all hit the org auto-bootstrap path and do not see this error.)', content: { 'application/json': { schema: ErrorSchema } }, }, 401: { From 206535a2c9cf1d8649236b294fb3ff8801744f8b Mon Sep 17 00:00:00 2001 From: Emma Mulitz Date: Fri, 8 May 2026 10:48:16 -0400 Subject: [PATCH 2/2] test(registry): pin agent-type and brand-domain contract + tighten read schema Addresses Brian's review on #4235. - Add 7 integration tests in member-agents-api.test.ts pinning the new contract: POST 400 on missing/unknown/out-of-enum type, PATCH invalid_type with omission-preserves-existing, primary_brand_domain auto-backfill on null + unanimous hostname, no backfill on conflict, no overwrite when already set. - Update 14 existing POST sites in member-agents-api.test.ts and member-agents-auto-bootstrap.test.ts to declare type: 'sales' so they exercise the new gate cleanly rather than tripping it. - Tighten MemberAgentSchema.type from optional to required so the OpenAPI read shape matches what the operator route always emits. Follow-ups filed: #4236 (surface bulk-defaulted sales type to owners), #4237 (corrupt-row diagnostics sentinel on operator response). --- server/src/schemas/member-agents-openapi.ts | 7 +- .../integration/member-agents-api.test.ts | 205 +++++++++++++++++- .../member-agents-auto-bootstrap.test.ts | 16 +- 3 files changed, 212 insertions(+), 16 deletions(-) diff --git a/server/src/schemas/member-agents-openapi.ts b/server/src/schemas/member-agents-openapi.ts index 6cf2f44f13..da4dc64844 100644 --- a/server/src/schemas/member-agents-openapi.ts +++ b/server/src/schemas/member-agents-openapi.ts @@ -61,14 +61,17 @@ const MemberAgentSchema = z .object({ url: z.string().url().openapi({ example: 'https://agent.example.com/mcp' }), visibility: MemberAgentVisibilitySchema, + type: MemberAgentTypeSchema, name: z.string().optional(), - type: MemberAgentTypeSchema.optional(), health_check_url: z.string().url().optional().openapi({ description: 'Optional fallback liveness URL used by the health probe when the protocol handshake fails.', }), }) - .openapi('MemberAgent', { description: 'Agent entry stored on a member profile.' }); + .openapi('MemberAgent', { + description: + "Agent entry stored on a member profile. `type` is required on read because every write surface declares it and the operator endpoint always emits it; a stored value of `unknown` is the smuggle-protection outcome (snapshot contradicted the declaration without classifying it) and is the only path that surfaces an agent without a real type.", + }); const MemberAgentInputSchema = z .object({ diff --git a/server/tests/integration/member-agents-api.test.ts b/server/tests/integration/member-agents-api.test.ts index b4a9ddeb57..fb6ac8dfc8 100644 --- a/server/tests/integration/member-agents-api.test.ts +++ b/server/tests/integration/member-agents-api.test.ts @@ -248,14 +248,14 @@ describe('Per-agent REST API (/api/me/agents)', () => { (app as any).setCurrentUser(userId); const created = await request(app) .post('/api/me/agents') - .send({ url: 'https://new.example/mcp', name: 'New', visibility: 'private' }); + .send({ url: 'https://new.example/mcp', name: 'New', type: 'sales', visibility: 'private' }); expect(created.status).toBe(201); expect(created.body.agent.url).toBe('https://new.example/mcp'); expect(created.body.agent.name).toBe('New'); const updated = await request(app) .post('/api/me/agents') - .send({ url: 'https://new.example/mcp', name: 'Renamed', visibility: 'private' }); + .send({ url: 'https://new.example/mcp', name: 'Renamed', type: 'sales', visibility: 'private' }); expect(updated.status).toBe(200); expect(updated.body.agent.name).toBe('Renamed'); @@ -272,10 +272,17 @@ describe('Per-agent REST API (/api/me/agents)', () => { await createProfile(orgId, 'postinv'); (app as any).setCurrentUser(userId); - const noUrl = await request(app).post('/api/me/agents').send({ name: 'No URL' }); + // Both cases include `type: 'sales'` so the 400 is unambiguously from + // the URL validator and not the new type-required gate (covered + // separately below). + const noUrl = await request(app) + .post('/api/me/agents') + .send({ name: 'No URL', type: 'sales' }); expect(noUrl.status).toBe(400); - const badUrl = await request(app).post('/api/me/agents').send({ url: 'not a url' }); + const badUrl = await request(app) + .post('/api/me/agents') + .send({ url: 'not a url', type: 'sales' }); expect(badUrl.status).toBe(400); }); @@ -289,7 +296,7 @@ describe('Per-agent REST API (/api/me/agents)', () => { (app as any).setCurrentUser(userId); const res = await request(app) .post('/api/me/agents') - .send({ url: 'https://upgrade.example/mcp', visibility: 'public' }); + .send({ url: 'https://upgrade.example/mcp', type: 'sales', visibility: 'public' }); expect(res.status).toBe(201); expect(res.body.agent.visibility).toBe('members_only'); expect(res.body.warnings).toBeDefined(); @@ -384,7 +391,7 @@ describe('Per-agent REST API (/api/me/agents)', () => { (app as any).setCurrentUser(userId); const res = await request(app) .post(`/api/me/agents?org=${secondaryOrg}`) - .send({ url: 'https://multi.example/mcp', visibility: 'private' }); + .send({ url: 'https://multi.example/mcp', type: 'sales', visibility: 'private' }); expect(res.status).toBe(201); expect(res.body.agent.url).toBe('https://multi.example/mcp'); @@ -443,6 +450,192 @@ describe('Per-agent REST API (/api/me/agents)', () => { expect(stored?.type).toBe('sales'); }); + // ── Type-required contract (PR #4235) ────────────────────────── + // The owner declares `type` at registration; the server never infers. + // 'unknown' is reserved for the server-side smuggle-protection outcome + // (covered by the snapshot-override test above) and is not accepted on + // input. + + it('POST returns 400 when type is missing', async () => { + const orgId = `${TEST_PREFIX}_type_missing`; + const userId = `${TEST_PREFIX}_type_missing_user`; + await seedOrg(pool, orgId, 'individual_professional'); + await provisionUser(userId, orgId); + await createProfile(orgId, 'typemissing'); + + (app as any).setCurrentUser(userId); + const res = await request(app) + .post('/api/me/agents') + .send({ url: 'https://no-type.example/mcp', visibility: 'private' }); + expect(res.status).toBe(400); + expect(res.body.error).toBe('type is required'); + }); + + it('POST returns 400 when type is "unknown" (reserved for server-side outcome)', async () => { + const orgId = `${TEST_PREFIX}_type_unknown`; + const userId = `${TEST_PREFIX}_type_unknown_user`; + await seedOrg(pool, orgId, 'individual_professional'); + await provisionUser(userId, orgId); + await createProfile(orgId, 'typeunknown'); + + (app as any).setCurrentUser(userId); + const res = await request(app) + .post('/api/me/agents') + .send({ url: 'https://unknown.example/mcp', type: 'unknown', visibility: 'private' }); + expect(res.status).toBe(400); + expect(res.body.error).toBe('type is required'); + }); + + it('POST returns 400 when type is not in the AgentType enum', async () => { + const orgId = `${TEST_PREFIX}_type_garbage`; + const userId = `${TEST_PREFIX}_type_garbage_user`; + await seedOrg(pool, orgId, 'individual_professional'); + await provisionUser(userId, orgId); + await createProfile(orgId, 'typegarbage'); + + (app as any).setCurrentUser(userId); + const res = await request(app) + .post('/api/me/agents') + .send({ url: 'https://garbage.example/mcp', type: 'seller', visibility: 'private' }); + expect(res.status).toBe(400); + expect(res.body.error).toBe('type is required'); + }); + + it('PATCH returns 400 invalid_type when patch.type is invalid; preserves existing type when omitted', async () => { + const orgId = `${TEST_PREFIX}_patch_type`; + const userId = `${TEST_PREFIX}_patch_type_user`; + await seedOrg(pool, orgId, 'individual_professional'); + await provisionUser(userId, orgId); + // Seed a profile with one agent that already has a declared type so + // we can verify omission preserves it. + await memberDb.createProfile({ + workos_organization_id: orgId, + display_name: 'Test patchtype', + slug: 'patchtype', + primary_brand_domain: 'patchtype.example', + is_public: false, + agents: [ + { url: 'https://existing.example/mcp', type: 'sales', visibility: 'private' }, + ], + }); + + (app as any).setCurrentUser(userId); + const target = encodeURIComponent('https://existing.example/mcp'); + + // Invalid type → 400 invalid_type (caller-supplied 'unknown' rejected, + // out-of-enum strings rejected). + const badEnum = await request(app) + .patch(`/api/me/agents/${target}`) + .send({ type: 'seller' }); + expect(badEnum.status).toBe(400); + expect(badEnum.body.error).toBe('invalid_type'); + + const unknown = await request(app) + .patch(`/api/me/agents/${target}`) + .send({ type: 'unknown' }); + expect(unknown.status).toBe(400); + expect(unknown.body.error).toBe('invalid_type'); + + // Omitting type → existing 'sales' preserved on the row. + const renamed = await request(app) + .patch(`/api/me/agents/${target}`) + .send({ name: 'Renamed' }); + expect(renamed.status).toBe(200); + expect(renamed.body.agent.type).toBe('sales'); + + // Valid type → updated. + const swapped = await request(app) + .patch(`/api/me/agents/${target}`) + .send({ type: 'buying' }); + expect(swapped.status).toBe(200); + expect(swapped.body.agent.type).toBe('buying'); + }); + + // ── primary_brand_domain auto-backfill (PR #4235) ────────────── + // When a profile has no brand domain and agents agree on a hostname, + // backfill atomically with the JSONB write so /api/registry/operator + // discovery works without a separate setup step. + + it('POST backfills primary_brand_domain from the agent hostname when null and unanimous', async () => { + const orgId = `${TEST_PREFIX}_bd_backfill`; + const userId = `${TEST_PREFIX}_bd_backfill_user`; + await seedOrg(pool, orgId, 'individual_professional'); + await provisionUser(userId, orgId); + // Seed a profile with NULL primary_brand_domain (mirrors the + // pre-PR auto-bootstrap path that produced the harvingupta bug). + await memberDb.createProfile({ + workos_organization_id: orgId, + display_name: 'Test bdbackfill', + slug: 'bdbackfill', + // primary_brand_domain intentionally omitted — defaults to null. + is_public: false, + agents: [], + }); + + (app as any).setCurrentUser(userId); + const res = await request(app) + .post('/api/me/agents') + .send({ url: 'https://www.bdbackfill.example/api/mcp', type: 'sales', visibility: 'private' }); + expect(res.status).toBe(201); + + const profile = await memberDb.getProfileByOrgId(orgId); + // `www.` stripped to match `extractDomain` in registry-api so a + // /api/registry/operator?domain=bdbackfill.example query lands. + expect(profile!.primary_brand_domain).toBe('bdbackfill.example'); + }); + + it('POST does NOT backfill primary_brand_domain when agents disagree on hostname', async () => { + const orgId = `${TEST_PREFIX}_bd_conflict`; + const userId = `${TEST_PREFIX}_bd_conflict_user`; + await seedOrg(pool, orgId, 'individual_professional'); + await provisionUser(userId, orgId); + await memberDb.createProfile({ + workos_organization_id: orgId, + display_name: 'Test bdconflict', + slug: 'bdconflict', + is_public: false, + agents: [ + { url: 'https://first.example/mcp', type: 'sales', visibility: 'private' }, + ], + }); + + (app as any).setCurrentUser(userId); + const res = await request(app) + .post('/api/me/agents') + .send({ url: 'https://second.example/mcp', type: 'sales', visibility: 'private' }); + expect(res.status).toBe(201); + + const profile = await memberDb.getProfileByOrgId(orgId); + // Two distinct hostnames in the agents array — refuse to guess. + expect(profile!.primary_brand_domain).toBeNull(); + }); + + it('POST does NOT overwrite primary_brand_domain when one is already set', async () => { + const orgId = `${TEST_PREFIX}_bd_preset`; + const userId = `${TEST_PREFIX}_bd_preset_user`; + await seedOrg(pool, orgId, 'individual_professional'); + await provisionUser(userId, orgId); + await memberDb.createProfile({ + workos_organization_id: orgId, + display_name: 'Test bdpreset', + slug: 'bdpreset', + primary_brand_domain: 'preset.example', + is_public: false, + agents: [], + }); + + (app as any).setCurrentUser(userId); + const res = await request(app) + .post('/api/me/agents') + .send({ url: 'https://different.example/mcp', type: 'sales', visibility: 'private' }); + expect(res.status).toBe(201); + + const profile = await memberDb.getProfileByOrgId(orgId); + // The existing brand-domain wins; auto-backfill never fires when the + // column is already populated, even if the agent URL hostname differs. + expect(profile!.primary_brand_domain).toBe('preset.example'); + }); + it('DELETE returns 409 unpublish_first when the agent is currently public', async () => { const orgId = `${TEST_PREFIX}_delete_public`; const userId = `${TEST_PREFIX}_delete_public_user`; diff --git a/server/tests/integration/member-agents-auto-bootstrap.test.ts b/server/tests/integration/member-agents-auto-bootstrap.test.ts index cf6de1a4c4..1c28b52848 100644 --- a/server/tests/integration/member-agents-auto-bootstrap.test.ts +++ b/server/tests/integration/member-agents-auto-bootstrap.test.ts @@ -203,7 +203,7 @@ describe('POST /api/me/agents (auto-bootstrap)', () => { const res = await request(app) .post('/api/me/agents') - .send({ url: 'https://agent.example.com/mcp', visibility: 'private' }); + .send({ url: 'https://agent.example.com/mcp', type: 'sales', visibility: 'private' }); expect(res.status).toBe(201); expect(res.body.profile_auto_created).toBe(true); @@ -227,13 +227,13 @@ describe('POST /api/me/agents (auto-bootstrap)', () => { const first = await request(app) .post('/api/me/agents') - .send({ url: 'https://agent-1.example.com/mcp', visibility: 'private' }); + .send({ url: 'https://agent-1.example.com/mcp', type: 'sales', visibility: 'private' }); expect(first.status).toBe(201); expect(first.body.profile_auto_created).toBe(true); const second = await request(app) .post('/api/me/agents') - .send({ url: 'https://agent-2.example.com/mcp', visibility: 'private' }); + .send({ url: 'https://agent-2.example.com/mcp', type: 'sales', visibility: 'private' }); expect(second.status).toBe(201); expect(second.body.profile_auto_created).toBeUndefined(); }); @@ -244,12 +244,12 @@ describe('POST /api/me/agents (auto-bootstrap)', () => { const first = await request(app) .post('/api/me/agents') - .send({ url: 'https://agent.example.com/mcp', visibility: 'private', name: 'v1' }); + .send({ url: 'https://agent.example.com/mcp', type: 'sales', visibility: 'private', name: 'v1' }); expect(first.status).toBe(201); const second = await request(app) .post('/api/me/agents') - .send({ url: 'https://agent.example.com/mcp', visibility: 'private', name: 'v2' }); + .send({ url: 'https://agent.example.com/mcp', type: 'sales', visibility: 'private', name: 'v2' }); expect(second.status).toBe(200); expect(second.body.profile_auto_created).toBeUndefined(); @@ -278,7 +278,7 @@ describe('POST /api/me/agents (auto-bootstrap)', () => { const res = await request(app) .post('/api/me/agents') - .send({ url: 'https://agent.boot-corp.test/mcp', visibility: 'private' }); + .send({ url: 'https://agent.boot-corp.test/mcp', type: 'sales', visibility: 'private' }); expect(res.status).toBe(201); expect(res.body.org_auto_created).toBe(true); @@ -321,7 +321,7 @@ describe('POST /api/me/agents (auto-bootstrap)', () => { const res = await request(app) .post('/api/me/agents') - .send({ url: 'https://agent.solo.test/mcp', visibility: 'private' }); + .send({ url: 'https://agent.solo.test/mcp', type: 'sales', visibility: 'private' }); expect(res.status).toBe(201); expect(res.body.org_auto_created).toBe(true); @@ -360,7 +360,7 @@ describe('POST /api/me/agents (auto-bootstrap)', () => { const res = await request(app) .post('/api/me/agents') - .send({ url: 'https://agent.no-fork.test/mcp', visibility: 'private' }); + .send({ url: 'https://agent.no-fork.test/mcp', type: 'sales', visibility: 'private' }); expect(res.status).toBe(201); // Profile auto-bootstrap fires (no profile yet on the existing org)