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
18 changes: 18 additions & 0 deletions .changeset/training-agent-sdk-6-multi-tenant.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
---

Migrate the training agent to `@adcp/sdk@6.0.0` and split it into six per-specialism tenants.

**SDK migration:** package renamed `@adcp/client@5.21.0` → `@adcp/sdk@^6.0.0`. 159 imports across `server/src` and `server/tests` updated. `createAdcpServer` (v5) imports moved to `@adcp/sdk/server/legacy/v5`. Resolves from npm registry; no worktree link.

**Multi-tenant training agent:** the single `/api/training-agent/mcp` URL is replaced with six per-specialism tenants — `/sales`, `/signals`, `/governance`, `/creative`, `/creative-builder`, `/brand` — each declaring its own specialism via the v6 `DecisioningPlatform` interface. Routing works for both the local mount (`/api/training-agent/<tenant>/mcp`) and host-based dispatch (`test-agent.adcontextprotocol.org/<tenant>/mcp`).

**Back-compat alias:** the legacy `/api/training-agent/mcp` continues to serve the v5 single-URL behavior with `Deprecation: true` and a `Link: rel="successor-version"` header pointing at `adagents.json`. AAO entries, Sage/Addie configs, docs, and external storyboard runners keep working unchanged on day 1; references migrate to per-tenant URLs over time.

**Error code canonicalization (F15):** lowercase v5-era codes (`brand_not_found`, `validation_error`, `not_found`, `invalid_request`, etc.) replaced with canonical uppercase codes (`BRAND_NOT_FOUND`, `VALIDATION_ERROR`, `REFERENCE_NOT_FOUND`, `CREATIVE_NOT_FOUND`, `SIGNAL_NOT_FOUND`, `INVALID_REQUEST`).

**Removed:** `framework-server.ts`, `v6-server.ts`, `v6-*.test.ts`, SSE/strict integration tests, framework-comply unit test — all rolled into the multi-tenant architecture.

371/371 tests passing. Storyboards 55–59/62 clean per tenant against AdCP 3.0.1 conformance suite.

Documentation references to the legacy URL (docs/, learning specialist pages, quickstart) are tracked as a separate follow-up PR.
25 changes: 25 additions & 0 deletions .changeset/training-agent-sdk-6-review-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
---

Address expert review punch list on the training-agent multi-tenant migration (`7974aeccd8`).

**Schema-conformant adagents.json**
- `/signals/mcp` entry now uses `authorization_type: 'signal_tags'` (the schema discriminator for signals agents) instead of `inline_properties`.
- Added `_training_agent_tenants` discovery extension listing all six per-specialism tenants with their URLs and specialisms — surfaces governance/creative/creative-builder/brand which don't fit the schema's `authorized_agents` discriminator.

**Security hardening**
- `noopJwksValidator` now throws at boot if `NODE_ENV=production` without an explicit `ALLOW_NOOP_JWKS_VALIDATOR=1` opt-in.
- Per-tenant signing kid generated via `randomBytes(4)` instead of `Math.random()`.
- `comply.ts` hardcoded principal documented as an SDK gap (ComplyControllerContext doesn't expose authInfo); session-state semantics in `tenants/registry.ts` doc-comment updated to be honest about cross-tenant shared state being intentional for sandbox scenarios.

**Test/dev URL surfaces**
- `PUBLIC_TEST_AGENT.url` defaults to `/sales/mcp` (the most common tenant for media-buy testing); `PUBLIC_TEST_AGENT_URLS` exposes all six per-specialism URLs plus the legacy alias.
- Addie's `member-tools.ts` redirect for `INTERNAL_PATH_AGENT_URL` now targets the legacy back-compat alias (preserves single-URL multi-tool semantics) instead of routing to a single specialism.

**Polish**
- Stale `tenants/registry.ts` header comment refreshed (was "Five tenants" + "only /signals registered"; now describes all six and the path-routing model).
- 3 `console.log` calls in `tenants/tenant-smoke.test.ts` removed.

**Deferred to upstream (filed as SDK feedback)**
- Wrong-tenant DX hint (`Tool 'X' lives on /sales/mcp`) — first attempt regressed `creative-builder` storyboards by 1 because the SDK storyboard runner's missing-tool detection (`/Unknown tool[:\s]/i` + `!taskResult`) doesn't classify any of `result.isError`, JSON-RPC `error`, or `adcp_error`-wrapped responses as a graceful skip. Needs SDK adjustment before we can ship the hint.
- `BRAND_NOT_FOUND` vs `REFERENCE_NOT_FOUND`: the SDK's bundled brand storyboard fixture explicitly enumerates `BRAND_NOT_FOUND` as canonical, contradicting universal `error-handling.mdx` which puts brands in the `REFERENCE_NOT_FOUND` fallback list. Kept `BRAND_NOT_FOUND` for storyboard conformance; filed feedback to reconcile the spec.
248 changes: 51 additions & 197 deletions .github/workflows/training-agent-storyboards.yml

Large diffs are not rendered by default.

11 changes: 6 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@
"dev:docs": "node scripts/dev-docs.mjs"
},
"dependencies": {
"@adcp/sdk": "5.25.1",
"@adcp/sdk": "^6.0.0",
"@anthropic-ai/sdk": "^0.91.1",
"@asteasolutions/zod-to-openapi": "^8.5.0",
"@contentauth/c2pa-node": "^0.5.4",
Expand Down
38 changes: 32 additions & 6 deletions server/src/addie/mcp/member-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const logger = createLogger('addie-member-tools');
import { classifyProbeError, probeReasonLabel } from '../../utils/probe-error.js';
import { validateExternalUrl } from '../../utils/url-security.js';
import { parseOAuthClientCredentialsInput } from '../../routes/helpers/oauth-client-credentials-input.js';
import { PUBLIC_TEST_AGENT, INTERNAL_PATH_AGENT_URL } from '../../config/test-agent.js';
import { PUBLIC_TEST_AGENT, PUBLIC_TEST_AGENT_URLS, INTERNAL_PATH_AGENT_URL } from '../../config/test-agent.js';
import type { AddieTool } from '../types.js';
import type { MemberContext } from '../member-context.js';
import { ToolError } from '../tool-error.js';
Expand Down Expand Up @@ -221,15 +221,41 @@ async function resolveAgentAuth(
): Promise<ResolvedAgentAuth> {
let resolvedUrl = agentUrl;

// Redirect internal path URL to canonical hostname
if (resolvedUrl.toLowerCase() === INTERNAL_PATH_AGENT_URL.toLowerCase()) {
resolvedUrl = PUBLIC_TEST_AGENT.url;
// Canonicalize for comparison: strip trailing slash, query string, and
// fragment so saved `agent_contexts` rows with cosmetic variations
// ("…/sales/mcp/", "…/sales/mcp?retry=1") still resolve to the public
// token path.
const canonicalize = (u: string): string => {
try {
const parsed = new URL(u);
// Drop fragment and query — they don't change which agent is being addressed.
parsed.hash = '';
parsed.search = '';
let pathname = parsed.pathname;
if (pathname.length > 1 && pathname.endsWith('/')) pathname = pathname.slice(0, -1);
return `${parsed.protocol}//${parsed.host}${pathname}`.toLowerCase();
} catch {
// Not a parseable URL — fall back to lowercased input so the
// includes-check still has stable semantics.
return u.toLowerCase();
}
};

// Redirect internal path URL to the legacy back-compat alias on the
// canonical hostname. Callers using INTERNAL_PATH_AGENT_URL are referencing
// the single-URL multi-tool agent — preserve that semantics by routing to
// the legacy alias (v5 monolith), not a per-specialism tenant.
if (canonicalize(resolvedUrl) === canonicalize(INTERNAL_PATH_AGENT_URL)) {
resolvedUrl = PUBLIC_TEST_AGENT_URLS.legacy;
}

// Public test agent always uses the known public token — saved or explicit tokens
// for this URL are ignored because they're likely incorrect (the public token is
// intentionally published and doesn't need per-user credentials).
if (resolvedUrl.toLowerCase() === PUBLIC_TEST_AGENT.url.toLowerCase()) {
// intentionally published and doesn't need per-user credentials). Match against
// any per-specialism URL or the legacy alias — they all hit the same Fly app.
const canonicalResolved = canonicalize(resolvedUrl);
const publicTestAgentUrls: string[] = Object.values(PUBLIC_TEST_AGENT_URLS).map(canonicalize);
if (publicTestAgentUrls.includes(canonicalResolved)) {
return { authToken: PUBLIC_TEST_AGENT.token, authType: 'bearer', source: 'public', resolvedUrl };
}

Expand Down
31 changes: 29 additions & 2 deletions server/src/config/test-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,39 @@
*
* The token can be overridden via PUBLIC_TEST_AGENT_TOKEN env var if needed,
* but defaults to the documented public token.
*
* URL: defaults to the `/sales/mcp` per-specialism tenant — most callers
* (`get_products`, `create_media_buy`, the demo flows in Sage and Addie)
* exercise the sales surface. Other specialisms live at sibling URLs:
* `/signals/mcp`, `/governance/mcp`, `/creative/mcp`,
* `/creative-builder/mcp`, `/brand/mcp`. The legacy single-URL
* `/mcp` continues to serve the v5 monolith via the back-compat alias for
* AAO entries and external callers that haven't migrated.
*/
export const PUBLIC_TEST_AGENT = {
url: process.env.PUBLIC_TEST_AGENT_URL || 'https://test-agent.adcontextprotocol.org/mcp',
url: process.env.PUBLIC_TEST_AGENT_URL || 'https://test-agent.adcontextprotocol.org/sales/mcp',
token: process.env.PUBLIC_TEST_AGENT_TOKEN || '1v8tAhASaUYYp' + '4odoQ1PnMpdqNaMiTrCRqYo9OJp6IQ',
name: 'AdCP Public Test Agent',
};

// Internal path URL — redirect to the canonical hostname
/**
* Per-specialism test-agent URLs. Use when steering a caller at a non-sales
* specialism (e.g., signals labs, governance demos). All resolve to the
* same Fly app via host-based dispatch.
*/
export const PUBLIC_TEST_AGENT_URLS = {
sales: 'https://test-agent.adcontextprotocol.org/sales/mcp',
signals: 'https://test-agent.adcontextprotocol.org/signals/mcp',
governance: 'https://test-agent.adcontextprotocol.org/governance/mcp',
creative: 'https://test-agent.adcontextprotocol.org/creative/mcp',
'creative-builder': 'https://test-agent.adcontextprotocol.org/creative-builder/mcp',
brand: 'https://test-agent.adcontextprotocol.org/brand/mcp',
/** Back-compat alias serving the v5 single-URL training agent. */
legacy: 'https://test-agent.adcontextprotocol.org/mcp',
} as const;

// Internal path URL — redirect to the canonical hostname.
// Kept as the legacy single-URL form because existing `agent_contexts` rows
// reference it; the redirect target (PUBLIC_TEST_AGENT.url) is now the sales
// tenant on the canonical hostname.
export const INTERNAL_PATH_AGENT_URL = 'https://agenticadvertising.org/api/training-agent/mcp';
28 changes: 17 additions & 11 deletions server/src/training-agent/brand-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -670,7 +670,13 @@ export function handleGetBrandIdentity(

const talent = BRAND_MAP.get(brandId);
if (!talent) {
return { errors: [{ code: 'brand_not_found', message: `No brand with id '${brandId}'` }] };
// BRAND_NOT_FOUND per the brand-protocol conformance suite
// (`@adcp/sdk/compliance/.../domains/brand/index.yaml` enumerates
// brand_not_found / BRAND_NOT_FOUND / NOT_FOUND as canonical) and
// skills/adcp-brand/SKILL.md. The universal error-handling.mdx puts
// brands in the REFERENCE_NOT_FOUND fallback list, which contradicts
// the brand-specific storyboard — feedback filed upstream to reconcile.
return { errors: [{ code: 'BRAND_NOT_FOUND', message: `No brand with id '${brandId}'` }] };
}

const requested = fields ?? [...ALL_FIELDS];
Expand Down Expand Up @@ -907,7 +913,7 @@ export async function handleAcquireRights(
const campaign = req.campaign;

if (!buyer) {
return { errors: [{ code: 'invalid_request', message: 'buyer is required' }] };
return { errors: [{ code: 'INVALID_REQUEST', message: 'buyer is required' }] };
}

let talent: TalentEntry | undefined;
Expand All @@ -922,16 +928,16 @@ export async function handleAcquireRights(
}

if (!talent || !offering) {
return { errors: [{ code: 'rights_not_found', message: `No rights offering with id '${rightsId}'` }] };
return { errors: [{ code: 'REFERENCE_NOT_FOUND', message: `No rights offering with id '${rightsId}'` }] };
}

const pricingOption = offering.pricing_options.find(p => p.pricing_option_id === pricingOptionId);
if (!pricingOption) {
return { errors: [{ code: 'invalid_pricing_option', message: `No pricing option '${pricingOptionId}' in offering '${rightsId}'` }] };
return { errors: [{ code: 'INVALID_REQUEST', message: `No pricing option '${pricingOptionId}' in offering '${rightsId}'` }] };
}

if (!campaign?.description) {
return { errors: [{ code: 'invalid_request', message: 'campaign.description is required' }] };
return { errors: [{ code: 'INVALID_REQUEST', message: 'campaign.description is required' }] };
}

if (campaign.end_date) {
Expand Down Expand Up @@ -1134,18 +1140,18 @@ export function handleUpdateRights(
}

if (!talent || !offering) {
return { errors: [{ code: 'rights_not_found', message: `No active grant with id '${rightsId}'` }] };
return { errors: [{ code: 'REFERENCE_NOT_FOUND', message: `No active grant with id '${rightsId}'` }] };
}

const currentEndDate = '2026-06-30';
const currentStartDate = '2026-04-01';
if (endDate && endDate < currentEndDate) {
return { errors: [{ code: 'invalid_update', message: 'New end_date must be >= current end_date' }] };
return { errors: [{ code: 'INVALID_REQUEST', message: 'New end_date must be >= current end_date' }] };
}

const deliveredImpressions = 50000;
if (impressionCap !== undefined && impressionCap < deliveredImpressions) {
return { errors: [{ code: 'invalid_update', message: `New impression_cap (${impressionCap}) must be >= impressions already delivered (${deliveredImpressions})` }] };
return { errors: [{ code: 'INVALID_REQUEST', message: `New impression_cap (${impressionCap}) must be >= impressions already delivered (${deliveredImpressions})` }] };
}

const pricingOption = offering.pricing_options[0];
Expand Down Expand Up @@ -1226,19 +1232,19 @@ export function handleCreativeApproval(

const rightsId = req.rights_id || req.rights_grant_id;
if (!rightsId) {
return { errors: [{ code: 'invalid_request', message: 'rights_id or rights_grant_id is required' }] };
return { errors: [{ code: 'INVALID_REQUEST', message: 'rights_id or rights_grant_id is required' }] };
}

const isKnownOffering = TALENT.some(t =>
t.rights_offerings.some(r => r.rights_id === rightsId)
);
if (!isKnownOffering) {
return { errors: [{ code: 'rights_not_found', message: `No rights offering with id '${rightsId}'. Acquire rights first using acquire_rights.` }] };
return { errors: [{ code: 'REFERENCE_NOT_FOUND', message: `No rights offering with id '${rightsId}'. Acquire rights first using acquire_rights.` }] };
}

const creativeUrl = req.creative_url || req.creative?.assets?.[0]?.url;
if (!creativeUrl) {
return { errors: [{ code: 'invalid_request', message: 'creative_url or creative.assets[].url is required' }] };
return { errors: [{ code: 'INVALID_REQUEST', message: 'creative_url or creative.assets[].url is required' }] };
}

const creativeId = req.creative_id || req.creative?.creative_id || 'sandbox_creative';
Expand Down
8 changes: 4 additions & 4 deletions server/src/training-agent/content-standards-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ export async function handleGetContentStandards(

const state = session.contentStandards.get(req.standards_id);
if (!state) {
return { errors: [{ code: 'not_found', message: `No content standards with id '${req.standards_id}'` }] };
return { errors: [{ code: 'REFERENCE_NOT_FOUND', message: `No content standards with id '${req.standards_id}'` }] };
}

return {
Expand Down Expand Up @@ -315,7 +315,7 @@ export async function handleUpdateContentStandards(

const state = session.contentStandards.get(req.standards_id);
if (!state) {
return { errors: [{ code: 'not_found', message: `No content standards with id '${req.standards_id}'` }] };
return { errors: [{ code: 'REFERENCE_NOT_FOUND', message: `No content standards with id '${req.standards_id}'` }] };
}

if (req.scope) {
Expand Down Expand Up @@ -350,7 +350,7 @@ export async function handleCalibrateContent(

const state = session.contentStandards.get(req.standards_id);
if (!state) {
return { errors: [{ code: 'not_found', message: `No content standards with id '${req.standards_id}'` }] };
return { errors: [{ code: 'REFERENCE_NOT_FOUND', message: `No content standards with id '${req.standards_id}'` }] };
}

// Sandbox heuristic: scan the artifact's text fields for must-rule keywords
Expand Down Expand Up @@ -432,7 +432,7 @@ export async function handleValidateContentDelivery(

const state = session.contentStandards.get(req.standards_id);
if (!state) {
return { errors: [{ code: 'not_found', message: `No content standards with id '${req.standards_id}'` }] };
return { errors: [{ code: 'REFERENCE_NOT_FOUND', message: `No content standards with id '${req.standards_id}'` }] };
}

const records = req.records || [];
Expand Down
Loading
Loading