From d49d678cec2d479c0ff926f76b3ca9eb9822211a Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 30 Apr 2026 19:35:22 -0400 Subject: [PATCH 1/5] feat(training-agent)!: migrate to @adcp/sdk@6.0.0 + split into per-specialism tenants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SDK migration: @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: single /api/training-agent/mcp URL replaced with six per-specialism tenants — /sales, /signals, /governance, /creative, /creative-builder, /brand. Each declares its own specialism via the v6 DecisioningPlatform interface. Routing works for both the local mount (/api/training-agent//mcp) and host-based dispatch (test-agent.adcontextprotocol.org//mcp) — handler binds tenantId at route definition and resolves via registry.resolveByRequest with the canonical host, independent of request URL parsing. Back-compat alias: legacy /api/training-agent/mcp continues to serve the v5 single-URL behavior with Deprecation: true and Link: rel="successor-version" pointing at adagents.json. AAO entries, Sage/Addie configs, docs, and external storyboard runners keep working unchanged on day 1. Error code canonicalization (F15): lowercase v5-era codes (brand_not_found, validation_error, not_found, invalid_request, invalid_update, invalid_pricing_option, rights_not_found, etc.) replaced with canonical uppercase codes (BRAND_NOT_FOUND, VALIDATION_ERROR, REFERENCE_NOT_FOUND, CREATIVE_NOT_FOUND, SIGNAL_NOT_FOUND, INVALID_REQUEST). Test assertions updated to match. Removed: framework-server.ts, v6-server.ts, v6-*.test.ts, SSE/strict integration tests, framework-comply unit test — all subsumed by the multi-tenant architecture. 371/371 training-agent tests passing (354 unit + 6 demo-key + 3 webhook + 8 legacy/host-based). Full suite passing (835/835 incl. 38 brand-sandbox tool assertions updated for the F15 codes). Storyboards 55–59/62 clean per tenant against AdCP 3.0.1 conformance suite. Documentation references to the legacy URL tracked as a separate follow-up PR. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../training-agent-sdk-6-multi-tenant.md | 18 + package-lock.json | 11 +- package.json | 2 +- server/src/training-agent/brand-handlers.ts | 22 +- .../content-standards-handlers.ts | 8 +- server/src/training-agent/framework-server.ts | 706 ----------------- .../src/training-agent/governance-handlers.ts | 48 +- server/src/training-agent/index.ts | 710 ++++-------------- server/src/training-agent/task-handlers.ts | 6 +- server/src/training-agent/tenants/brand.ts | 88 +++ server/src/training-agent/tenants/comply.ts | 201 +++++ .../tenants/creative-builder.ts | 33 + server/src/training-agent/tenants/creative.ts | 30 + .../tenants/custom-tool-helper.ts | 116 +++ .../src/training-agent/tenants/governance.ts | 37 + server/src/training-agent/tenants/registry.ts | 181 +++++ server/src/training-agent/tenants/router.ts | 155 ++++ server/src/training-agent/tenants/sales.ts | 33 + server/src/training-agent/tenants/signals.ts | 31 + server/src/training-agent/tenants/signing.ts | 94 +++ .../tenants/tenant-smoke.test.ts | 108 +++ .../src/training-agent/v6-brand-platform.ts | 131 ++++ .../v6-creative-builder-platform.ts | 143 ++++ .../training-agent/v6-creative-platform.ts | 143 ++++ .../training-agent/v6-governance-platform.ts | 239 ++++++ server/src/training-agent/v6-platform.ts | 167 ++++ .../src/training-agent/v6-sales-platform.ts | 207 +++++ .../training-agent-demo-key.test.ts | 2 +- .../training-agent-legacy-mcp.test.ts | 155 ++++ .../integration/training-agent-sse.test.ts | 177 ----- .../integration/training-agent-strict.test.ts | 272 ------- .../training-agent-webhooks.test.ts | 4 +- server/tests/manual/run-storyboards.ts | 20 +- .../unit/storyboard-fix-plan-e2e.test.ts | 2 +- .../training-agent-framework-comply.test.ts | 160 ---- server/tests/unit/training-agent.test.ts | 26 +- tests/addie/brand-sandbox-tools.test.ts | 14 +- 37 files changed, 2562 insertions(+), 1938 deletions(-) create mode 100644 .changeset/training-agent-sdk-6-multi-tenant.md delete mode 100644 server/src/training-agent/framework-server.ts create mode 100644 server/src/training-agent/tenants/brand.ts create mode 100644 server/src/training-agent/tenants/comply.ts create mode 100644 server/src/training-agent/tenants/creative-builder.ts create mode 100644 server/src/training-agent/tenants/creative.ts create mode 100644 server/src/training-agent/tenants/custom-tool-helper.ts create mode 100644 server/src/training-agent/tenants/governance.ts create mode 100644 server/src/training-agent/tenants/registry.ts create mode 100644 server/src/training-agent/tenants/router.ts create mode 100644 server/src/training-agent/tenants/sales.ts create mode 100644 server/src/training-agent/tenants/signals.ts create mode 100644 server/src/training-agent/tenants/signing.ts create mode 100644 server/src/training-agent/tenants/tenant-smoke.test.ts create mode 100644 server/src/training-agent/v6-brand-platform.ts create mode 100644 server/src/training-agent/v6-creative-builder-platform.ts create mode 100644 server/src/training-agent/v6-creative-platform.ts create mode 100644 server/src/training-agent/v6-governance-platform.ts create mode 100644 server/src/training-agent/v6-platform.ts create mode 100644 server/src/training-agent/v6-sales-platform.ts create mode 100644 server/tests/integration/training-agent-legacy-mcp.test.ts delete mode 100644 server/tests/integration/training-agent-sse.test.ts delete mode 100644 server/tests/integration/training-agent-strict.test.ts delete mode 100644 server/tests/unit/training-agent-framework-comply.test.ts diff --git a/.changeset/training-agent-sdk-6-multi-tenant.md b/.changeset/training-agent-sdk-6-multi-tenant.md new file mode 100644 index 0000000000..7a2b497537 --- /dev/null +++ b/.changeset/training-agent-sdk-6-multi-tenant.md @@ -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//mcp`) and host-based dispatch (`test-agent.adcontextprotocol.org//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. diff --git a/package-lock.json b/package-lock.json index 5dd6d52f18..dd5b5efb69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "adcontextprotocol", "version": "3.0.3", "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", @@ -122,9 +122,9 @@ } }, "node_modules/@adcp/sdk": { - "version": "5.25.1", - "resolved": "https://registry.npmjs.org/@adcp/sdk/-/sdk-5.25.1.tgz", - "integrity": "sha512-53WiJqlYcxcsrFs7GkeV7ibTCc5yNTMqud5+3bo+9/8nM0ha1KiZ24lCqcQUiYlG9RYX+9ekPV2YVS9B2tV+KA==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@adcp/sdk/-/sdk-6.0.0.tgz", + "integrity": "sha512-mg2BNZhCGphrbs63+P7yJdTKKEqUV4ACtLhfWI8xD94QLoyrg4dJOP+4q0jEthSxTmuhOe5vtsapOzJBrH3udQ==", "license": "Apache-2.0", "workspaces": [ ".", @@ -149,7 +149,8 @@ "@a2a-js/sdk": "^0.3.4", "@modelcontextprotocol/sdk": "^1.17.5", "@opentelemetry/api": "^1.0.0", - "pg": "^8.0.0" + "pg": "^8.0.0", + "zod": "^4.1.0" }, "peerDependenciesMeta": { "@opentelemetry/api": { diff --git a/package.json b/package.json index 1d0b1d1b51..c78017fd2e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/src/training-agent/brand-handlers.ts b/server/src/training-agent/brand-handlers.ts index a3b6f258b0..a5788169e2 100644 --- a/server/src/training-agent/brand-handlers.ts +++ b/server/src/training-agent/brand-handlers.ts @@ -670,7 +670,7 @@ export function handleGetBrandIdentity( const talent = BRAND_MAP.get(brandId); if (!talent) { - return { errors: [{ code: 'brand_not_found', message: `No brand with id '${brandId}'` }] }; + return { errors: [{ code: 'BRAND_NOT_FOUND', message: `No brand with id '${brandId}'` }] }; } const requested = fields ?? [...ALL_FIELDS]; @@ -907,7 +907,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; @@ -922,16 +922,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) { @@ -1134,18 +1134,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]; @@ -1226,19 +1226,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'; diff --git a/server/src/training-agent/content-standards-handlers.ts b/server/src/training-agent/content-standards-handlers.ts index cbff8afd0e..aaa2921068 100644 --- a/server/src/training-agent/content-standards-handlers.ts +++ b/server/src/training-agent/content-standards-handlers.ts @@ -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 { @@ -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) { @@ -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 @@ -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 || []; diff --git a/server/src/training-agent/framework-server.ts b/server/src/training-agent/framework-server.ts deleted file mode 100644 index a4e83f15c7..0000000000 --- a/server/src/training-agent/framework-server.ts +++ /dev/null @@ -1,706 +0,0 @@ -/** - * createAdcpServer-based training agent server (behind feature flag). - * - * Routes all spec-declared tools through `@adcp/sdk/server`'s - * `createAdcpServer` domain-grouped config so idempotency, capability - * declaration, signed-requests verification, and webhook emission are - * handled by the framework rather than our hand-rolled dispatch. - * - * Handlers emit pre-formatted `CallToolResult` envelopes so the - * framework's `isFormattedResponse` check passes through — response - * bytes remain byte-identical to the legacy dispatch path, which keeps - * every existing unit test and storyboard valid without re-baselining. - * - * Custom tools outside `AdcpToolMap` (`creative_approval`, - * `update_rights`, `comply_test_controller`, `validate_property_delivery`, - * the five collection-list endpoints) register directly on the returned - * server via `registerTool` after `createAdcpServer` returns. - * - * Default dispatch path since both modes hit 52/52 storyboard parity. - * Legacy stays reachable via `TRAINING_AGENT_USE_FRAMEWORK=0` for one - * release as an escape hatch; a follow-up PR deletes the legacy dispatch - * after burn-in. - */ - -import { createAdcpServer, wrapEnvelope } from '@adcp/sdk/server'; -import { mergeSeedProduct } from '@adcp/sdk/testing'; -import type { HandlerContext, AdcpServerToolName, AdcpServer, AdcpCustomToolConfig } from '@adcp/sdk/server'; -import { MediaChannelSchema } from '@adcp/sdk/types'; -import type { Product } from '@adcp/sdk'; -import { z } from 'zod'; -import type { TrainingContext, ToolArgs, AccountRef, BrandRef } from './types.js'; -import { getIdempotencyStore, scopedPrincipal } from './idempotency.js'; -import { getWebhookSigningMaterial, maybeEmitCompletionWebhook } from './webhooks.js'; -import { selectSigningCapability } from './request-signing.js'; -import { PUBLISHERS } from './publishers.js'; -import { getSession, runWithSessionContext, flushDirtySessions, sessionKeyFromArgs } from './state.js'; -import { createLogger } from '../logger.js'; - -import { - handleGetProducts, - handleCreateMediaBuy, - handleUpdateMediaBuy, - handleGetMediaBuys, - handleGetMediaBuyDelivery, - handleGetCreativeDelivery, - handleSyncCreatives, - handleListCreatives, - handleBuildCreative, - handlePreviewCreative, - handleListCreativeFormats, - handleGetSignals, - handleActivateSignal, - handleReportUsage, -} from './task-handlers.js'; -import { handleSyncAccounts, handleSyncGovernance, handleListAccounts } from './account-handlers.js'; -import { - handleSyncCatalogs, - handleSyncEventSources, - handleLogEvent, - handleProvidePerformanceFeedback, -} from './catalog-event-handlers.js'; -import { - handleSyncPlans, - handleCheckGovernance, - handleReportPlanOutcome, - handleGetPlanAuditLogs, -} from './governance-handlers.js'; -import { - handleGetBrandIdentity, - handleGetRights, - handleAcquireRights, - handleUpdateRights, - handleCreativeApproval, -} from './brand-handlers.js'; -import { - handleCreatePropertyList, - handleListPropertyLists, - handleGetPropertyList, - handleUpdatePropertyList, - handleDeletePropertyList, - handleValidatePropertyDelivery, -} from './property-handlers.js'; -import { - handleCreateCollectionList, - handleGetCollectionList, - handleUpdateCollectionList, - handleListCollectionLists, - handleDeleteCollectionList, -} from './inventory-governance-handlers.js'; -import { - handleCreateContentStandards, - handleListContentStandards, - handleGetContentStandards, - handleUpdateContentStandards, - handleCalibrateContent, - handleValidateContentDelivery, -} from './content-standards-handlers.js'; -import { handleComplyTestController } from './comply-test-controller.js'; - -const logger = createLogger('training-agent-framework'); - -const SUPPORTED_MAJOR_VERSIONS = [3] as const; - -// Baseline seeded-product fields — fills in the Product response-schema -// minimums (description, publisher_properties, format_ids, pricing_options, -// reporting_capabilities, delivery_type) so storyboards that seed a sparse -// `{ name, channels }` fixture still emit a schema-valid product. -const SEED_PRODUCT_DEFAULTS: Partial = { - description: 'Seeded sandbox fixture product', - delivery_type: 'non_guaranteed', - publisher_properties: [], - format_ids: [], - pricing_options: [], - reporting_capabilities: { - available_metrics: [], - available_reporting_frequencies: ['daily'], - expected_delay_minutes: 240, - timezone: 'UTC', - supports_webhooks: false, - date_range_support: 'date_range', - }, -}; - -// ── Types ──────────────────────────────────────────────────────── - -type LegacyHandler = (args: ToolArgs, ctx: TrainingContext) => object | Promise; - -interface InlineError { - code: string; - message: string; - field?: string; - details?: unknown; - recovery?: string; -} - -/** - * Shape satisfies the framework's `McpToolResponse` (content + non-null - * structuredContent) and the MCP SDK's `CallToolResult` (content + - * optional structuredContent). Index signature keeps it assignable to - * `Record` so both `DomainHandler` and `ToolCallback` - * return-type unions accept it. - */ -interface AdaptedResponse { - content: Array<{ type: 'text'; text: string }>; - structuredContent: Record; - isError?: boolean; - [key: string]: unknown; -} - -// ── Response shaping ───────────────────────────────────────────── - -function toAdaptedResponse(result: unknown, callerContext: unknown): AdaptedResponse { - const errsField = (result as { errors?: unknown[] } | null | undefined)?.errors; - if (Array.isArray(errsField) && errsField.length > 0) { - const first = errsField[0] as InlineError; - const errorObj: Record = { code: first.code, message: first.message }; - if (first.field) errorObj.field = first.field; - if (first.details !== undefined) errorObj.details = first.details; - if (first.recovery) errorObj.recovery = first.recovery; - const body = wrapEnvelope({ adcp_error: errorObj }, { context: callerContext }); - return { - isError: true, - content: [{ type: 'text', text: JSON.stringify(body) }], - structuredContent: body, - }; - } - const inner = (result ?? {}) as Record; - // wrapEnvelope stamps the AdCP context-echo envelope. `replayed` is - // intentionally NOT set here — per protocol-envelope.json and the - // SDK's injectReplayed helper, fresh executions MUST omit the field - // (the framework stamps `replayed: true` only on idempotency replays). - const withEnvelope = wrapEnvelope(inner, { - ...(callerContext !== undefined && typeof callerContext === 'object' && callerContext !== null - ? { context: callerContext } - : {}), - }); - const response = withEnvelope as Record; - return { - content: [{ type: 'text', text: JSON.stringify(response) }], - structuredContent: response, - }; -} - -function serviceUnavailable(err: unknown, callerContext: unknown): AdaptedResponse { - const errorObj: Record = { - code: 'SERVICE_UNAVAILABLE', - message: err instanceof Error ? err.message : 'Unknown error', - recovery: 'transient', - }; - const body = wrapEnvelope({ adcp_error: errorObj }, { context: callerContext }); - return { - isError: true, - content: [{ type: 'text', text: JSON.stringify(body) }], - structuredContent: body, - }; -} - -function versionUnsupported(requested: unknown, callerContext: unknown): AdaptedResponse { - const errorObj: Record = { - code: 'VERSION_UNSUPPORTED', - message: `AdCP major version ${String(requested)} is not supported`, - details: { supported_major_versions: SUPPORTED_MAJOR_VERSIONS }, - field: 'adcp_major_version', - }; - const body = wrapEnvelope({ adcp_error: errorObj }, { context: callerContext }); - return { - isError: true, - content: [{ type: 'text', text: JSON.stringify(body) }], - structuredContent: body, - }; -} - -// ── Handler adapter ────────────────────────────────────────────── - -/** - * Convert a legacy `(args, TrainingContext)` handler into the framework's - * `(params, HandlerContext) => Promise` signature. - * - * - Enforces `VERSION_UNSUPPORTED` for `adcp_major_version !== 3` (legacy - * dispatch behavior, not yet in the framework). - * - Strips `context` from params before calling the handler, then re-stamps - * it on the response (so handlers never see or forward `context` and the - * framework's own injectContextIntoResponse doesn't double-echo). - * - Wraps thrown exceptions as `SERVICE_UNAVAILABLE` per legacy behavior. - * - Fires a completion webhook after a successful handler when the buyer - * supplied `push_notification_config.url` and the tool maps to a webhook - * task type. Matches legacy dispatch behavior in `task-handlers.ts`. - */ -function adapt(toolName: string, handler: LegacyHandler) { - return async (params: unknown, ctx: HandlerContext): Promise => { - const rawParams = (params as Record | undefined) ?? {}; - const { context: callerContext, ...handlerArgs } = rawParams; - - const requestedVersion = (handlerArgs as { adcp_major_version?: unknown }).adcp_major_version; - if ( - requestedVersion !== undefined - && !(SUPPORTED_MAJOR_VERSIONS as readonly number[]).includes(requestedVersion as number) - ) { - return versionUnsupported(requestedVersion, callerContext); - } - - const trainingCtx: TrainingContext = { - mode: 'open', - principal: ctx.authInfo?.clientId ?? 'anonymous', - }; - - return runWithSessionContext(async () => { - let result: unknown; - try { - result = await Promise.resolve(handler(handlerArgs as ToolArgs, trainingCtx)); - } catch (err) { - logger.error({ err }, 'framework handler threw'); - return serviceUnavailable(err, callerContext); - } - try { - await flushDirtySessions(); - } catch (err) { - logger.error({ err }, 'framework flushDirtySessions threw'); - return serviceUnavailable(err, callerContext); - } - const response = toAdaptedResponse(result, callerContext); - if (!response.isError) { - const idk = (handlerArgs as { idempotency_key?: unknown }).idempotency_key; - maybeEmitCompletionWebhook({ - toolName, - args: handlerArgs as Record, - response: (result ?? {}) as Record, - requestIdempotencyKey: typeof idk === 'string' ? idk : undefined, - principal: scopedWebhookPrincipal(ctx, handlerArgs as Record), - }); - } - return response; - }); - }; -} - -// ── Resolver hooks ─────────────────────────────────────────────── - -function deriveAccountScope(params: Record): string | undefined { - const account = params.account as { account_id?: string; brand?: { domain?: string } } | undefined; - if (account?.account_id && typeof account.account_id === 'string') { - return `a:${account.account_id}`; - } - const domain = account?.brand?.domain - ?? (params.brand as { domain?: string } | undefined)?.domain; - if (typeof domain === 'string' && domain.length > 0) { - return `b:${domain.toLowerCase()}`; - } - return undefined; -} - -/** Scoped principal for webhook idempotency. Mirrors the - * `resolveIdempotencyPrincipal` rule on the AdcpServer config below: only - * `static:public` (the shared sandbox token) needs account-level partitioning; - * other principals are single-caller and use the auth principal directly. - * Delegates to `scopedPrincipal` so the partition format stays defined in - * one place and can never drift from the request-side cache. */ -function scopedWebhookPrincipal(ctx: HandlerContext, params: Record): string { - const auth = ctx.authInfo?.clientId ?? 'anonymous'; - if (auth !== 'static:public') return auth; - return scopedPrincipal(auth, deriveAccountScope(params)); -} - -// ── Server factory ────────────────────────────────────────────── - -/** - * Build the framework-based training-agent MCP server. Returns the - * opaque `AdcpServer` handle from `@adcp/sdk/server` — no SDK types - * escape our module boundary. - */ -export function createFrameworkTrainingAgentServer(ctx: TrainingContext): AdcpServer { - const signingCap = selectSigningCapability(ctx); - - // ── Custom tools outside AdcpToolMap ───────────────────────── - // Registered through the framework's `customTools` config (5.4). - // Each tool ships a real zod inputSchema so `tools/list` publishes the - // actual argument contract — MCP clients (Claude Desktop, inspector, - // schema-driven callers) see the real fields instead of a `_passthrough` - // placeholder. Handlers still do semantic validation (NOT_FOUND, - // VALIDATION_ERROR); zod only gates type shape at the MCP surface. - // - // Schemas are permissive (`.passthrough()` / `z.any()` nested) because - // the training agent emulates a full seller/brand and accepts spec - // payload variants that evolve faster than we want to tighten here. - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - function customToolFor(name: string, description: string, inputSchema: Record, handler: LegacyHandler): AdcpCustomToolConfig { - return { - description, - inputSchema, - handler: async (args: unknown, extra: unknown) => { - const params = (args as Record) ?? {}; - const authInfo = ((extra as { authInfo?: { clientId?: string } } | undefined)?.authInfo) ?? undefined; - const trainingCtx: TrainingContext = { - mode: 'open', - principal: authInfo?.clientId ?? 'anonymous', - }; - const { context: callerContext, ...handlerArgs } = params; - return runWithSessionContext(async () => { - let result: unknown; - try { - result = await Promise.resolve(handler(handlerArgs as ToolArgs, trainingCtx)); - } catch (err) { - logger.error({ err, tool: name }, 'framework custom-tool handler threw'); - return serviceUnavailable(err, callerContext); - } - try { - await flushDirtySessions(); - } catch (err) { - logger.error({ err, tool: name }, 'framework custom-tool flushDirtySessions threw'); - return serviceUnavailable(err, callerContext); - } - return toAdaptedResponse(result, callerContext); - }); - }, - }; - } - - const ACCOUNT_REF = z.object({ - publisher_id: z.string().optional(), - buyer_id: z.string().optional(), - sandbox: z.boolean().optional(), - }).passthrough().optional(); - - const BRAND_REF = z.object({ - domain: z.string().optional(), - }).passthrough().optional(); - - const CONTEXT_REF = z.any().optional(); - - const CREATIVE_APPROVAL_SCHEMA = { - rights_id: z.string().optional(), - rights_grant_id: z.string().optional(), - creative_url: z.string().optional(), - creative_id: z.string().optional(), - creative_format: z.string().optional(), - creative: z.object({ - creative_id: z.string().optional(), - format: z.string().optional(), - assets: z.array(z.any()).optional(), - }).passthrough().optional(), - account: ACCOUNT_REF, - brand: BRAND_REF, - context: CONTEXT_REF, - }; - - const UPDATE_RIGHTS_SCHEMA = { - rights_id: z.string(), - end_date: z.string().optional(), - impression_cap: z.number().optional(), - paused: z.boolean().optional(), - account: ACCOUNT_REF, - brand: BRAND_REF, - context: CONTEXT_REF, - }; - - const VALIDATE_PROPERTY_DELIVERY_SCHEMA = { - list_id: z.string(), - account: ACCOUNT_REF, - records: z.array(z.object({ - identifier: z.object({ type: z.string(), value: z.string() }).passthrough(), - impressions: z.number().int().min(0), - record_id: z.string().optional(), - }).passthrough()), - brand: BRAND_REF, - context: CONTEXT_REF, - }; - - const CREATE_COLLECTION_LIST_SCHEMA = { - name: z.string(), - description: z.string().optional(), - base_collections: z.array(z.any()).optional(), - filters: z.record(z.string(), z.any()).optional(), - account: ACCOUNT_REF, - brand: BRAND_REF, - context: CONTEXT_REF, - }; - - const GET_COLLECTION_LIST_SCHEMA = { - list_id: z.string(), - resolve: z.boolean().optional(), - account: ACCOUNT_REF, - brand: BRAND_REF, - context: CONTEXT_REF, - }; - - const UPDATE_COLLECTION_LIST_SCHEMA = { - list_id: z.string(), - name: z.string().optional(), - description: z.string().optional(), - base_collections: z.array(z.any()).optional(), - filters: z.record(z.string(), z.any()).optional(), - webhook_url: z.string().optional(), - account: ACCOUNT_REF, - brand: BRAND_REF, - context: CONTEXT_REF, - }; - - const LIST_COLLECTION_LISTS_SCHEMA = { - name_contains: z.string().optional(), - account: ACCOUNT_REF, - brand: BRAND_REF, - context: CONTEXT_REF, - pagination: z.object({ - max_results: z.number().int().min(1).max(100).optional(), - cursor: z.string().optional(), - }).optional(), - }; - - const DELETE_COLLECTION_LIST_SCHEMA = { - list_id: z.string(), - account: ACCOUNT_REF, - brand: BRAND_REF, - context: CONTEXT_REF, - }; - - // `scenario` stays an open string rather than z.enum so unrecognized - // scenarios reach the SDK handler and get a typed `UNKNOWN_SCENARIO` - // response envelope. A zod enum here would reject at MCP input validation, - // returning a generic validation error without the controller's context - // echo — breaking the deterministic_testing storyboard's unknown-scenario - // probe. Seed scenarios (seed_product, seed_creative, etc.) are also - // accepted here for the same reason. - const COMPLY_TEST_CONTROLLER_SCHEMA = { - scenario: z.string(), - params: z.record(z.string(), z.any()).optional(), - account: ACCOUNT_REF, - brand: BRAND_REF, - context: CONTEXT_REF, - }; - - const allChannels = [...new Set(PUBLISHERS.flatMap(p => p.channels))] - .map(c => MediaChannelSchema.parse(c)) - .sort(); - - const server = createAdcpServer({ - name: 'adcp-training-agent', - version: '1.0.0', - - idempotency: getIdempotencyStore(), - webhooks: getWebhookSigningMaterial(), - - // Only `static:public` is account-scoped: it's the shared sandbox token, - // so unscoped idempotency keys would collide across callers. Other - // principals (`workos:`, `static:primary`, `signing:`) are - // single-caller and use the auth principal directly — callers that want - // account isolation within one org include `account.{publisher,buyer}_id` - // in the idempotency key payload, which the SDK's canonical hash already - // differentiates. Revisit if a shared-token pattern emerges for orgs. - resolveIdempotencyPrincipal: (ctx: HandlerContext, params: Record, _toolName: AdcpServerToolName) => { - const auth = ctx.authInfo?.clientId ?? 'anonymous'; - if (auth !== 'static:public') return auth; - const scope = deriveAccountScope(params); - return `${auth}\u001F${scope ?? ''}`; - }, - - // Seeded-product bridge: flow `comply_test_controller.seed_product` - // fixtures into `get_products` responses on sandbox requests. Our seed - // store is session-scoped (one Map per brand.domain/account_id), so - // the SDK's `bridgeFromTestControllerStore(store, defaults)` helper - // (which closes over a `Map` at server-construction - // time, before the request is parsed) doesn't fit. We session-load in - // the callback and run each fixture through `mergeSeedProduct` on top - // of `SEED_PRODUCT_DEFAULTS` (the response-schema minimum fields). - // `bridgeFromSessionStore` in @adcp/sdk 5.14+ collapses this to a - // one-liner — pending adcp-client#866 (5.14 storyboard regression). - // - // Security: the dispatcher's sandbox gate is `isSandboxRequest(params)` - // + (when `resolveAccount` is wired) `ctx.account.sandbox === true`. - // We don't wire `resolveAccount` — by design for the training agent — - // so the *only* fence is `isSandboxRequest`. Sessions are keyed by - // `brand.domain` / `account_id`, not by auth principal. Any caller - // authenticated via the training-agent bearer authenticator that sends - // `context.sandbox: true` (or `account.sandbox: true`) plus another - // caller's `brand.domain` will read that tenant's seeded fixtures. - // Acceptable for this surface: fixture data is non-sensitive by design - // (see `buildBearerAuthenticator` in ./index.ts for the sandbox - // security posture). Production sellers adopting this wiring SHOULD - // configure `resolveAccount` so the dispatcher's belt-and-suspenders - // second gate activates. - testController: { - async getSeededProducts(bridgeCtx) { - const args = bridgeCtx.input as { account?: AccountRef; brand?: BrandRef }; - const session = await getSession(sessionKeyFromArgs(args, 'open')); - const seeded = session.complyExtensions.seededProducts; - if (seeded.size === 0) return []; - return Array.from(seeded.entries()).map(([productId, fixture]) => { - const base = { ...SEED_PRODUCT_DEFAULTS, product_id: productId } as Partial; - return mergeSeedProduct(base, fixture as Partial) as Product; - }); - }, - }, - - capabilities: { - major_versions: [3], - specialisms: [], - features: { - inlineCreativeManagement: true, - propertyListFiltering: true, - contentStandards: true, - conversionTracking: true, - audienceTargeting: true, - }, - account: { - requireOperatorAuth: false, - supportedBilling: ['agent', 'operator', 'advertiser'], - }, - creative: { - hasCreativeLibrary: true, - supportsTransformation: true, - supportsGeneration: true, - supportsCompliance: false, - }, - request_signing: { - supported: signingCap.supported, - covers_content_digest: signingCap.covers_content_digest, - required_for: [...signingCap.required_for], - ...(signingCap.supported_for && { supported_for: [...signingCap.supported_for] }), - }, - // 5.5 `overrides`: deep-merged on top of the framework's auto-derived - // response so training-agent-specific fields (publisher portfolio, - // compliance_testing scenarios, per-domain targeting surface) surface - // on `get_adcp_capabilities` without needing to replace the tool. - // - // Note: `identity.brand_json_url` emit is deferred until @adcp/sdk's - // pinned ADCP_VERSION ('3.0.1') publishes the schema with the new - // `identity` field — the storyboard runner validates against that - // pinned schema and rejects unknown sub-fields. Re-add once the SDK - // version with the field lands. The brand.json + e2e fixture wiring - // in this PR exercises the chain in --inproc mode; production wiring - // is a follow-up. - overrides: { - media_buy: { - portfolio: { - publisher_domains: PUBLISHERS.map(p => p.domain), - primary_channels: allChannels, - }, - content_standards: { - supports_local_evaluation: true, - supported_channels: allChannels, - supports_webhook_delivery: false, - }, - audience_targeting: { - supported_identifier_types: ['hashed_email'], - minimum_audience_size: 100, - }, - conversion_tracking: { - supported_event_types: ['purchase', 'add_to_cart', 'lead', 'page_view'], - supported_hashed_identifiers: ['hashed_email'], - supported_action_sources: ['website', 'app'], - }, - execution: { - targeting: { - geo_countries: true, - geo_regions: true, - geo_metros: { nielsen_dma: true }, - geo_postal_areas: { us_zip: true }, - language: true, - keyword_targets: { supported_match_types: ['broad', 'phrase', 'exact'] }, - negative_keywords: { supported_match_types: ['broad', 'phrase', 'exact'] }, - }, - }, - }, - compliance_testing: { - scenarios: [ - 'force_creative_status', - 'force_account_status', - 'force_media_buy_status', - 'force_session_status', - 'simulate_delivery', - 'simulate_budget_spend', - ], - }, - }, - }, - - mediaBuy: { - getProducts: adapt('get_products', handleGetProducts), - createMediaBuy: adapt('create_media_buy', handleCreateMediaBuy), - updateMediaBuy: adapt('update_media_buy', handleUpdateMediaBuy), - getMediaBuys: adapt('get_media_buys', handleGetMediaBuys), - getMediaBuyDelivery: adapt('get_media_buy_delivery', handleGetMediaBuyDelivery), - providePerformanceFeedback: adapt('provide_performance_feedback', handleProvidePerformanceFeedback), - listCreativeFormats: adapt('list_creative_formats', handleListCreativeFormats), - syncCreatives: adapt('sync_creatives', handleSyncCreatives), - listCreatives: adapt('list_creatives', handleListCreatives), - }, - creative: { - buildCreative: adapt('build_creative', handleBuildCreative), - previewCreative: adapt('preview_creative', handlePreviewCreative), - getCreativeDelivery: adapt('get_creative_delivery', handleGetCreativeDelivery), - }, - signals: { - getSignals: adapt('get_signals', handleGetSignals), - activateSignal: adapt('activate_signal', handleActivateSignal), - }, - governance: { - syncPlans: adapt('sync_plans', handleSyncPlans), - checkGovernance: adapt('check_governance', handleCheckGovernance), - reportPlanOutcome: adapt('report_plan_outcome', handleReportPlanOutcome), - getPlanAuditLogs: adapt('get_plan_audit_logs', handleGetPlanAuditLogs), - createPropertyList: adapt('create_property_list', handleCreatePropertyList), - listPropertyLists: adapt('list_property_lists', handleListPropertyLists), - getPropertyList: adapt('get_property_list', handleGetPropertyList), - updatePropertyList: adapt('update_property_list', handleUpdatePropertyList), - deletePropertyList: adapt('delete_property_list', handleDeletePropertyList), - createContentStandards: adapt('create_content_standards', handleCreateContentStandards), - listContentStandards: adapt('list_content_standards', handleListContentStandards), - getContentStandards: adapt('get_content_standards', handleGetContentStandards), - updateContentStandards: adapt('update_content_standards', handleUpdateContentStandards), - calibrateContent: adapt('calibrate_content', handleCalibrateContent), - validateContentDelivery: adapt('validate_content_delivery', handleValidateContentDelivery), - }, - accounts: { - syncAccounts: adapt('sync_accounts', handleSyncAccounts), - listAccounts: adapt('list_accounts', handleListAccounts), - syncGovernance: adapt('sync_governance', handleSyncGovernance), - reportUsage: adapt('report_usage', handleReportUsage), - }, - eventTracking: { - syncEventSources: adapt('sync_event_sources', handleSyncEventSources), - logEvent: adapt('log_event', handleLogEvent), - syncCatalogs: adapt('sync_catalogs', handleSyncCatalogs), - }, - brandRights: { - getBrandIdentity: adapt('get_brand_identity', handleGetBrandIdentity), - getRights: adapt('get_rights', handleGetRights), - acquireRights: adapt('acquire_rights', handleAcquireRights), - }, - - customTools: { - creative_approval: customToolFor('creative_approval', 'Submit a generated creative for brand approval against rights grant terms.', CREATIVE_APPROVAL_SCHEMA, handleCreativeApproval), - update_rights: customToolFor('update_rights', 'Update an existing rights grant — extend dates, adjust impression caps, or pause/resume.', UPDATE_RIGHTS_SCHEMA, handleUpdateRights), - validate_property_delivery: customToolFor('validate_property_delivery', 'Validate that delivered properties comply with a property list.', VALIDATE_PROPERTY_DELIVERY_SCHEMA, handleValidatePropertyDelivery), - create_collection_list: customToolFor('create_collection_list', 'Create a collection list for program-level brand safety.', CREATE_COLLECTION_LIST_SCHEMA, handleCreateCollectionList), - get_collection_list: customToolFor('get_collection_list', 'Retrieve a collection list with optional resolution.', GET_COLLECTION_LIST_SCHEMA, handleGetCollectionList), - update_collection_list: customToolFor('update_collection_list', 'Modify an existing collection list.', UPDATE_COLLECTION_LIST_SCHEMA, handleUpdateCollectionList), - list_collection_lists: customToolFor('list_collection_lists', 'List collection lists owned by the given account.', LIST_COLLECTION_LISTS_SCHEMA, handleListCollectionLists), - delete_collection_list: customToolFor('delete_collection_list', 'Delete a collection list.', DELETE_COLLECTION_LIST_SCHEMA, handleDeleteCollectionList), - comply_test_controller: customToolFor('comply_test_controller', 'Training-agent compliance helper for forcing statuses and simulating delivery/spend. Sandbox only.', COMPLY_TEST_CONTROLLER_SCHEMA, handleComplyTestController), - }, - }); - - logger.debug({ signingCap: signingCap.supported }, 'Framework training agent server constructed'); - - return server; -} - -/** - * Returns true when the framework path should be used. Default is ON now - * that both dispatch modes hit 52/52 storyboard parity. Set - * `TRAINING_AGENT_USE_FRAMEWORK=0` (or `=false`) to fall back to legacy - * as an escape hatch; the fallback exists for one release so a regression - * in the flipped-default config has a clean rollback before legacy is - * deleted. - */ -export function useFrameworkServer(): boolean { - const v = process.env.TRAINING_AGENT_USE_FRAMEWORK; - // Default ON (framework dispatch). Framework storyboards have been at - // 52/52 clean since the envelope + session-fallback work landed; - // legacy stays alive as an explicit opt-out escape hatch - // (`TRAINING_AGENT_USE_FRAMEWORK=0`) for one release so a regression - // shows up on the flipped-default config before legacy deletion. - if (v === '0' || v === 'false') return false; - return true; -} diff --git a/server/src/training-agent/governance-handlers.ts b/server/src/training-agent/governance-handlers.ts index b693868e5c..76d0b2fd17 100644 --- a/server/src/training-agent/governance-handlers.ts +++ b/server/src/training-agent/governance-handlers.ts @@ -434,7 +434,7 @@ export async function handleSyncPlans(args: ToolArgs, ctx: TrainingContext) { const input = args as SyncPlansInput; if (!input.plans?.length) { - return { errors: [{ code: 'validation_error', message: 'plans array is required' }] }; + return { errors: [{ code: 'VALIDATION_ERROR', message: 'plans array is required' }] }; } const results: Array<{ plan_id: string; status: string; version: number; categories: Array<{ category_id: string; status: string }> }> = []; @@ -443,44 +443,44 @@ export async function handleSyncPlans(args: ToolArgs, ctx: TrainingContext) { for (let i = 0; i < input.plans.length; i++) { const plan = input.plans[i]; if (!plan.plan_id || !plan.brand || !plan.objectives || !plan.budget || !plan.flight) { - return { errors: [{ code: 'validation_error', message: `plan at index ${i} requires plan_id, brand, objectives, budget, and flight` }] }; + return { errors: [{ code: 'VALIDATION_ERROR', message: `plan at index ${i} requires plan_id, brand, objectives, budget, and flight` }] }; } if (plan.objectives.length > 2000) { - return { errors: [{ code: 'validation_error', message: `plan ${plan.plan_id} objectives exceeds 2000 character limit; caller-untrusted free text must be bounded` }] }; + return { errors: [{ code: 'VALIDATION_ERROR', message: `plan ${plan.plan_id} objectives exceeds 2000 character limit; caller-untrusted free text must be bounded` }] }; } for (let j = 0; j < (plan.custom_policies?.length ?? 0); j++) { const cp = plan.custom_policies![j]; if (typeof cp !== 'object' || cp === null || Array.isArray(cp)) { - return { errors: [{ code: 'validation_error', message: `plan ${plan.plan_id} custom_policies[${j}] must be an object per policy-entry schema; string form is deprecated` }] }; + return { errors: [{ code: 'VALIDATION_ERROR', message: `plan ${plan.plan_id} custom_policies[${j}] must be an object per policy-entry schema; string form is deprecated` }] }; } if (!cp.policy || typeof cp.policy !== 'string') { - return { errors: [{ code: 'validation_error', message: `plan ${plan.plan_id} custom_policies[${j}] requires a policy string` }] }; + return { errors: [{ code: 'VALIDATION_ERROR', message: `plan ${plan.plan_id} custom_policies[${j}] requires a policy string` }] }; } if (cp.policy.length > 5000) { - return { errors: [{ code: 'validation_error', message: `plan ${plan.plan_id} custom_policies[${j}].policy exceeds 5000 character limit` }] }; + return { errors: [{ code: 'VALIDATION_ERROR', message: `plan ${plan.plan_id} custom_policies[${j}].policy exceeds 5000 character limit` }] }; } if (cp.description != null && (typeof cp.description !== 'string' || cp.description.length > 500)) { - return { errors: [{ code: 'validation_error', message: `plan ${plan.plan_id} custom_policies[${j}].description must be a string ≤ 500 characters` }] }; + return { errors: [{ code: 'VALIDATION_ERROR', message: `plan ${plan.plan_id} custom_policies[${j}].description must be a string ≤ 500 characters` }] }; } } if (typeof plan.budget.total !== 'number' || !plan.budget.currency) { - return { errors: [{ code: 'validation_error', message: `plan ${plan.plan_id} budget requires total (number) and currency (string)` }] }; + return { errors: [{ code: 'VALIDATION_ERROR', message: `plan ${plan.plan_id} budget requires total (number) and currency (string)` }] }; } const hasThreshold = typeof plan.budget.reallocation_threshold === 'number'; const hasUnlimited = plan.budget.reallocation_unlimited === true; if (hasThreshold === hasUnlimited) { - return { errors: [{ code: 'validation_error', message: `plan ${plan.plan_id} budget must specify exactly one of reallocation_threshold (number >= 0) or reallocation_unlimited (true)` }] }; + return { errors: [{ code: 'VALIDATION_ERROR', message: `plan ${plan.plan_id} budget must specify exactly one of reallocation_threshold (number >= 0) or reallocation_unlimited (true)` }] }; } if (hasThreshold && plan.budget.reallocation_threshold! < 0) { - return { errors: [{ code: 'validation_error', message: `plan ${plan.plan_id} budget.reallocation_threshold must be >= 0` }] }; + return { errors: [{ code: 'VALIDATION_ERROR', message: `plan ${plan.plan_id} budget.reallocation_threshold must be >= 0` }] }; } if (!plan.flight.start || !plan.flight.end) { - return { errors: [{ code: 'validation_error', message: `plan ${plan.plan_id} flight requires start and end` }] }; + return { errors: [{ code: 'VALIDATION_ERROR', message: `plan ${plan.plan_id} flight requires start and end` }] }; } if (plan.budget.allocations) { const invalidKeys = Object.keys(plan.budget.allocations).filter(k => !VALID_PURCHASE_TYPES.has(k)); if (invalidKeys.length > 0) { - return { errors: [{ code: 'validation_error', message: `plan ${plan.plan_id} budget.allocations has invalid keys: ${invalidKeys.join(', ')}. Must be one of: ${[...VALID_PURCHASE_TYPES].join(', ')}` }] }; + return { errors: [{ code: 'VALIDATION_ERROR', message: `plan ${plan.plan_id} budget.allocations has invalid keys: ${invalidKeys.join(', ')}. Must be one of: ${[...VALID_PURCHASE_TYPES].join(', ')}` }] }; } } @@ -492,7 +492,7 @@ export async function handleSyncPlans(args: ToolArgs, ctx: TrainingContext) { // Cross-field schema invariant: if policy_categories contains a regulated vertical, // human_review_required MUST be true. Reject explicit false. if (plan.human_review_required === false && resolvedTriggers.length > 0) { - return { errors: [{ code: 'validation_error', message: `plan ${plan.plan_id} declares ${resolvedTriggers.join(', ')} which require human_review_required=true; cannot set false` }] }; + return { errors: [{ code: 'VALIDATION_ERROR', message: `plan ${plan.plan_id} declares ${resolvedTriggers.join(', ')} which require human_review_required=true; cannot set false` }] }; } // Revision safety: prior plan with humanReviewRequired=true cannot be downgraded @@ -500,26 +500,26 @@ export async function handleSyncPlans(args: ToolArgs, ctx: TrainingContext) { const existing = session.governancePlans.get(plan.plan_id); if (existing?.humanReviewRequired && !effectiveHumanReview) { if (!plan.human_override) { - return { errors: [{ code: 'validation_error', message: `plan ${plan.plan_id} previously had human_review_required=true; downgrading requires a human_override artifact` }] }; + return { errors: [{ code: 'VALIDATION_ERROR', message: `plan ${plan.plan_id} previously had human_review_required=true; downgrading requires a human_override artifact` }] }; } const override = plan.human_override; if (!override.reason || override.reason.length < 20) { - return { errors: [{ code: 'validation_error', message: `plan ${plan.plan_id} human_override.reason must be at least 20 characters describing the rationale for downgrade` }] }; + return { errors: [{ code: 'VALIDATION_ERROR', message: `plan ${plan.plan_id} human_override.reason must be at least 20 characters describing the rationale for downgrade` }] }; } if (!override.approver || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(override.approver)) { - return { errors: [{ code: 'validation_error', message: `plan ${plan.plan_id} human_override.approver must be a valid email address. Production governance agents SHOULD bind this to an authenticated identity.` }] }; + return { errors: [{ code: 'VALIDATION_ERROR', message: `plan ${plan.plan_id} human_override.approver must be a valid email address. Production governance agents SHOULD bind this to an authenticated identity.` }] }; } if (override.approved_at) { const approvedAt = new Date(override.approved_at); if (isNaN(approvedAt.getTime())) { - return { errors: [{ code: 'validation_error', message: `plan ${plan.plan_id} human_override.approved_at must be a valid ISO 8601 timestamp` }] }; + return { errors: [{ code: 'VALIDATION_ERROR', message: `plan ${plan.plan_id} human_override.approved_at must be a valid ISO 8601 timestamp` }] }; } const ageMs = Date.now() - approvedAt.getTime(); if (ageMs > 24 * 60 * 60 * 1000) { - return { errors: [{ code: 'validation_error', message: `plan ${plan.plan_id} human_override.approved_at is older than 24 hours; fresh approval required for each downgrade` }] }; + return { errors: [{ code: 'VALIDATION_ERROR', message: `plan ${plan.plan_id} human_override.approved_at is older than 24 hours; fresh approval required for each downgrade` }] }; } if (ageMs < -60 * 1000) { - return { errors: [{ code: 'validation_error', message: `plan ${plan.plan_id} human_override.approved_at is in the future` }] }; + return { errors: [{ code: 'VALIDATION_ERROR', message: `plan ${plan.plan_id} human_override.approved_at is in the future` }] }; } } } @@ -635,7 +635,7 @@ export async function handleCheckGovernance(args: ToolArgs, ctx: TrainingContext const deliveryMetrics = req.delivery_metrics; if (req.purchase_type && !VALID_PURCHASE_TYPES.has(req.purchase_type)) { - return { errors: [{ code: 'validation_error', message: `Invalid purchase_type: ${req.purchase_type}. Must be one of: ${[...VALID_PURCHASE_TYPES].join(', ')}` }] }; + return { errors: [{ code: 'VALIDATION_ERROR', message: `Invalid purchase_type: ${req.purchase_type}. Must be one of: ${[...VALID_PURCHASE_TYPES].join(', ')}` }] }; } // Infer binding from field presence per the schema spec: @@ -1140,7 +1140,7 @@ export async function handleReportPlanOutcome(args: ToolArgs, ctx: TrainingConte const delivery = req.delivery; if (req.purchase_type && !VALID_PURCHASE_TYPES.has(req.purchase_type)) { - return { errors: [{ code: 'validation_error', message: `Invalid purchase_type: ${req.purchase_type}. Must be one of: ${[...VALID_PURCHASE_TYPES].join(', ')}` }] }; + return { errors: [{ code: 'VALIDATION_ERROR', message: `Invalid purchase_type: ${req.purchase_type}. Must be one of: ${[...VALID_PURCHASE_TYPES].join(', ')}` }] }; } let plan = session.governancePlans.get(planId); @@ -1155,7 +1155,7 @@ export async function handleReportPlanOutcome(args: ToolArgs, ctx: TrainingConte } } if (!plan) { - return { errors: [{ code: 'not_found', message: `Plan not found: ${planId}` }] }; + return { errors: [{ code: 'REFERENCE_NOT_FOUND', message: `Plan not found: ${planId}` }] }; } let committedBudget = 0; @@ -1244,13 +1244,13 @@ export async function handleGetPlanAuditLogs(args: ToolArgs, ctx: TrainingContex const includeEntries = req.include_entries || false; if (!planIds.length && !portfolioPlanIds.length && !governanceContextsFilter?.length) { - return { errors: [{ code: 'validation_error', message: 'plan_ids, portfolio_plan_ids, or governance_contexts is required' }] }; + return { errors: [{ code: 'VALIDATION_ERROR', message: 'plan_ids, portfolio_plan_ids, or governance_contexts is required' }] }; } if (purchaseTypesFilter?.length) { const invalid = purchaseTypesFilter.filter(t => !VALID_PURCHASE_TYPES.has(t)); if (invalid.length) { - return { errors: [{ code: 'validation_error', message: `Invalid purchase_types: ${invalid.join(', ')}. Must be one of: ${[...VALID_PURCHASE_TYPES].join(', ')}` }] }; + return { errors: [{ code: 'VALIDATION_ERROR', message: `Invalid purchase_types: ${invalid.join(', ')}. Must be one of: ${[...VALID_PURCHASE_TYPES].join(', ')}` }] }; } } diff --git a/server/src/training-agent/index.ts b/server/src/training-agent/index.ts index a389b62135..ca10a9d0d0 100644 --- a/server/src/training-agent/index.ts +++ b/server/src/training-agent/index.ts @@ -1,8 +1,16 @@ /** - * Training agent route setup. + * Training agent route setup — multi-tenant. * - * Mounts the MCP endpoint at /api/training-agent/mcp with simple - * bearer token auth, CORS, and adagents.json discovery. + * Mounts six per-specialism tenants under `/api/training-agent/`: + * /sales, /signals, /governance, /creative, /creative-builder, /brand + * + * Each tenant exposes its own MCP endpoint (`//mcp`) with bearer + * auth + rate limiting. Health, JWKS, and adagents.json discovery live + * at the parent prefix. + * + * Replaces the legacy single-URL `/mcp` and `/mcp-strict` routes (the + * latter was a request-signing-required variant). Production agents are + * registered per-tenant in AAO; storyboards target the per-tenant URL. */ import { Router } from 'express'; @@ -10,13 +18,11 @@ import type { Request, Response, NextFunction } from 'express'; import rateLimit from 'express-rate-limit'; import { WorkOS } from '@workos-inc/node'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; -import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import { anyOf, verifyApiKey, extractBearerToken, respondUnauthorized, - requireAuthenticatedOrSigned, requireSignatureWhenPresent, signatureErrorCodeFromCause, AuthError, @@ -24,24 +30,16 @@ import { type AuthPrincipal, } from '@adcp/sdk/server'; import { createLogger } from '../logger.js'; +import { mountTenantRoutes } from './tenants/router.js'; import { createTrainingAgentServer } from './task-handlers.js'; -import { createFrameworkTrainingAgentServer, useFrameworkServer } from './framework-server.js'; -import { redactConflictEnvelopeInBody } from './conflict-envelope.js'; -import { startSessionCleanup } from './state.js'; +import { runWithSessionContext, flushDirtySessions, startSessionCleanup } from './state.js'; +import type { TrainingContext } from './types.js'; import { PUBLISHERS } from './publishers.js'; import { SIGNAL_PROVIDERS } from './signal-providers.js'; import { getPublicJwks } from './webhooks.js'; -import { - buildRequestSigningAuthenticator, - buildStrictRequestSigningAuthenticator, - buildStrictRequiredRequestSigningAuthenticator, - buildStrictForbiddenRequestSigningAuthenticator, - enforceSigningWhenWebhookAuthPresent, - STRICT_REQUIRED_FOR, -} from './request-signing.js'; +import { buildRequestSigningAuthenticator } from './request-signing.js'; import { isWorkOSApiKeyFormat } from '../middleware/api-key-format.js'; import { PUBLIC_TEST_AGENT } from '../config/test-agent.js'; -import type { TrainingContext } from './types.js'; const logger = createLogger('training-agent-routes'); @@ -54,34 +52,18 @@ const workos = process.env.WORKOS_API_KEY && process.env.WORKOS_CLIENT_ID ? new WorkOS(process.env.WORKOS_API_KEY, { clientId: process.env.WORKOS_CLIENT_ID }) : null; -// Permissive CORS: this is a sandbox training agent meant to be -// called from any origin (certification UI, notebooks, CLI tools, etc.) -function setCORSHeaders(res: Response): void { - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, mcp-session-id'); - res.setHeader('Access-Control-Expose-Headers', 'Content-Type'); -} - /** * Security posture: the training agent is a public sandbox. Any valid AAO * dashboard API key authenticates — there is no org allowlist, no plan-tier - * gate, no per-org quota check. Account-level isolation is already provided + * gate, no per-org quota check. Account-level isolation is provided * downstream via `scopedPrincipal` (idempotency is partitioned by * authPrincipal ⨯ account scope) and session state is keyed by * brand.domain / account_id. Training-agent data is non-sensitive by design. - * - * Do NOT reuse this authenticator for tenant-scoped surfaces. Agents that - * need org gating should extend the `verify` callback with an allowlist - * check (e.g., `if (!allowedOrgs.has(result.apiKey.owner.id)) return null`) - * or layer an `anyOf` with a separate scope-aware authenticator. */ // Conformance handle documented in every test-kit header // (static/compliance/source/test-kits/*.yaml, auth.api_key comment): agents // SHOULD accept any Bearer matching `demo--v` so the suffix can rotate -// across spec versions without breaking previously-conformant agents. The -// training agent IS the reference — so it accepts the handle directly. -// Anchored to forbid `demo--v1` / `demo-v1` and lock alg-num segments. +// across spec versions without breaking previously-conformant agents. const DEMO_TEST_KIT_KEY_PATTERN = /^demo-[a-z0-9]+(?:-[a-z0-9]+)*-v\d+$/; function buildBearerAuthenticator(): Authenticator | null { @@ -103,7 +85,7 @@ function buildBearerAuthenticator(): Authenticator | null { }, })); if (workos) { - const workosClient = workos; // narrow for closure + const workosClient = workos; authenticators.push(verifyApiKey({ verify: async (token) => { if (!isWorkOSApiKeyFormat(token)) return null; @@ -119,10 +101,9 @@ function buildBearerAuthenticator(): Authenticator | null { return authenticators.length === 1 ? authenticators[0] : anyOf(...authenticators); } -// Per-route lazy signing authenticators. Each route MUST own its own -// `InMemoryReplayStore` — sharing one store lets a nonce consumed on -// one route falsely fire `request_signature_replayed` on another (#3338). -// Lazy so the compliance test JWKS isn't read at module import time. +// Lazy so the signing authenticator builds on first auth call — +// avoids reading the compliance test JWKS at module import time, which +// would break test setups that mock the compliance cache. let _signingAuth: Authenticator | null = null; function lazySigningAuth(): Authenticator { return (req) => { @@ -131,45 +112,10 @@ function lazySigningAuth(): Authenticator { }; } -let _strictSigningAuth: Authenticator | null = null; -function lazyStrictSigningAuth(): Authenticator { - return (req) => { - if (!_strictSigningAuth) _strictSigningAuth = buildStrictRequestSigningAuthenticator(); - return _strictSigningAuth(req); - }; -} - -let _strictRequiredSigningAuth: Authenticator | null = null; -function lazyStrictRequiredSigningAuth(): Authenticator { - return (req) => { - if (!_strictRequiredSigningAuth) _strictRequiredSigningAuth = buildStrictRequiredRequestSigningAuthenticator(); - return _strictRequiredSigningAuth(req); - }; -} - -let _strictForbiddenSigningAuth: Authenticator | null = null; -function lazyStrictForbiddenSigningAuth(): Authenticator { - return (req) => { - if (!_strictForbiddenSigningAuth) _strictForbiddenSigningAuth = buildStrictForbiddenRequestSigningAuthenticator(); - return _strictForbiddenSigningAuth(req); - }; -} - /** - * Default `/mcp` route: presence-gated signature composition. Callers with no - * `Signature-Input` header fall through to bearer auth (sandbox backward - * compat — unsigned AAO API keys keep working). Callers that DO present a - * signature header MUST produce a valid one: malformed/invalid signatures - * fail closed with the signing-layer error code instead of silently - * downgrading to bearer. Closes signed-requests vector 011 - * (`request_signature_header_malformed`) on the sandbox endpoint. - * - * The webhook-auth downgrade-resistance rule (security.mdx#webhook-callbacks) - * is enforced only on `/mcp-strict`. The sandbox `/mcp` route accepts - * unsigned `push_notification_config.authentication` for backward compat - * with pre-3.0 storyboards that wire legacy HMAC-SHA256 webhooks over - * bearer-auth'd `create_media_buy`. Presence-gating is orthogonal to that - * — it only changes behavior when a caller DOES present a signature header. + * Tenant-route authenticator: presence-gated signature composition. + * Callers with no `Signature-Input` header fall through to bearer auth. + * Callers that DO present a signature header MUST produce a valid one. */ function buildDefaultAuthenticator(): Authenticator | null { const bearerAuth = buildBearerAuthenticator(); @@ -177,53 +123,12 @@ function buildDefaultAuthenticator(): Authenticator | null { return requireSignatureWhenPresent(lazySigningAuth(), bearerAuth); } -/** - * Presence-gated authenticator for all `/mcp-strict*` routes. Accepts a lazy - * signing authenticator so each route uses its own capability instance - * (covers_content_digest varies per route and is baked at init time). - * - * Delegates to `requireAuthenticatedOrSigned` (5.7): bypass on valid bearer, - * `request_signature_required` on unsigned required-op, `signatureErrorCodeFromCause` - * surfaces RFC 9421 error codes on bad signatures. - */ -function buildStrictModeAuthenticator(lazyAuth: () => Authenticator): Authenticator | null { - const bearerAuth = buildBearerAuthenticator(); - if (!bearerAuth) return null; - return enforceSigningWhenWebhookAuthPresent(requireAuthenticatedOrSigned({ - signature: lazyAuth(), - fallback: bearerAuth, - requiredFor: STRICT_REQUIRED_FOR, - resolveOperation: (req) => { - // rawBody is populated by the production http.ts `verify` callback. - // Fall back to req.body (already-parsed by express.json) when rawBody - // is absent — e.g. in test harnesses that skip the verify callback. - // Safe here because resolveOperation drives only the required_for - // routing decision, not cryptographic verification. - const raw = (req as { rawBody?: string }).rawBody; - try { - const body = raw - ? JSON.parse(raw) as { method?: string; params?: { name?: string } } - : (req as { body?: { method?: string; params?: { name?: string } } }).body; - if (body && body.method === 'tools/call' && typeof body.params?.name === 'string') { - return body.params.name; - } - } catch { - // Transport rejects malformed JSON downstream. - } - return undefined; - }, - })); -} - const defaultAuthenticator = buildDefaultAuthenticator(); -const strictAuthenticator = buildStrictModeAuthenticator(lazyStrictSigningAuth); -const strictRequiredAuthenticator = buildStrictModeAuthenticator(lazyStrictRequiredSigningAuth); -const strictForbiddenAuthenticator = buildStrictModeAuthenticator(lazyStrictForbiddenSigningAuth); function buildRequireToken(authenticator: Authenticator | null) { return async function requireToken(req: Request, res: Response, next: NextFunction): Promise { if (!authenticator) { - // No tokens configured and no WorkOS = dev mode, allow all + // No tokens configured = dev mode, allow all res.locals.trainingPrincipal = 'anonymous'; return next(); } @@ -232,10 +137,6 @@ function buildRequireToken(authenticator: Authenticator | null) { principal = await authenticator(req); } catch (err) { logger.warn({ err }, 'Training agent: authentication error'); - // `signatureErrorCodeFromCause` (5.7) unwraps `AuthError` → cause to - // surface RFC 9421 error codes. `respondUnauthorized({ signatureError })` - // emits `WWW-Authenticate: Signature error=""` — the challenge - // the `signed_requests` conformance grader reads the code off of. const signatureError = signatureErrorCodeFromCause(err); if (signatureError) { respondUnauthorized(req, res, { @@ -244,13 +145,8 @@ function buildRequireToken(authenticator: Authenticator | null) { }); return; } - const publicMessage = err instanceof AuthError - ? err.publicMessage - : 'Authentication failed'; - respondUnauthorized(req, res, { - error: 'invalid_token', - errorDescription: publicMessage, - }); + const publicMessage = err instanceof AuthError ? err.publicMessage : 'Authentication failed'; + respondUnauthorized(req, res, { error: 'invalid_token', errorDescription: publicMessage }); return; } if (!principal) { @@ -269,93 +165,6 @@ function buildRequireToken(authenticator: Authenticator | null) { } const requireTokenDefault = buildRequireToken(defaultAuthenticator); -const requireTokenStrict = buildRequireToken(strictAuthenticator); -const requireTokenStrictRequired = buildRequireToken(strictRequiredAuthenticator); -const requireTokenStrictForbidden = buildRequireToken(strictForbiddenAuthenticator); - -/** - * Capture the response body as it's written by the MCP transport, redact any - * `IDEMPOTENCY_CONFLICT` envelopes (framework-dispatch's `adcpError()` emits - * `recovery` which the storyboard invariant treats as a payload leak), and - * flush the transformed body through the original writer. Idempotent: safe - * to call even when no conflict envelope is present (pass-through via a - * fast-path `includes('IDEMPOTENCY_CONFLICT')` probe inside the redactor). - * - * Works for the JSON-response mode (`enableJsonResponse: true`) the training - * agent forces for every request — the transport writes a single - * `res.write(body) ; res.end()` pair, which this wrapper buffers into one - * string before rewriting. Streaming/SSE would break this contract, so do - * not remove `enableJsonResponse: true` from the transport config above. - * - * Goes away once we bump past adcp-client#866 — @adcp/sdk 5.14+ has - * built-in `sanitizeAdcpErrorEnvelope` in the dispatcher, which makes this - * wire-layer redaction redundant. - */ -function wrapResponseForConflictRedaction(res: Response): void { - const origWriteHead = res.writeHead.bind(res); - const origWrite = res.write.bind(res) as (chunk: unknown, ...rest: unknown[]) => boolean; - const origEnd = res.end.bind(res) as (chunk?: unknown, ...rest: unknown[]) => Response; - const chunks: Buffer[] = []; - let pendingHead: { status: number; headers: Record } | null = null; - - const collect = (chunk: unknown): void => { - if (chunk === undefined || chunk === null) return; - if (Buffer.isBuffer(chunk)) chunks.push(chunk); - else if (typeof chunk === 'string') chunks.push(Buffer.from(chunk, 'utf8')); - else if (chunk instanceof Uint8Array) chunks.push(Buffer.from(chunk)); - else chunks.push(Buffer.from(String(chunk), 'utf8')); - }; - - // `@hono/node-server` flushes headers via `writeHead(status, headers)` - // before calling `write` — with content-length already computed from the - // original body length. Buffering headers here defers flush until `end` - // runs, so the final `Content-Length` reflects the redacted body size. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (res as any).writeHead = (( - status: number, - statusMessageOrHeaders?: string | Record, - headersArg?: Record, - ): Response => { - const headers = typeof statusMessageOrHeaders === 'object' && statusMessageOrHeaders !== null - ? statusMessageOrHeaders - : headersArg ?? {}; - pendingHead = { status, headers: { ...headers } }; - return res; - }) as typeof res.writeHead; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (res as any).write = (chunk: unknown, encoding?: unknown, cb?: unknown): boolean => { - collect(chunk); - const callback = typeof encoding === 'function' ? encoding : cb; - if (typeof callback === 'function') (callback as () => void)(); - return true; - }; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (res as any).end = (chunk?: unknown, encoding?: unknown, cb?: unknown): Response => { - if (chunk !== undefined && typeof chunk !== 'function') collect(chunk); - const callback = typeof chunk === 'function' - ? chunk - : typeof encoding === 'function' - ? encoding - : cb; - const body = Buffer.concat(chunks).toString('utf8'); - const rewritten = redactConflictEnvelopeInBody(body); - if (pendingHead) { - const headers = pendingHead.headers; - for (const key of Object.keys(headers)) { - if (key.toLowerCase() === 'content-length') delete headers[key]; - } - headers['content-length'] = Buffer.byteLength(rewritten, 'utf8'); - origWriteHead(pendingHead.status, headers); - pendingHead = null; - } - if (rewritten.length > 0) origWrite(rewritten); - const args: unknown[] = []; - if (typeof callback === 'function') args.push(callback); - return origEnd(...args); - }; -} function getBaseUrl(req: Request): string { if (process.env.BASE_URL) return process.env.BASE_URL.replace(/\/$/, ''); @@ -364,12 +173,119 @@ function getBaseUrl(req: Request): string { return `${proto}://${host}`; } +const TENANT_IDS = ['signals', 'sales', 'governance', 'creative', 'creative-builder', 'brand'] as const; + export function createTrainingAgentRouter(): Router { const router = Router(); - // Start session cleanup startSessionCleanup(); + // Rate limiting: 1500 requests/minute per IP (in-memory, no DB dependency). + // The training agent is a sandbox — bulk storyboard evaluation runs 3-4 MCP + // calls per step across 27 storyboards (~600+ calls within a short window). + const mcpRateLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 1500, + standardHeaders: true, + legacyHeaders: false, + validate: { xForwardedForHeader: false, ip: false }, + handler: (_req: Request, res: Response) => { + res.status(429).json({ + jsonrpc: '2.0', + id: null, + error: { code: -32000, message: 'Rate limit exceeded. Please try again later.' }, + }); + }, + }); + + // Per-tenant MCP routes — each tenant gets POST //mcp with bearer + // auth + rate limiting. The tenant registry handles dispatch via + // resolveByRequest(host, pathname). + mountTenantRoutes(router, TENANT_IDS, { + rateLimit: mcpRateLimiter, + requireAuth: requireTokenDefault, + }); + + // Legacy single-URL `/mcp` route — preserved as a back-compat alias for + // existing AAO entries, Sage/Addie configs, docs, and external storyboard + // runners that target `test-agent.adcontextprotocol.org/mcp`. Serves the + // v5 monolith (`createTrainingAgentServer`) so it advertises every tool + // on one URL, the way it always has. Per-tenant URLs are the migration + // target; this mount goes away once the references are cut over. + function setLegacyCORS(res: Response): void { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, mcp-session-id'); + res.setHeader('Access-Control-Expose-Headers', 'Content-Type'); + } + + async function legacyMcpHandler(req: Request, res: Response): Promise { + setLegacyCORS(res); + res.setHeader('Deprecation', 'true'); + res.setHeader('Link', '; rel="successor-version"'); + let server: ReturnType | null = null; + try { + const principal = (res.locals.trainingPrincipal as string | undefined) ?? 'anonymous'; + const ctx: TrainingContext = { mode: 'open', principal }; + server = createTrainingAgentServer(ctx); + + // Streamable HTTP transport requires both `application/json` and + // `text/event-stream` in Accept; storyboard probes only send the + // former. Add the missing one + propagate to rawHeaders so the + // transport's Fetch wrapper sees it. + const acceptHeader = req.headers.accept; + const hasJson = typeof acceptHeader === 'string' && acceptHeader.includes('application/json'); + const hasSse = typeof acceptHeader === 'string' && acceptHeader.includes('text/event-stream'); + if (hasJson && !hasSse) { + const rewritten = `${acceptHeader}, text/event-stream`; + req.headers.accept = rewritten; + const raw = (req as unknown as { rawHeaders?: string[] }).rawHeaders; + if (Array.isArray(raw)) { + for (let i = 0; i < raw.length; i += 2) { + if (raw[i].toLowerCase() === 'accept') raw[i + 1] = rewritten; + } + } + } + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + await server.connect(transport); + logger.debug({ method: req.body?.method, route: 'legacy /mcp' }, 'Training agent: legacy request'); + await runWithSessionContext(async () => { + await transport.handleRequest(req, res, req.body); + await flushDirtySessions(); + }); + } catch (error) { + logger.error({ error, route: 'legacy /mcp' }, 'Training agent: legacy request error'); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + id: null, + error: { code: -32603, message: 'Internal server error' }, + }); + } + } finally { + await server?.close().catch(() => {}); + } + } + + router.options('/mcp', (_req: Request, res: Response) => { + setLegacyCORS(res); + res.status(204).end(); + }); + router.post('/mcp', mcpRateLimiter, requireTokenDefault, legacyMcpHandler); + router.get('/mcp', (_req: Request, res: Response) => { + setLegacyCORS(res); + res.setHeader('Allow', 'POST, OPTIONS'); + res.status(405).json({ + jsonrpc: '2.0', + id: null, + error: { code: -32000, message: 'Method not allowed. Use POST for MCP requests.' }, + }); + }); + // Health check router.get('/health', (_req: Request, res: Response) => { res.json({ status: 'healthy', service: 'training-agent' }); @@ -382,11 +298,11 @@ export function createTrainingAgentRouter(): Router { res.json(getPublicJwks()); }); - // adagents.json discovery + // adagents.json discovery — lists each per-tenant URL with the + // properties / signals it serves. Per-tenant entries replace the + // single-URL `authorized_agents[0].url` from the v5 monolith. router.get('/.well-known/adagents.json', (req: Request, res: Response) => { const baseUrl = getBaseUrl(req); - // req.baseUrl is the mount path (e.g. '/api/training-agent') — empty when - // served via host-based routing at root const agentUrl = `${baseUrl}${req.baseUrl}`; res.json({ @@ -395,20 +311,28 @@ export function createTrainingAgentRouter(): Router { name: 'AdCP Training Agent', url: 'https://adcontextprotocol.org', }, - authorized_agents: [{ - url: `${agentUrl}/mcp`, - authorized_for: 'AdCP training, testing, and certification (sandbox)', - authorization_type: 'inline_properties', - properties: PUBLISHERS.flatMap(pub => - pub.properties.map(prop => ({ - identifier_type: prop.identifierType, - identifier_value: prop.identifierValue, - name: prop.name, - supported_channels: prop.channels, - tags: prop.tags, - })), - ), - }], + authorized_agents: [ + { + url: `${agentUrl}/sales/mcp`, + authorized_for: 'AdCP training — sales (programmatic + guaranteed)', + authorization_type: 'inline_properties', + properties: PUBLISHERS.flatMap(pub => + pub.properties.map(prop => ({ + identifier_type: prop.identifierType, + identifier_value: prop.identifierValue, + name: prop.name, + supported_channels: prop.channels, + tags: prop.tags, + })), + ), + }, + { + url: `${agentUrl}/signals/mcp`, + authorized_for: 'AdCP training — signals (marketplace + owned)', + authorization_type: 'inline_properties', + properties: [], + }, + ], signals: SIGNAL_PROVIDERS.flatMap(provider => provider.signals.map(signal => ({ id: signal.signalAgentSegmentId, @@ -433,318 +357,6 @@ export function createTrainingAgentRouter(): Router { }); }); - // CORS preflight - router.options('/mcp', (_req: Request, res: Response) => { - setCORSHeaders(res); - res.status(204).end(); - }); - - // Rate limiting: 1500 requests/minute per IP (in-memory, no DB dependency). - // The training agent is a sandbox — bulk storyboard evaluation runs 3-4 MCP - // calls per step across 27 storyboards (~600+ calls within a short window). - const mcpRateLimiter = rateLimit({ - windowMs: 60 * 1000, - max: 1500, - standardHeaders: true, - legacyHeaders: false, - validate: { xForwardedForHeader: false, ip: false }, - handler: (_req: Request, res: Response) => { - res.status(429).json({ - jsonrpc: '2.0', - id: null, - error: { code: -32000, message: 'Rate limit exceeded. Please try again later.' }, - }); - }, - }); - - // MCP endpoint factory. Routes share the same body: - // /mcp — sandbox. anyOf(bearers, signing). required_for=[]. - // /mcp-strict — grader target. covers_content_digest='either'. - // /mcp-strict-required — grader target. covers_content_digest='required'. Fires neg/007. - // /mcp-strict-forbidden — grader target. covers_content_digest='forbidden'. Fires neg/018. - // The `strict` + `digestMode` flags flow into TrainingContext so get_adcp_capabilities - // advertises the correct request_signing block per route. - function mcpHandler(strict: boolean, digestMode?: 'either' | 'required' | 'forbidden') { - return async (req: Request, res: Response) => { - setCORSHeaders(res); - - // The framework returns `AdcpServer` (5.4+); the legacy factory returns - // the SDK's `Server`. Both satisfy the transport contract at runtime - // but have incompatible nominal types (different private fields). - // `any` stays until the flip-default PR deletes the legacy path. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let server: any = null; - try { - // Principal is set by requireToken; defaults to 'anonymous' in dev mode - // when no tokens are configured. - const principal = (res.locals.trainingPrincipal as string | undefined) ?? 'anonymous'; - const ctx: TrainingContext = { mode: 'open', principal, strict, digestMode }; - - server = useFrameworkServer() - ? createFrameworkTrainingAgentServer(ctx) - : createTrainingAgentServer(ctx); - - // MCP Streamable HTTP transport requires the client Accept header to - // list BOTH `application/json` and `text/event-stream` per the - // 2025-03-26 spec — or it returns 406 Not Acceptable. Storyboard - // conformance probes and other strict JSON consumers only send - // `application/json`. We satisfy both by (a) adding the missing SSE - // content type so the transport's check passes and (b) enabling JSON - // response mode so the body is single-shot JSON rather than an SSE - // stream the probe can't parse. `@adcp/sdk/express-mcp`'s - // `mcpAcceptHeaderMiddleware` does the same thing in 5.14+ but - // @adcp/sdk is pinned to 5.13 in this repo (storyboard runner - // regressed at 5.14 — adcp-client#866). Once that's resolved, swap - // this block for `app.use('/mcp', mcpAcceptHeaderMiddleware())`. - const acceptHeader = req.headers.accept; - const hasJson = typeof acceptHeader === 'string' && acceptHeader.includes('application/json'); - const hasSse = typeof acceptHeader === 'string' && acceptHeader.includes('text/event-stream'); - if (hasJson && !hasSse) { - const rewritten = `${acceptHeader}, text/event-stream`; - req.headers.accept = rewritten; - // @hono/node-server (used internally by StreamableHTTPServerTransport) - // reads headers from `rawHeaders` — the alternating [name, value] - // array Node's HTTP parser fills in. Mutating `req.headers.accept` - // alone doesn't propagate to the transport's Fetch Request wrapper. - const raw = (req as unknown as { rawHeaders?: string[] }).rawHeaders; - if (Array.isArray(raw)) { - for (let i = 0; i < raw.length; i += 2) { - if (raw[i].toLowerCase() === 'accept') { - raw[i + 1] = rewritten; - } - } - } - } - - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: undefined, // Stateless - enableJsonResponse: true, - }); - - await server.connect(transport); - - // Framework-dispatch IDEMPOTENCY_CONFLICT envelopes route through - // `@adcp/sdk/server`'s `adcpError()` builder, which in 5.13 - // auto-injects `recovery` on every error. The universal idempotency - // storyboard's `conflict_no_payload_leak` invariant allows only a - // narrow set of envelope keys on conflict — anything else is flagged - // as a potential stolen-key read oracle. Intercept the response - // bytes before they leave the process and strip disallowed keys. - // Legacy dispatch builds a minimal envelope by hand, so the wrap is - // a no-op there in practice (it still runs but finds nothing to - // redact). @adcp/sdk 5.14 moves this sanitization into the - // dispatcher via `sanitizeAdcpErrorEnvelope` — once we bump past - // the 5.14 storyboard regression (adcp-client#866), drop this - // wrapper and delete conflict-envelope.ts. - wrapResponseForConflictRedaction(res); - - logger.debug({ method: req.body?.method, ip: req.ip, strict }, 'Training agent: handling request'); - - // Both legacy and framework dispatch wrap handler execution in - // runWithSessionContext internally (legacy: CallToolRequestSchema, - // framework: adapt + customToolFor in framework-server.ts), so the - // transport-level handler just delegates. - await transport.handleRequest(req, res, req.body); - } catch (error) { - logger.error({ error, strict }, 'Training agent: request error'); - if (!res.headersSent) { - res.status(500).json({ - jsonrpc: '2.0', - id: null, - error: { code: -32603, message: 'Internal server error' }, - }); - } - } finally { - await server?.close().catch(() => {}); - } - }; - } - - router.post('/mcp', mcpRateLimiter, requireTokenDefault, mcpHandler(false)); - - // Strict endpoint for `adcp grade request-signing` and the AAO Verified - // compliance dashboard. Enforces `required_for: ['create_media_buy']` with - // presence-gated auth so vector 001 (`request_signature_required`) fires - // instead of being swallowed by the bearer fallthrough on /mcp. - router.options('/mcp-strict', (_req: Request, res: Response) => { - setCORSHeaders(res); - res.status(204).end(); - }); - router.post('/mcp-strict', mcpRateLimiter, requireTokenStrict, mcpHandler(true)); - router.get('/mcp-strict', (_req: Request, res: Response) => { - setCORSHeaders(res); - res.setHeader('Allow', 'POST, OPTIONS'); - res.status(405).json({ - jsonrpc: '2.0', - id: null, - error: { code: -32000, message: 'Method not allowed. Use POST for MCP requests.' }, - }); - }); - - // Grader-only targets for covers_content_digest='required' and 'forbidden' modes. - // Enables grader vectors neg/007 (missing-content-digest) and neg/018 - // (digest-covered-when-forbidden) that can't fire against /mcp-strict because - // that route advertises 'either' — correct for the sandbox but untestable for - // those specific negative paths. Buyers smoke-testing their signing implementation - // can use these to verify their code handles required-mode and forbidden-mode - // rejections. - function mcpStrictModeGet(_req: Request, res: Response): void { - setCORSHeaders(res); - res.setHeader('Allow', 'POST, OPTIONS'); - res.status(405).json({ - jsonrpc: '2.0', - id: null, - error: { code: -32000, message: 'Method not allowed. Use POST for MCP requests.' }, - }); - } - - router.options('/mcp-strict-required', (_req: Request, res: Response) => { - setCORSHeaders(res); - res.status(204).end(); - }); - router.post('/mcp-strict-required', mcpRateLimiter, requireTokenStrictRequired, mcpHandler(true, 'required')); - router.get('/mcp-strict-required', mcpStrictModeGet); - - router.options('/mcp-strict-forbidden', (_req: Request, res: Response) => { - setCORSHeaders(res); - res.status(204).end(); - }); - router.post('/mcp-strict-forbidden', mcpRateLimiter, requireTokenStrictForbidden, mcpHandler(true, 'forbidden')); - router.get('/mcp-strict-forbidden', mcpStrictModeGet); - - // GET/DELETE not supported in stateless mode - router.get('/mcp', (_req: Request, res: Response) => { - setCORSHeaders(res); - res.setHeader('Allow', 'POST, OPTIONS'); - res.status(405).json({ - jsonrpc: '2.0', - id: null, - error: { code: -32000, message: 'Method not allowed. Use POST for MCP requests.' }, - }); - }); - - // --- Legacy SSE transport --- - // Some MCP clients (e.g. older SDKs) only support the deprecated SSE transport. - // GET /sse establishes the event stream; POST /message delivers JSON-RPC messages. - // Rate limiter is shared with /mcp — SSE uses 2 requests per interaction (GET + POST). - const sseSessions = new Map(); - const sseSessionLastSeen = new Map(); - const SSE_MAX_SESSIONS = 200; - const SSE_SESSION_TTL_MS = 30 * 60 * 1000; // 30 minutes - - // Sweep stale sessions that didn't trigger a close event (e.g. LB timeout) - const sseSweepTimer = setInterval(() => { - const now = Date.now(); - for (const [id, ts] of sseSessionLastSeen) { - if (now - ts > SSE_SESSION_TTL_MS) { - const transport = sseSessions.get(id); - if (transport) transport.close().catch(() => {}); - sseSessions.delete(id); - sseSessionLastSeen.delete(id); - } - } - }, 5 * 60 * 1000); - if (sseSweepTimer.unref) sseSweepTimer.unref(); - - router.options('/sse', (_req: Request, res: Response) => { - setCORSHeaders(res); - res.status(204).end(); - }); - - router.options('/message', (_req: Request, res: Response) => { - setCORSHeaders(res); - res.status(204).end(); - }); - - router.get('/sse', mcpRateLimiter, requireTokenDefault, async (req: Request, res: Response) => { - setCORSHeaders(res); - - if (sseSessions.size >= SSE_MAX_SESSIONS) { - res.status(503).json({ - jsonrpc: '2.0', - id: null, - error: { code: -32000, message: 'Too many active SSE sessions. Please try again later.' }, - }); - return; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let server: any = null; - try { - const principal = (res.locals.trainingPrincipal as string | undefined) ?? 'anonymous'; - const ctx: TrainingContext = { mode: 'open', principal }; - server = useFrameworkServer() - ? createFrameworkTrainingAgentServer(ctx) - : createTrainingAgentServer(ctx); - - // The endpoint path is relative to the router mount point - const transport = new SSEServerTransport(`${req.baseUrl}/message`, res); - const sessionId = transport.sessionId; - sseSessions.set(sessionId, transport); - sseSessionLastSeen.set(sessionId, Date.now()); - - transport.onclose = () => { - sseSessions.delete(sessionId); - sseSessionLastSeen.delete(sessionId); - server?.close().catch(() => {}); - logger.debug({ sessionId }, 'SSE session closed'); - }; - - await server.connect(transport); - - logger.debug({ sessionId, ip: req.ip }, 'SSE session established'); - } catch (error) { - logger.error({ error }, 'Training agent: SSE connection error'); - await server?.close().catch(() => {}); - if (!res.headersSent) { - res.status(500).json({ - jsonrpc: '2.0', - id: null, - error: { code: -32603, message: 'Internal server error' }, - }); - } - } - }); - - router.post('/message', mcpRateLimiter, requireTokenDefault, async (req: Request, res: Response) => { - setCORSHeaders(res); - - const sessionId = req.query.sessionId as string; - if (!sessionId) { - res.status(400).json({ - jsonrpc: '2.0', - id: null, - error: { code: -32600, message: 'Missing sessionId query parameter' }, - }); - return; - } - - const transport = sseSessions.get(sessionId); - if (!transport) { - res.status(404).json({ - jsonrpc: '2.0', - id: null, - error: { code: -32000, message: 'SSE session not found or expired' }, - }); - return; - } - - sseSessionLastSeen.set(sessionId, Date.now()); - - try { - await transport.handlePostMessage(req, res, req.body); - } catch (error) { - logger.error({ error, sessionId }, 'Training agent: SSE message error'); - if (!res.headersSent) { - res.status(500).json({ - jsonrpc: '2.0', - id: null, - error: { code: -32603, message: 'Internal server error' }, - }); - } - } - }); - - logger.info('Training agent routes configured'); + logger.info({ tenants: TENANT_IDS }, 'Training agent routes configured'); return router; } diff --git a/server/src/training-agent/task-handlers.ts b/server/src/training-agent/task-handlers.ts index ba11c9287c..5895cbdc66 100644 --- a/server/src/training-agent/task-handlers.ts +++ b/server/src/training-agent/task-handlers.ts @@ -3510,7 +3510,7 @@ export async function handleBuildCreative(args: ToolArgs, ctx: TrainingContext): const creative = session.creatives.get(req.creative_id) ?? getComplianceCreative(req.creative_id); if (!creative) { return { - errors: [{ code: 'NOT_FOUND', message: `Creative "${req.creative_id}" not found. Use sync_creatives to upload or list_creatives to browse.` }], + errors: [{ code: 'CREATIVE_NOT_FOUND', message: `Creative "${req.creative_id}" not found. Use sync_creatives to upload or list_creatives to browse.` }], }; } @@ -3789,7 +3789,7 @@ export async function handleReportUsage(args: ToolArgs, ctx: TrainingContext) { if (record.creative_id) { const creative = session.creatives.get(record.creative_id) ?? getComplianceCreative(record.creative_id); if (!creative) { - errors.push({ code: 'NOT_FOUND', message: `Creative "${record.creative_id}" not found in session.`, field: `usage[${i}].creative_id` }); + errors.push({ code: 'CREATIVE_NOT_FOUND', message: `Creative "${record.creative_id}" not found in session.`, field: `usage[${i}].creative_id` }); continue; } @@ -3808,7 +3808,7 @@ export async function handleReportUsage(args: ToolArgs, ctx: TrainingContext) { if (record.signal_agent_segment_id) { const activation = session.signalActivations.get(record.signal_agent_segment_id); if (!activation) { - errors.push({ code: 'NOT_FOUND', message: `Signal "${record.signal_agent_segment_id}" not found in session. Use activate_signal first.`, field: `usage[${i}].signal_agent_segment_id` }); + errors.push({ code: 'SIGNAL_NOT_FOUND', message: `Signal "${record.signal_agent_segment_id}" not found in session. Use activate_signal first.`, field: `usage[${i}].signal_agent_segment_id` }); continue; } } diff --git a/server/src/training-agent/tenants/brand.ts b/server/src/training-agent/tenants/brand.ts new file mode 100644 index 0000000000..fda59e5361 --- /dev/null +++ b/server/src/training-agent/tenants/brand.ts @@ -0,0 +1,88 @@ +/** + * /brand tenant — brand-rights specialism. + * + * Native: getBrandIdentity, getRights, acquireRights (3 methods on + * BrandRightsPlatform). Merge seam: update_rights + creative_approval — + * spec-published but not yet in `AdcpToolMap`, so they ride + * `opts.customTools` until the spec adds them. + */ + +import { z } from 'zod'; +import type { TenantConfig } from '@adcp/sdk/server'; +import { TrainingBrandPlatform } from '../v6-brand-platform.js'; +import { getTenantSigningMaterial } from './signing.js'; +import { customToolFor } from './custom-tool-helper.js'; +import { handleUpdateRights, handleCreativeApproval } from '../brand-handlers.js'; + +const TENANT_ID = 'brand'; + +const ACCOUNT_REF = z.object({ + publisher_id: z.string().optional(), + buyer_id: z.string().optional(), + sandbox: z.boolean().optional(), +}).passthrough().optional(); + +const BRAND_REF = z.object({ + domain: z.string().optional(), +}).passthrough().optional(); + +const CONTEXT_REF = z.any().optional(); + +const UPDATE_RIGHTS_SCHEMA = { + rights_id: z.string(), + end_date: z.string().optional(), + impression_cap: z.number().optional(), + paused: z.boolean().optional(), + account: ACCOUNT_REF, + brand: BRAND_REF, + context: CONTEXT_REF, +}; + +const CREATIVE_APPROVAL_SCHEMA = { + rights_id: z.string().optional(), + rights_grant_id: z.string().optional(), + creative_url: z.string().optional(), + creative_id: z.string().optional(), + creative_format: z.string().optional(), + creative: z.object({ + creative_id: z.string().optional(), + format: z.string().optional(), + assets: z.array(z.any()).optional(), + }).passthrough().optional(), + account: ACCOUNT_REF, + brand: BRAND_REF, + context: CONTEXT_REF, +}; + +export function buildBrandTenantConfig(host: string): { + tenantId: string; + config: TenantConfig; +} { + const material = getTenantSigningMaterial(TENANT_ID); + return { + tenantId: TENANT_ID, + config: { + agentUrl: `${host}/${TENANT_ID}`, + signingKey: material.signingKey, + label: 'Training agent — brand', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + platform: new TrainingBrandPlatform() as any, + serverOptions: { + customTools: { + update_rights: customToolFor( + 'update_rights', + 'Update an existing rights grant — extend dates, adjust impression caps, or pause/resume.', + UPDATE_RIGHTS_SCHEMA, + handleUpdateRights, + ), + creative_approval: customToolFor( + 'creative_approval', + 'Submit a generated creative for brand approval against rights grant terms.', + CREATIVE_APPROVAL_SCHEMA, + handleCreativeApproval, + ), + }, + }, + }, + }; +} diff --git a/server/src/training-agent/tenants/comply.ts b/server/src/training-agent/tenants/comply.ts new file mode 100644 index 0000000000..ac0bc1f206 --- /dev/null +++ b/server/src/training-agent/tenants/comply.ts @@ -0,0 +1,201 @@ +/** + * Per-tenant `comply_test_controller` adapter sets. + * + * Each tenant exposes only the comply scenarios applicable to its surface + * (sales tenant: media-buy / delivery / product seeds; creative tenant: + * creative-status / creative-format seeds; etc.). The framework auto- + * derives `capabilities.compliance_testing.scenarios` from the supplied + * adapters (per `c08b1052`). + * + * Implementation: shim through to the v5 `handleComplyTestController` — + * same approach as the rest of the v6 spike. The v5 handler reads + * `scenario` + `params` from its `ToolArgs` and dispatches to per-scenario + * code (with session-keyed state via `account` + `brand` in args). For + * each v6 adapter we synthesize the right `ToolArgs` and translate the + * v5 response into the v6 typed result. + */ + +import { z } from 'zod'; +import { + TestControllerError, + type ComplyControllerConfig, + type ComplyControllerContext, +} from '@adcp/sdk/testing'; +import { TOOL_INPUT_SHAPE } from '@adcp/sdk/server'; +import { handleComplyTestController } from '../comply-test-controller.js'; +import type { ToolArgs, TrainingContext } from '../types.js'; + +const trainingCtx: TrainingContext = { mode: 'open', principal: 'static:public' }; + +/** + * v5 handler return shape — wide union of seed/force/simulate response + * envelopes. We narrow per-call site based on the scenario being shimmed. + */ +interface V5Response { + success: boolean; + error?: string; + error_detail?: string; + current_state?: string; + // ...other fields are scenario-specific + [key: string]: unknown; +} + +/** + * Generic v5 → v6 comply-adapter shim. Builds the `ToolArgs` for the v5 + * handler, dispatches, throws `TestControllerError` on `success: false`. + */ +async function dispatchV5(scenario: string, params: Record, input: Record): Promise { + // v5 handler reads brand/account from the wire-shaped args to derive + // the session key. `ctx.input` is the full raw input (including + // brand/account/sandbox/etc.), so spread it and stamp scenario+params. + const args = { ...input, scenario, params } as ToolArgs; + return await handleComplyTestController(args, trainingCtx) as V5Response; +} + +function throwOnFailure(result: V5Response): void { + if (result.success) return; + const code = result.error ?? 'INVALID_REQUEST'; + const message = result.error_detail ?? `Comply controller returned ${code}`; + throw new TestControllerError( + code as 'NOT_FOUND' | 'INVALID_PARAMS' | 'INVALID_TRANSITION' | 'FORBIDDEN' | 'UNKNOWN_SCENARIO' | 'INTERNAL_ERROR', + message, + typeof result.current_state === 'string' ? result.current_state : undefined, + ); +} + +// Generic adapter shim — the SDK's typed `SeedAdapter

`/`ForceAdapter

`/ +// `SimulateAdapter

` constrain `P` to per-scenario param interfaces, but +// our shim handles all scenarios uniformly. The casts at the assignment +// site narrow back to the typed adapter shape. +type AdapterShim = (params: unknown, ctx: ComplyControllerContext) => Promise; + +function seedAdapter(scenario: string): AdapterShim { + return async (params, ctx) => { + const result = await dispatchV5(scenario, params as Record, ctx.input); + throwOnFailure(result); + // Seed adapters return void — framework builds SeedSuccess envelope + // from its own idempotency cache. + }; +} + +function forceAdapter(scenario: string): AdapterShim { + return async (params, ctx) => { + const result = await dispatchV5(scenario, params as Record, ctx.input); + throwOnFailure(result); + return result; + }; +} + +function simulateAdapter(scenario: string): AdapterShim { + return async (params, ctx) => { + const result = await dispatchV5(scenario, params as Record, ctx.input); + throwOnFailure(result); + return result; + }; +} + +/** + * Sales tenant comply config. Exposes the scenarios storyboards in the + * sales track exercise: force_media_buy_status, simulate.delivery / + * .budget_spend, seed.product / .pricing_option / .media_buy / .creative. + */ +/** + * Extend the spec-canonical `TOOL_INPUT_SHAPE` with a top-level `account` + * field. v5 storyboards send `account: { brand: { domain }, sandbox }` at + * the top level — the v5 handler reads `account.brand.domain` for session + * keying. The v6 first-class registration uses the spec-canonical shape + * (no top-level `account` — spec routes account context through `context`) + * which would strip the field. F10's `inputSchema` extension point lets us + * accept the v5-vintage shape until storyboard fixtures migrate to spec. + */ +const SALES_COMPLY_INPUT_SCHEMA = { + ...TOOL_INPUT_SHAPE, + account: z.object({ + account_id: z.string().optional(), + brand: z.object({ domain: z.string().optional() }).passthrough().optional(), + sandbox: z.boolean().optional(), + }).passthrough().optional(), +}; + +/** + * Governance tenant comply config. Storyboards in the governance track + * test how governance interacts with sales-side state (e.g., a registered + * plan denying a media buy with a seeded product/pricing). They seed + * sales entities AT the governance tenant rather than dispatching across + * tenants. We accept the sales seeds here so a single-URL storyboard run + * can set up state and then exercise governance flows. + * + * In a production multi-agent deployment these seeds would target the + * sales agent directly; the storyboard runner doesn't yet route per-tool + * across tenants (separate finding). + */ +export function buildGovernanceComplyConfig(): ComplyControllerConfig { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const cast = (a: AdapterShim) => a as any; + return { + inputSchema: SALES_COMPLY_INPUT_SCHEMA, + seed: { + plan: cast(seedAdapter('seed_plan')), + product: cast(seedAdapter('seed_product')), + pricing_option: cast(seedAdapter('seed_pricing_option')), + media_buy: cast(seedAdapter('seed_media_buy')), + }, + force: { + account_status: cast(forceAdapter('force_account_status')), + session_status: cast(forceAdapter('force_session_status')), + media_buy_status: cast(forceAdapter('force_media_buy_status')), + }, + simulate: { + budget_spend: cast(simulateAdapter('simulate_budget_spend')), + delivery: cast(simulateAdapter('simulate_delivery')), + }, + }; +} + +/** + * Creative tenant comply config. Scenarios applicable to a creative + * ad-server, plus sales seeds for storyboards that set up a sales + * context before exercising creative flows (creative_generative/seller). + */ +export function buildCreativeComplyConfig(): ComplyControllerConfig { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const cast = (a: AdapterShim) => a as any; + return { + inputSchema: SALES_COMPLY_INPUT_SCHEMA, + seed: { + creative: cast(seedAdapter('seed_creative')), + // F14 (`bd0d4028`) added the `creative_format` slot — needed for + // `pagination_integrity_creative_formats` storyboard which seeds + // multiple format fixtures and walks list_creative_formats pagination. + creative_format: cast(seedAdapter('seed_creative_format')), + product: cast(seedAdapter('seed_product')), + pricing_option: cast(seedAdapter('seed_pricing_option')), + media_buy: cast(seedAdapter('seed_media_buy')), + }, + force: { + creative_status: cast(forceAdapter('force_creative_status')), + media_buy_status: cast(forceAdapter('force_media_buy_status')), + }, + }; +} + +export function buildSalesComplyConfig(): ComplyControllerConfig { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const cast = (a: AdapterShim) => a as any; + return { + inputSchema: SALES_COMPLY_INPUT_SCHEMA, + seed: { + product: cast(seedAdapter('seed_product')), + pricing_option: cast(seedAdapter('seed_pricing_option')), + media_buy: cast(seedAdapter('seed_media_buy')), + creative: cast(seedAdapter('seed_creative')), + }, + force: { + media_buy_status: cast(forceAdapter('force_media_buy_status')), + }, + simulate: { + delivery: cast(simulateAdapter('simulate_delivery')), + budget_spend: cast(simulateAdapter('simulate_budget_spend')), + }, + }; +} diff --git a/server/src/training-agent/tenants/creative-builder.ts b/server/src/training-agent/tenants/creative-builder.ts new file mode 100644 index 0000000000..0dd60c4ed4 --- /dev/null +++ b/server/src/training-agent/tenants/creative-builder.ts @@ -0,0 +1,33 @@ +/** + * /creative-builder tenant — creative-template + creative-generative. + * + * Per F13's CreativeBuilderPlatform unification. Distinct from the + * `/creative` tenant which serves the creative-ad-server archetype. + */ + +import type { TenantConfig } from '@adcp/sdk/server'; +import { TrainingCreativeBuilderPlatform } from '../v6-creative-builder-platform.js'; +import { getTenantSigningMaterial } from './signing.js'; +import { buildCreativeComplyConfig } from './comply.js'; + +const TENANT_ID = 'creative-builder'; + +export function buildCreativeBuilderTenantConfig(host: string): { + tenantId: string; + config: TenantConfig; +} { + const material = getTenantSigningMaterial(TENANT_ID); + return { + tenantId: TENANT_ID, + config: { + agentUrl: `${host}/${TENANT_ID}`, + signingKey: material.signingKey, + label: 'Training agent — creative builder', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + platform: new TrainingCreativeBuilderPlatform() as any, + serverOptions: { + complyTest: buildCreativeComplyConfig(), + }, + }, + }; +} diff --git a/server/src/training-agent/tenants/creative.ts b/server/src/training-agent/tenants/creative.ts new file mode 100644 index 0000000000..c670358b2b --- /dev/null +++ b/server/src/training-agent/tenants/creative.ts @@ -0,0 +1,30 @@ +/** + * /creative tenant — creative-ad-server specialism. + */ + +import type { TenantConfig } from '@adcp/sdk/server'; +import { TrainingCreativePlatform } from '../v6-creative-platform.js'; +import { getTenantSigningMaterial } from './signing.js'; +import { buildCreativeComplyConfig } from './comply.js'; + +const TENANT_ID = 'creative'; + +export function buildCreativeTenantConfig(host: string): { + tenantId: string; + config: TenantConfig; +} { + const material = getTenantSigningMaterial(TENANT_ID); + return { + tenantId: TENANT_ID, + config: { + agentUrl: `${host}/${TENANT_ID}`, + signingKey: material.signingKey, + label: 'Training agent — creative', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + platform: new TrainingCreativePlatform() as any, + serverOptions: { + complyTest: buildCreativeComplyConfig(), + }, + }, + }; +} diff --git a/server/src/training-agent/tenants/custom-tool-helper.ts b/server/src/training-agent/tenants/custom-tool-helper.ts new file mode 100644 index 0000000000..a9c7ef9013 --- /dev/null +++ b/server/src/training-agent/tenants/custom-tool-helper.ts @@ -0,0 +1,116 @@ +/** + * Custom-tool helper for tenants that need to register tools outside the + * platform interface (e.g., `/brand` registering `update_rights` / + * `creative_approval` while waiting for them to land in `AdcpToolMap`). + * + * Wraps a v5-style `(ToolArgs, TrainingContext) → object` handler into + * the SDK's `AdcpCustomToolConfig` shape so it can ride the + * `opts.customTools` merge seam on `createAdcpServerFromPlatform`. + */ + +import { z } from 'zod'; +import { wrapEnvelope } from '@adcp/sdk/server'; +import type { AdcpCustomToolConfig } from '@adcp/sdk/server'; +import { createLogger } from '../../logger.js'; +import { runWithSessionContext, flushDirtySessions } from '../state.js'; +import type { ToolArgs, TrainingContext } from '../types.js'; + +const logger = createLogger('training-agent-custom-tool'); + +interface AdaptedResponse { + content: Array<{ type: 'text'; text: string }>; + structuredContent: Record; + isError?: boolean; + [key: string]: unknown; +} + +interface InlineError { + code: string; + message: string; + field?: string; + details?: unknown; + recovery?: string; +} + +function toAdaptedResponse(result: unknown, callerContext: unknown): AdaptedResponse { + const errsField = (result as { errors?: unknown[] } | null | undefined)?.errors; + if (Array.isArray(errsField) && errsField.length > 0) { + const first = errsField[0] as InlineError; + const errorObj: Record = { code: first.code, message: first.message }; + if (first.field) errorObj.field = first.field; + if (first.details !== undefined) errorObj.details = first.details; + if (first.recovery) errorObj.recovery = first.recovery; + const body = wrapEnvelope({ adcp_error: errorObj }, { context: callerContext }); + return { + isError: true, + content: [{ type: 'text', text: JSON.stringify(body) }], + structuredContent: body, + }; + } + const inner = (result ?? {}) as Record; + const withEnvelope = wrapEnvelope(inner, { + ...(callerContext !== undefined && typeof callerContext === 'object' && callerContext !== null + ? { context: callerContext } + : {}), + }); + const response = withEnvelope as Record; + return { + content: [{ type: 'text', text: JSON.stringify(response) }], + structuredContent: response, + }; +} + +function serviceUnavailable(err: unknown, callerContext: unknown): AdaptedResponse { + const errorObj: Record = { + code: 'SERVICE_UNAVAILABLE', + message: err instanceof Error ? err.message : 'Unknown error', + recovery: 'transient', + }; + const body = wrapEnvelope({ adcp_error: errorObj }, { context: callerContext }); + return { + isError: true, + content: [{ type: 'text', text: JSON.stringify(body) }], + structuredContent: body, + }; +} + +type LegacyHandler = (args: ToolArgs, ctx: TrainingContext) => object | Promise; + +/** + * Wrap a v5-style handler into an `AdcpCustomToolConfig` for + * `opts.customTools` registration. Handles the `(args, ctx)` → + * `(args, extra)` adaptation, session-context wrapping, and envelope + * shaping. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function customToolFor(name: string, description: string, inputSchema: Record, handler: LegacyHandler): AdcpCustomToolConfig { + return { + description, + inputSchema, + handler: async (args: unknown, extra: unknown) => { + const params = (args as Record) ?? {}; + const authInfo = ((extra as { authInfo?: { clientId?: string } } | undefined)?.authInfo) ?? undefined; + const trainingCtx: TrainingContext = { + mode: 'open', + principal: authInfo?.clientId ?? 'anonymous', + }; + const { context: callerContext, ...handlerArgs } = params; + return runWithSessionContext(async () => { + let result: unknown; + try { + result = await Promise.resolve(handler(handlerArgs as ToolArgs, trainingCtx)); + } catch (err) { + logger.error({ err, tool: name }, 'custom-tool handler threw'); + return serviceUnavailable(err, callerContext); + } + try { + await flushDirtySessions(); + } catch (err) { + logger.error({ err, tool: name }, 'custom-tool flushDirtySessions threw'); + return serviceUnavailable(err, callerContext); + } + return toAdaptedResponse(result, callerContext); + }); + }, + }; +} diff --git a/server/src/training-agent/tenants/governance.ts b/server/src/training-agent/tenants/governance.ts new file mode 100644 index 0000000000..1e64ca57fc --- /dev/null +++ b/server/src/training-agent/tenants/governance.ts @@ -0,0 +1,37 @@ +/** + * /governance tenant — campaign-governance + property-lists + + * collection-lists + content-standards specialisms. + * + * Bundles the governance/buyer-side specialisms in one tenant since + * storyboards frequently span them (e.g., property-list policy cited in + * a check_governance finding). Splitting further is a follow-up if any + * specific surface needs distinct credentials or independent tenant + * lifecycle. + */ + +import type { TenantConfig } from '@adcp/sdk/server'; +import { TrainingGovernancePlatform } from '../v6-governance-platform.js'; +import { getTenantSigningMaterial } from './signing.js'; +import { buildGovernanceComplyConfig } from './comply.js'; + +const TENANT_ID = 'governance'; + +export function buildGovernanceTenantConfig(host: string): { + tenantId: string; + config: TenantConfig; +} { + const material = getTenantSigningMaterial(TENANT_ID); + return { + tenantId: TENANT_ID, + config: { + agentUrl: `${host}/${TENANT_ID}`, + signingKey: material.signingKey, + label: 'Training agent — governance', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + platform: new TrainingGovernancePlatform() as any, + serverOptions: { + complyTest: buildGovernanceComplyConfig(), + }, + }, + }; +} diff --git a/server/src/training-agent/tenants/registry.ts b/server/src/training-agent/tenants/registry.ts new file mode 100644 index 0000000000..7b78d8bcd0 --- /dev/null +++ b/server/src/training-agent/tenants/registry.ts @@ -0,0 +1,181 @@ +/** + * Multi-tenant TenantRegistry setup. + * + * Five tenants — `/sales`, `/signals`, `/creative`, `/governance`, `/brand` — + * each with its own `DecisioningPlatform` impl, signing key, and specialism + * declarations. Path-based routing per `cbff7773` (`resolveByRequest(host, + * pathname)`). + * + * Today's wedge: only `/signals` is registered. Other tenants follow as + * specialism platforms get ported. + */ + +import type { Request } from 'express'; +import { + createTenantRegistry, + type TenantRegistry, + type CreateAdcpServerFromPlatformOptions, +} from '@adcp/sdk/server'; +import { getIdempotencyStore, scopedPrincipal } from '../idempotency.js'; +import { getWebhookSigningMaterial } from '../webhooks.js'; +import { buildSignalsTenantConfig } from './signals.js'; +import { buildSalesTenantConfig } from './sales.js'; +import { buildGovernanceTenantConfig } from './governance.js'; +import { buildCreativeTenantConfig } from './creative.js'; +import { buildCreativeBuilderTenantConfig } from './creative-builder.js'; +import { buildBrandTenantConfig } from './brand.js'; +import { createLogger } from '../../logger.js'; + +const logger = createLogger('training-agent-tenants'); + +/** + * No-op JWKS validator for the spike. The SDK's default validator fetches + * `{agentUrl}/.well-known/brand.json` which resolves to host root — but our + * brand.json sits under `/api/training-agent/.well-known/brand.json`, not + * server root. Pre-flight validation can't succeed against the wrong URL. + * + * Without a passing validator, tenants are stuck in `'pending'` and the + * registry refuses traffic. The spike trades pre-flight validation for + * functionality; AAO certification needs either: + * (a) brand.json hosted at server root (path-routed multi-tenant + * requires this anyway per RFC 5785), OR + * (b) a custom validator that knows our mount path. + */ +const noopJwksValidator = { + async validate() { + return { ok: true as const }; + }, +}; + +/** + * Canonical agent URL used as each tenant's `agentUrl` and advertised in + * `brand.json`. In production this is `https://test-agent.adcontextprotocol.org`; + * locally it falls back to `http://localhost`. Tenants register at + * `${CANONICAL_BASE}/` so the path prefix matches `/sales/mcp`, + * `/signals/mcp`, etc. — the same path Express routes resolve to inside + * the router regardless of whether the request arrived via host-based + * dispatch (`test-agent.adcontextprotocol.org/sales/mcp`) or the local + * mount (`/api/training-agent/sales/mcp` — Express strips the prefix + * before the router runs). + */ +const CANONICAL_BASE: string = (() => { + const candidates = [process.env.BASE_URL, process.env.TRAINING_AGENT_URL]; + for (const candidate of candidates) { + if (!candidate) continue; + const trimmed = candidate.trim().replace(/\/$/, ''); + try { + const url = new URL(trimmed); + if (url.host) return trimmed; + } catch { + // not a valid absolute URL, fall through + } + } + return 'http://localhost'; +})(); + +const CANONICAL_HOST = new URL(CANONICAL_BASE).host; + +function buildHostBaseUrl(): string { + return CANONICAL_BASE; +} + +/** + * Host the registry should match against. Always the canonical host + * (matching what tenants register with) regardless of the actual Host + * header on the request — supertest, storyboard runner, and production + * proxies present different host values, none of which are interesting + * for tenant resolution. + */ +export function resolveTenantHost(_req: Request): string { + return CANONICAL_HOST; +} + +function buildDefaultServerOptions(): CreateAdcpServerFromPlatformOptions { + return { + name: 'adcp-training-agent', + version: '1.0.0', + idempotency: getIdempotencyStore(), + webhooks: getWebhookSigningMaterial(), + mergeSeam: 'log-once', + validation: { requests: 'off', responses: 'off' }, + // F11 — accept loopback push_notification_config.url in non-production. + // Conformance storyboards bind a loopback HTTP receiver and supply + // `http://127.0.0.1:/webhook`; production deployments + // (NODE_ENV=production) keep the SSRF-safe rejection. The framework + // emits a footgun warning if this is set in production without an + // ack env, which we tolerate (the warning surfaces operator misconfig). + allowPrivateWebhookUrls: process.env.NODE_ENV !== 'production', + resolveIdempotencyPrincipal: ( + ctx: { authInfo?: { clientId?: string } }, + params: Record, + _toolName: string, + ) => { + const auth = ctx.authInfo?.clientId ?? 'anonymous'; + if (auth !== 'static:public') return auth; + const account = params.account as { account_id?: string; brand?: { domain?: string } } | undefined; + const accountScope = account?.account_id + ? `a:${account.account_id}` + : account?.brand?.domain + ? `b:${account.brand.domain.toLowerCase()}` + : undefined; + return scopedPrincipal(auth, accountScope); + }, + }; +} + +/** + * Per-router-instance registry holder. Created on first request to capture + * the actual host (ephemeral port for tests, BASE_URL host in production). + * One holder per `createTrainingAgentRouter()` call — necessary so multiple + * server instances in the same process (e.g., test isolation) don't share + * a cached registry pinned to a stale port. + */ +export interface RegistryHolder { + get(req: Request): Promise; +} + +export function createRegistryHolder(): RegistryHolder { + let registry: TenantRegistry | null = null; + let pendingInit: Promise | null = null; + + return { + async get(req: Request): Promise { + if (registry) return registry; + if (pendingInit) return pendingInit; + void req; // request only used for registry initialization timing; host is stable + pendingInit = (async () => { + const hostBase = buildHostBaseUrl(); + const reg = createTenantRegistry({ + defaultServerOptions: buildDefaultServerOptions(), + jwksValidator: noopJwksValidator, + autoValidate: true, + }); + const signals = buildSignalsTenantConfig(hostBase); + const sales = buildSalesTenantConfig(hostBase); + const governance = buildGovernanceTenantConfig(hostBase); + const creative = buildCreativeTenantConfig(hostBase); + const creativeBuilder = buildCreativeBuilderTenantConfig(hostBase); + const brand = buildBrandTenantConfig(hostBase); + // awaitFirstValidation:true blocks until the no-op validator + // promotes the tenant to 'healthy'. Without it the first request + // would race the background validation and see 'pending' (refused + // traffic) for the first ~10ms. + await Promise.all([ + reg.register(signals.tenantId, signals.config, { awaitFirstValidation: true }), + reg.register(sales.tenantId, sales.config, { awaitFirstValidation: true }), + reg.register(governance.tenantId, governance.config, { awaitFirstValidation: true }), + reg.register(creative.tenantId, creative.config, { awaitFirstValidation: true }), + reg.register(creativeBuilder.tenantId, creativeBuilder.config, { awaitFirstValidation: true }), + reg.register(brand.tenantId, brand.config, { awaitFirstValidation: true }), + ]); + logger.info( + { hostBase, tenants: ['signals', 'sales', 'governance', 'creative', 'creative-builder', 'brand'] }, + 'Tenant registry initialized', + ); + registry = reg; + return reg; + })(); + return pendingInit; + }, + }; +} diff --git a/server/src/training-agent/tenants/router.ts b/server/src/training-agent/tenants/router.ts new file mode 100644 index 0000000000..41d12588ff --- /dev/null +++ b/server/src/training-agent/tenants/router.ts @@ -0,0 +1,155 @@ +/** + * Express router for path-routed multi-tenant training agent. + * + * Mounts: + * //mcp — MCP transport for the tenant + * //.well-known/brand.json — per-tenant brand discovery + * + * The host-level shared brand.json (listing all tenant public keys) is + * mounted at the parent training-agent router level, not here. + */ + +import { Router, type Request, type Response, type RequestHandler } from 'express'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { createLogger } from '../../logger.js'; +import { runWithSessionContext, flushDirtySessions } from '../state.js'; +import { createRegistryHolder, resolveTenantHost, type RegistryHolder } from './registry.js'; +import { getAggregatedPublicJwks } from './signing.js'; + +const logger = createLogger('training-agent-tenant-router'); + +function setCORSHeaders(res: Response): void { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, mcp-session-id'); + res.setHeader('Access-Control-Expose-Headers', 'Content-Type'); +} + +/** + * Tenant MCP handler. The tenantId is bound at route definition (each + * tenant gets its own Express route — `parent.post('/${tenantId}/mcp')`), + * so dispatch resolves against the canonical host + tenant path the + * registry was registered with — independent of the actual request URL. + * Same code path works for host-based dispatch + * (`test-agent.adcontextprotocol.org/sales/mcp`) and the local mount + * (`/api/training-agent/sales/mcp`). + */ +function tenantMcpHandler(holder: RegistryHolder, tenantId: string) { + return async (req: Request, res: Response): Promise => { + setCORSHeaders(res); + + const host = resolveTenantHost(req); + const registry = await holder.get(req); + const resolved = registry.resolveByRequest(host, `/${tenantId}/mcp`); + if (!resolved) { + logger.warn( + { + host, + tenantId, + registered: registry.list().map(s => ({ id: s.tenantId, health: s.health, agentUrl: s.agentUrl })), + }, + 'no tenant match', + ); + res.status(404).json({ + jsonrpc: '2.0', + id: null, + error: { code: -32000, message: `Tenant '${tenantId}' is not registered` }, + }); + return; + } + + // MCP transport Accept-header workaround (mirror v5 — clients sending + // only `application/json` need text/event-stream added so the + // StreamableHTTP transport's Accept check passes). + const acceptHeader = req.headers.accept; + const hasJson = typeof acceptHeader === 'string' && acceptHeader.includes('application/json'); + const hasSse = typeof acceptHeader === 'string' && acceptHeader.includes('text/event-stream'); + if (hasJson && !hasSse) { + const rewritten = `${acceptHeader}, text/event-stream`; + req.headers.accept = rewritten; + const raw = (req as unknown as { rawHeaders?: string[] }).rawHeaders; + if (Array.isArray(raw)) { + for (let i = 0; i < raw.length; i += 2) { + if (raw[i].toLowerCase() === 'accept') raw[i + 1] = rewritten; + } + } + } + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + + try { + await resolved.server.connect(transport); + logger.debug({ tenantId: resolved.tenantId, method: req.body?.method }, 'tenant MCP request'); + await runWithSessionContext(async () => { + await transport.handleRequest(req, res, req.body); + await flushDirtySessions(); + }); + } catch (err) { + logger.error({ err, tenantId: resolved.tenantId }, 'tenant MCP error'); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + id: null, + error: { code: -32603, message: 'Internal server error' }, + }); + } + } finally { + // Close server connection after handling — tenant servers are + // per-request transient, matching the v5 pattern. + await resolved.server.close().catch(() => {}); + } + }; +} + +/** + * Mount tenant routes under the training-agent router. Each tenant gets: + * POST //mcp — MCP endpoint + * OPTIONS //mcp — CORS preflight + */ +export interface TenantRouteMiddleware { + /** Rate limiter applied to every tenant POST. */ + rateLimit?: RequestHandler; + /** Bearer-auth middleware applied to every tenant POST (sets `res.locals.trainingPrincipal`). */ + requireAuth?: RequestHandler; +} + +export function mountTenantRoutes( + parent: Router, + tenantIds: readonly string[], + middleware: TenantRouteMiddleware = {}, +): void { + const holder = createRegistryHolder(); + const mw: RequestHandler[] = []; + if (middleware.rateLimit) mw.push(middleware.rateLimit); + if (middleware.requireAuth) mw.push(middleware.requireAuth); + for (const tenantId of tenantIds) { + parent.options(`/${tenantId}/mcp`, (_req, res) => { + setCORSHeaders(res); + res.status(204).end(); + }); + parent.post(`/${tenantId}/mcp`, ...mw, tenantMcpHandler(holder, tenantId)); + parent.get(`/${tenantId}/mcp`, (_req, res) => { + setCORSHeaders(res); + res.setHeader('Allow', 'POST, OPTIONS'); + res.status(405).json({ + jsonrpc: '2.0', + id: null, + error: { code: -32000, message: 'Method not allowed. Use POST for MCP requests.' }, + }); + }); + } + + // Aggregated brand.json — lists every tenant's public key with its kid. + // SDK validator calls `new URL('/.well-known/brand.json', agentUrl)` which + // resolves to host root. For our mount under `/api/training-agent`, the + // SDK's validator hits the host root path which is OUTSIDE our router — + // so the spike runs with `autoValidate: false` and we only serve this for + // discovery / debug introspection. + parent.get('/.well-known/brand.json', (_req, res) => { + res.setHeader('Cache-Control', 'public, max-age=300'); + res.json({ jwks: getAggregatedPublicJwks() }); + }); +} diff --git a/server/src/training-agent/tenants/sales.ts b/server/src/training-agent/tenants/sales.ts new file mode 100644 index 0000000000..10c2fa7fa5 --- /dev/null +++ b/server/src/training-agent/tenants/sales.ts @@ -0,0 +1,33 @@ +/** + * /sales tenant — sales-non-guaranteed + sales-guaranteed specialisms. + * + * Distinct platform from /signals (single-specialism per tenant). Buyers + * call sales-track tools at this URL; signals tools live on /signals. + */ + +import type { TenantConfig } from '@adcp/sdk/server'; +import { TrainingSalesPlatform } from '../v6-sales-platform.js'; +import { getTenantSigningMaterial } from './signing.js'; +import { buildSalesComplyConfig } from './comply.js'; + +const TENANT_ID = 'sales'; + +export function buildSalesTenantConfig(host: string): { + tenantId: string; + config: TenantConfig; +} { + const material = getTenantSigningMaterial(TENANT_ID); + return { + tenantId: TENANT_ID, + config: { + agentUrl: `${host}/${TENANT_ID}`, + signingKey: material.signingKey, + label: 'Training agent — sales', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + platform: new TrainingSalesPlatform() as any, + serverOptions: { + complyTest: buildSalesComplyConfig(), + }, + }, + }; +} diff --git a/server/src/training-agent/tenants/signals.ts b/server/src/training-agent/tenants/signals.ts new file mode 100644 index 0000000000..41958f5000 --- /dev/null +++ b/server/src/training-agent/tenants/signals.ts @@ -0,0 +1,31 @@ +/** + * /signals tenant — signal-marketplace + signal-owned specialisms. + * + * Reuses our existing v6 `TrainingPlatform` which already claims + * `signal-marketplace` + `signal-owned` and implements `SignalsPlatform`. + * For the tenant model, the platform stays focused on signals — the rest + * of the v5 surface (sales, governance, etc.) lives in other tenants. + */ + +import type { TenantConfig } from '@adcp/sdk/server'; +import { TrainingPlatform } from '../v6-platform.js'; +import { getTenantSigningMaterial } from './signing.js'; + +const TENANT_ID = 'signals'; + +export function buildSignalsTenantConfig(host: string): { + tenantId: string; + config: TenantConfig; +} { + const material = getTenantSigningMaterial(TENANT_ID); + return { + tenantId: TENANT_ID, + config: { + agentUrl: `${host}/${TENANT_ID}`, + signingKey: material.signingKey, + label: 'Training agent — signals', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + platform: new TrainingPlatform() as any, + }, + }; +} diff --git a/server/src/training-agent/tenants/signing.ts b/server/src/training-agent/tenants/signing.ts new file mode 100644 index 0000000000..3765c381fa --- /dev/null +++ b/server/src/training-agent/tenants/signing.ts @@ -0,0 +1,94 @@ +/** + * Per-tenant signing material. + * + * Each tenant has its own Ed25519 keypair and KID. Public keys aggregate + * into a shared `/.well-known/brand.json` at the host root — RFC 5785 + * well-known URIs are origin-scoped, so path-based tenants on one host + * share the discovery file. The SDK's TenantRegistry default JWKS + * validator fetches `new URL('/.well-known/brand.json', agentUrl)` which + * resolves to host root regardless of the tenant's path prefix. + * + * Production: per-tenant GCP KMS keys (env vars + * `GCP_KMS_WEBHOOK_KEY_VERSION_${TENANT_ID}`). Until KMS keys are + * provisioned, ephemeral per-tenant keys generated at boot. KIDs change + * across restarts — fine for the sandbox, AAO certification waits for + * KMS-backed keys. + */ + +import { generateKeyPairSync } from 'node:crypto'; +import type { TenantSigningKey } from '@adcp/sdk/server'; +import type { AdcpJsonWebKey } from '@adcp/sdk/signing'; +import { createLogger } from '../../logger.js'; + +const logger = createLogger('training-agent-tenant-signing'); + +interface TenantMaterial { + signingKey: TenantSigningKey; + publicJwk: AdcpJsonWebKey; +} + +const materials: Map = new Map(); + +/** + * Generate an ephemeral Ed25519 keypair for a tenant. KID = `training-${tenantId}-${random}`. + * Stable for the process lifetime; regenerates on restart. + */ +function generateEphemeralKey(tenantId: string): TenantMaterial { + const { publicKey, privateKey } = generateKeyPairSync('ed25519'); + // node:crypto JWK export is plain Record; use a permissive + // shape that satisfies both AdcpJsonWebKey and TenantSigningKey's + // JsonWebKey expectation. + const privateJwk = privateKey.export({ format: 'jwk' }) as Record; + const publicJwk = publicKey.export({ format: 'jwk' }) as Record; + const kid = `training-${tenantId}-${Math.random().toString(16).slice(2, 10)}`; + + const signingKey: TenantSigningKey = { + keyId: kid, + publicJwk: { ...publicJwk, kid }, + privateJwk: { ...privateJwk, kid }, + }; + + // brand.json's jwks.keys[] entries are AdcpJsonWebKey shape — adcp_use, + // key_ops, alg, use are all required by the SDK validator. + const brandJwk: AdcpJsonWebKey = { + ...publicJwk, + kid, + alg: 'EdDSA', + adcp_use: 'webhook-signing', + key_ops: ['verify'], + use: 'sig', + } as AdcpJsonWebKey; + + logger.warn( + { tenantId, kid }, + 'Tenant signing key generated ephemerally. Provision GCP KMS keys for stable kids.', + ); + + return { signingKey, publicJwk: brandJwk }; +} + +/** + * Get-or-create signing material for a tenant. Memoizes per process. + */ +export function getTenantSigningMaterial(tenantId: string): TenantMaterial { + let m = materials.get(tenantId); + if (!m) { + m = generateEphemeralKey(tenantId); + materials.set(tenantId, m); + } + return m; +} + +/** + * Aggregate public JWKs across all registered tenants. Served at the host's + * `/.well-known/brand.json` so the SDK validator finds each tenant's kid in + * one shared discovery document. + */ +export function getAggregatedPublicJwks(): { keys: AdcpJsonWebKey[] } { + return { keys: Array.from(materials.values()).map(m => m.publicJwk) }; +} + +/** Reset state — tests only. */ +export function resetTenantSigning(): void { + materials.clear(); +} diff --git a/server/src/training-agent/tenants/tenant-smoke.test.ts b/server/src/training-agent/tenants/tenant-smoke.test.ts new file mode 100644 index 0000000000..2d08d185c2 --- /dev/null +++ b/server/src/training-agent/tenants/tenant-smoke.test.ts @@ -0,0 +1,108 @@ +/** + * Smoke test: tenant routes mount, /signals/mcp dispatches, brand.json + * exposes the tenant key. + */ + +import { describe, it, expect } from 'vitest'; +import express from 'express'; +import http from 'node:http'; + +process.env.PUBLIC_TEST_AGENT_TOKEN = 'test-token'; + +async function bootServer(): Promise<{ baseUrl: string; close: () => Promise }> { + const { createTrainingAgentRouter } = await import('../index.js'); + const app = express(); + app.use(express.json({ + limit: '5mb', + verify: (req, _res, buf) => { + (req as unknown as { rawBody: string }).rawBody = buf.toString('utf8'); + }, + })); + app.use('/api/training-agent', createTrainingAgentRouter()); + const srv = http.createServer(app); + await new Promise(r => srv.listen(0, '127.0.0.1', () => r())); + const port = (srv.address() as { port: number }).port; + return { + baseUrl: `http://127.0.0.1:${port}/api/training-agent`, + close: () => new Promise(r => srv.close(() => r())), + }; +} + +describe('tenant routing smoke', () => { + it('serves brand.json with tenant public keys', async () => { + const { baseUrl, close } = await bootServer(); + try { + // Trigger registry init by hitting MCP first (lazy build). + const initR = await fetch(`${baseUrl}/signals/mcp`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + authorization: 'Bearer test-token', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-03-26', + clientInfo: { name: 'x', version: '1' }, + capabilities: {}, + }, + }), + }); + // eslint-disable-next-line no-console + console.log('init status:', initR.status, 'body[:200]:', (await initR.text()).slice(0, 300)); + const r = await fetch(`${baseUrl}/.well-known/brand.json`); + expect(r.status).toBe(200); + const body = await r.json() as { jwks: { keys: Array<{ kid: string; alg: string }> } }; + expect(Array.isArray(body.jwks?.keys)).toBe(true); + expect(body.jwks.keys.length).toBeGreaterThan(0); + const signalsKid = body.jwks.keys.find(k => k.kid?.includes('signals')); + expect(signalsKid).toBeDefined(); + // eslint-disable-next-line no-console + console.log('brand.json keys:', body.jwks.keys.map(k => ({ kid: k.kid, alg: k.alg }))); + } finally { + await close(); + } + }, 15000); + + it('dispatches /signals/mcp tools/list and returns only signals-tenant tools', async () => { + const { baseUrl, close } = await bootServer(); + try { + const url = `${baseUrl}/signals/mcp`; + await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + authorization: 'Bearer test-token', + }, + body: JSON.stringify({ + jsonrpc: '2.0', id: 1, method: 'initialize', + params: { protocolVersion: '2025-03-26', clientInfo: { name: 'x', version: '1' }, capabilities: {} }, + }), + }); + const list = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + authorization: 'Bearer test-token', + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }), + }); + const body = await list.json() as { result?: { tools?: Array<{ name: string }> } }; + const toolNames = (body.result?.tools ?? []).map(t => t.name).sort(); + // eslint-disable-next-line no-console + console.log('/signals tools:', toolNames); + expect(toolNames).toContain('get_signals'); + expect(toolNames).toContain('activate_signal'); + // Tenant should NOT expose mediaBuy / governance tools + expect(toolNames).not.toContain('create_media_buy'); + expect(toolNames).not.toContain('sync_plans'); + } finally { + await close(); + } + }, 15000); +}); diff --git a/server/src/training-agent/v6-brand-platform.ts b/server/src/training-agent/v6-brand-platform.ts new file mode 100644 index 0000000000..13238e3c7a --- /dev/null +++ b/server/src/training-agent/v6-brand-platform.ts @@ -0,0 +1,131 @@ +/** + * v6 BrandRightsPlatform for the `/brand` tenant. + * + * Single-specialism platform claiming `brand-rights`. Implements 3 methods + * the spec ships with stable schemas in `AdcpToolMap`: getBrandIdentity, + * getRights, acquireRights. The two related surfaces (`update_rights`, + * `creative_approval`) are spec-published but not yet in `AdcpToolMap`, + * so they ride the merge seam (`opts.customTools`) until v6.1+. + * + * Spike-grade port: shim through to v5 handlers via `translateV5Result`. + */ + +import { + AdcpError, + type DecisioningPlatform, + type BrandRightsPlatform, + type AccountStore, +} from '@adcp/sdk/server'; +import { + handleGetBrandIdentity, + handleGetRights, + handleAcquireRights, +} from './brand-handlers.js'; +import type { ToolArgs, TrainingContext } from './types.js'; + +interface TrainingBrandMeta { + brand_domain?: string; + [key: string]: unknown; +} + +interface TrainingBrandConfig { + strict: boolean; +} + +function buildTrainingCtx(account: { authInfo?: { principal?: string } } | undefined): TrainingContext { + return { + mode: 'open', + principal: account?.authInfo?.principal ?? 'anonymous', + }; +} + +function translateV5Result(result: unknown): T { + const errs = (result as { + errors?: Array<{ + code: string; + message: string; + field?: string; + details?: unknown; + recovery?: string; + }>; + } | undefined)?.errors; + if (Array.isArray(errs) && errs.length > 0) { + const first = errs[0]!; + const recovery = (first.recovery === 'transient' || first.recovery === 'correctable' || first.recovery === 'terminal') + ? first.recovery + : 'correctable'; + throw new AdcpError(first.code, { + recovery, + message: first.message, + ...(first.field !== undefined && { field: first.field }), + ...(first.details !== undefined && { details: first.details as Record }), + }); + } + return result as T; +} + +const trainingBrandAccounts: AccountStore = { + resolution: 'explicit', + resolve: async (ref, _ctx) => { + if (ref == null) { + return { + id: 'public_sandbox', + name: 'Public Sandbox', + status: 'active', + ctx_metadata: {}, + authInfo: { kind: 'public' }, + }; + } + const brandDomain = + 'brand' in ref && ref.brand && typeof ref.brand === 'object' && 'domain' in ref.brand + ? (ref.brand.domain as string | undefined) + : undefined; + const accountId = + 'account_id' in ref && typeof ref.account_id === 'string' ? ref.account_id : undefined; + const id = accountId ?? `synthetic_${brandDomain ?? 'anon'}`; + return { + id, + name: brandDomain ?? id, + status: 'active', + ...(brandDomain != null && { brand: { domain: brandDomain } }), + ...('operator' in ref && typeof ref.operator === 'string' && { operator: ref.operator }), + ctx_metadata: { brand_domain: brandDomain }, + authInfo: { kind: 'api_key' }, + }; + }, +}; + +export class TrainingBrandPlatform + implements DecisioningPlatform +{ + capabilities = { + specialisms: ['brand-rights'] as const, + creative_agents: [], + channels: [] as const, + pricingModels: ['cpm', 'cpa'] as const, + supportedBillings: ['agent', 'operator'] as const, + // brand-rights claims require capabilities.brand block per + // RequiredCapabilitiesFor. Empty inner object opts in; + // BrandRightsPlatform impl below auto-derives `brand.rights: true`. + brand: {}, + config: { strict: false }, + }; + + statusMappers = {}; + accounts: AccountStore = trainingBrandAccounts; + + brandRights: BrandRightsPlatform = { + getBrandIdentity: async (req, ctx) => { + const result = await handleGetBrandIdentity(req as ToolArgs, buildTrainingCtx(ctx.account)); + return translateV5Result(result); + }, + getRights: async (req, ctx) => { + const result = await handleGetRights(req as ToolArgs, buildTrainingCtx(ctx.account)); + return translateV5Result(result); + }, + acquireRights: async (req, ctx) => { + const result = await handleAcquireRights(req as ToolArgs, buildTrainingCtx(ctx.account)); + return translateV5Result(result); + }, + }; +} diff --git a/server/src/training-agent/v6-creative-builder-platform.ts b/server/src/training-agent/v6-creative-builder-platform.ts new file mode 100644 index 0000000000..8dbbde185e --- /dev/null +++ b/server/src/training-agent/v6-creative-builder-platform.ts @@ -0,0 +1,143 @@ +/** + * v6 CreativeBuilderPlatform for the `/creative-builder` tenant. + * + * F13 (`841616d7`) merged the template + generative archetypes into a + * single `CreativeBuilderPlatform` interface. This tenant claims both + * specialism IDs (`creative-template` + `creative-generative`) since + * buyer-side discovery still distinguishes them — the implementation + * surface unifies. + * + * The training agent's `/creative` tenant (CreativeAdServerPlatform) + * handles the stateful library/tags archetype; this tenant handles the + * stateless transform / brief-driven generation archetype. Two creative + * tenants for the v5 omni-creative codebase. + */ + +import { + AdcpError, + type DecisioningPlatform, + type CreativeBuilderPlatform, + type SyncCreativesRow, + type AccountStore, +} from '@adcp/sdk/server'; +import { + handleBuildCreative, + handlePreviewCreative, + handleSyncCreatives, +} from './task-handlers.js'; +import type { ToolArgs, TrainingContext } from './types.js'; + +interface TrainingCreativeBuilderMeta { + brand_domain?: string; + [key: string]: unknown; +} + +interface TrainingCreativeBuilderConfig { + strict: boolean; +} + +function buildTrainingCtx(account: { authInfo?: { principal?: string } } | undefined): TrainingContext { + return { + mode: 'open', + principal: account?.authInfo?.principal ?? 'anonymous', + }; +} + +function translateV5Result(result: unknown): T { + const errs = (result as { + errors?: Array<{ + code: string; + message: string; + field?: string; + details?: unknown; + recovery?: string; + }>; + } | undefined)?.errors; + if (Array.isArray(errs) && errs.length > 0) { + const first = errs[0]!; + const recovery = (first.recovery === 'transient' || first.recovery === 'correctable' || first.recovery === 'terminal') + ? first.recovery + : 'correctable'; + throw new AdcpError(first.code, { + recovery, + message: first.message, + ...(first.field !== undefined && { field: first.field }), + ...(first.details !== undefined && { details: first.details as Record }), + }); + } + return result as T; +} + +const trainingBuilderAccounts: AccountStore = { + resolution: 'explicit', + resolve: async (ref, _ctx) => { + if (ref == null) { + return { + id: 'public_sandbox', + name: 'Public Sandbox', + status: 'active', + ctx_metadata: {}, + authInfo: { kind: 'public' }, + }; + } + const brandDomain = + 'brand' in ref && ref.brand && typeof ref.brand === 'object' && 'domain' in ref.brand + ? (ref.brand.domain as string | undefined) + : undefined; + const accountId = + 'account_id' in ref && typeof ref.account_id === 'string' ? ref.account_id : undefined; + const id = accountId ?? `synthetic_${brandDomain ?? 'anon'}`; + return { + id, + name: brandDomain ?? id, + status: 'active', + ...(brandDomain != null && { brand: { domain: brandDomain } }), + ...('operator' in ref && typeof ref.operator === 'string' && { operator: ref.operator }), + ctx_metadata: { brand_domain: brandDomain }, + authInfo: { kind: 'api_key' }, + }; + }, +}; + +export class TrainingCreativeBuilderPlatform + implements DecisioningPlatform +{ + capabilities = { + specialisms: ['creative-template', 'creative-generative'] as const, + creative_agents: [], + channels: [] as const, + pricingModels: ['cpm', 'cpa'] as const, + supportedBillings: ['agent', 'operator'] as const, + compliance_testing: {}, + config: { strict: false }, + }; + + statusMappers = {}; + accounts: AccountStore = trainingBuilderAccounts; + + creative: CreativeBuilderPlatform = { + buildCreative: async (req, ctx) => { + const result = await handleBuildCreative(req as ToolArgs, buildTrainingCtx(ctx.account)); + // F16 (`bca20dfb`) — framework's discriminator detects the + // envelope shape: bare CreativeManifest wraps as + // { creative_manifest }; bare CreativeManifest[] wraps as + // { creative_manifests }; pre-shaped BuildCreativeSuccess / + // BuildCreativeMultiSuccess envelopes pass through unchanged. + // v5 returns the envelope shape directly, so passthrough. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return translateV5Result(result) as any; + }, + previewCreative: async (req, ctx) => { + const result = await handlePreviewCreative(req as ToolArgs, buildTrainingCtx(ctx.account)); + return translateV5Result(result); + }, + syncCreatives: async (creatives, ctx) => { + const result = await handleSyncCreatives({ creatives } as unknown as ToolArgs, buildTrainingCtx(ctx.account)); + const wrapped = translateV5Result<{ creatives?: unknown[] }>(result); + return (wrapped.creatives ?? []) as SyncCreativesRow[]; + }, + // refineCreative — v5 doesn't have a dedicated handler; the buildCreative + // handler accepts refinement payloads via the same code path. Skip for + // now; storyboards exercising refineCreative will hit UNSUPPORTED_FEATURE. + }; +} diff --git a/server/src/training-agent/v6-creative-platform.ts b/server/src/training-agent/v6-creative-platform.ts new file mode 100644 index 0000000000..212f7edc7a --- /dev/null +++ b/server/src/training-agent/v6-creative-platform.ts @@ -0,0 +1,143 @@ +/** + * v6 CreativeAdServerPlatform for the `/creative` tenant. + * + * Single-specialism platform claiming `creative-ad-server`. Implements + * 5 methods: buildCreative, previewCreative, listCreatives, + * getCreativeDelivery, syncCreatives. + * + * Spike-grade port: shim through to v5 handlers via `translateV5Result`. + */ + +import { + AdcpError, + type DecisioningPlatform, + type CreativeAdServerPlatform, + type SyncCreativesRow, + type AccountStore, +} from '@adcp/sdk/server'; +import { + handleBuildCreative, + handlePreviewCreative, + handleListCreatives, + handleGetCreativeDelivery, + handleSyncCreatives, +} from './task-handlers.js'; +import type { ToolArgs, TrainingContext } from './types.js'; + +interface TrainingCreativeMeta { + brand_domain?: string; + [key: string]: unknown; +} + +interface TrainingCreativeConfig { + strict: boolean; +} + +function buildTrainingCtx(account: { authInfo?: { principal?: string } } | undefined): TrainingContext { + return { + mode: 'open', + principal: account?.authInfo?.principal ?? 'anonymous', + }; +} + +function translateV5Result(result: unknown): T { + const errs = (result as { + errors?: Array<{ + code: string; + message: string; + field?: string; + details?: unknown; + recovery?: string; + }>; + } | undefined)?.errors; + if (Array.isArray(errs) && errs.length > 0) { + const first = errs[0]!; + const recovery = (first.recovery === 'transient' || first.recovery === 'correctable' || first.recovery === 'terminal') + ? first.recovery + : 'correctable'; + throw new AdcpError(first.code, { + recovery, + message: first.message, + ...(first.field !== undefined && { field: first.field }), + ...(first.details !== undefined && { details: first.details as Record }), + }); + } + return result as T; +} + +const trainingCreativeAccounts: AccountStore = { + resolution: 'explicit', + resolve: async (ref, _ctx) => { + if (ref == null) { + return { + id: 'public_sandbox', + name: 'Public Sandbox', + status: 'active', + ctx_metadata: {}, + authInfo: { kind: 'public' }, + }; + } + const brandDomain = + 'brand' in ref && ref.brand && typeof ref.brand === 'object' && 'domain' in ref.brand + ? (ref.brand.domain as string | undefined) + : undefined; + const accountId = + 'account_id' in ref && typeof ref.account_id === 'string' ? ref.account_id : undefined; + const id = accountId ?? `synthetic_${brandDomain ?? 'anon'}`; + return { + id, + name: brandDomain ?? id, + status: 'active', + ...(brandDomain != null && { brand: { domain: brandDomain } }), + ...('operator' in ref && typeof ref.operator === 'string' && { operator: ref.operator }), + ctx_metadata: { brand_domain: brandDomain }, + authInfo: { kind: 'api_key' }, + }; + }, +}; + +export class TrainingCreativePlatform + implements DecisioningPlatform +{ + capabilities = { + specialisms: ['creative-ad-server'] as const, + creative_agents: [], + channels: [] as const, + pricingModels: ['cpm', 'cpa'] as const, + supportedBillings: ['agent', 'operator'] as const, + compliance_testing: {}, + config: { strict: false }, + }; + + statusMappers = {}; + accounts: AccountStore = trainingCreativeAccounts; + + creative: CreativeAdServerPlatform = { + buildCreative: async (req, ctx) => { + const result = await handleBuildCreative(req as ToolArgs, buildTrainingCtx(ctx.account)); + // F16 (`bca20dfb`) — framework's discriminator passes through + // pre-shaped BuildCreativeSuccess / BuildCreativeMultiSuccess + // envelopes. v5 returns the envelope shape directly. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return translateV5Result(result) as any; + }, + previewCreative: async (req, ctx) => { + const result = await handlePreviewCreative(req as ToolArgs, buildTrainingCtx(ctx.account)); + return translateV5Result(result); + }, + listCreatives: async (req, ctx) => { + const result = await handleListCreatives(req as ToolArgs, buildTrainingCtx(ctx.account)); + return translateV5Result(result); + }, + getCreativeDelivery: async (filter, ctx) => { + const result = await handleGetCreativeDelivery(filter as ToolArgs, buildTrainingCtx(ctx.account)); + return translateV5Result(result); + }, + syncCreatives: async (creatives, ctx) => { + const result = await handleSyncCreatives({ creatives } as unknown as ToolArgs, buildTrainingCtx(ctx.account)); + // v5 returns wire-wrapped `{ creatives: [...] }`; v6 wants rows. + const wrapped = translateV5Result<{ creatives?: unknown[] }>(result); + return (wrapped.creatives ?? []) as SyncCreativesRow[]; + }, + }; +} diff --git a/server/src/training-agent/v6-governance-platform.ts b/server/src/training-agent/v6-governance-platform.ts new file mode 100644 index 0000000000..85302d22eb --- /dev/null +++ b/server/src/training-agent/v6-governance-platform.ts @@ -0,0 +1,239 @@ +/** + * v6 governance-tier platform for the `/governance` tenant. + * + * Bundles four specialisms that share governance/buyer-side + * responsibilities — campaign governance (spend-authority + + * delivery-monitor), property-lists, collection-lists, content-standards. + * Single tenant rather than four because the governance storyboards + * frequently span these surfaces (e.g., property-lists policy cited in + * a check_governance finding). + * + * Spike-grade port: bodies shim through to v5 handlers via + * `translateV5Result` — same approach as `/signals` and `/sales`. + */ + +import { + AdcpError, + type DecisioningPlatform, + type CampaignGovernancePlatform, + type PropertyListsPlatform, + type CollectionListsPlatform, + type ContentStandardsPlatform, + type AccountStore, +} from '@adcp/sdk/server'; +import { + handleSyncPlans, + handleCheckGovernance, + handleReportPlanOutcome, + handleGetPlanAuditLogs, +} from './governance-handlers.js'; +import { + handleCreatePropertyList, + handleListPropertyLists, + handleGetPropertyList, + handleUpdatePropertyList, + handleDeletePropertyList, +} from './property-handlers.js'; +import { + handleCreateCollectionList, + handleGetCollectionList, + handleUpdateCollectionList, + handleListCollectionLists, + handleDeleteCollectionList, +} from './inventory-governance-handlers.js'; +import { + handleCreateContentStandards, + handleListContentStandards, + handleGetContentStandards, + handleUpdateContentStandards, + handleCalibrateContent, + handleValidateContentDelivery, +} from './content-standards-handlers.js'; +import type { ToolArgs, TrainingContext } from './types.js'; + +interface TrainingGovernanceMeta { + brand_domain?: string; + [key: string]: unknown; +} + +interface TrainingGovernanceConfig { + strict: boolean; +} + +function buildTrainingCtx(account: { authInfo?: { principal?: string } } | undefined): TrainingContext { + return { + mode: 'open', + principal: account?.authInfo?.principal ?? 'anonymous', + }; +} + +function translateV5Result(result: unknown): T { + const errs = (result as { + errors?: Array<{ + code: string; + message: string; + field?: string; + details?: unknown; + recovery?: string; + }>; + } | undefined)?.errors; + if (Array.isArray(errs) && errs.length > 0) { + const first = errs[0]!; + const recovery = (first.recovery === 'transient' || first.recovery === 'correctable' || first.recovery === 'terminal') + ? first.recovery + : 'correctable'; + throw new AdcpError(first.code, { + recovery, + message: first.message, + ...(first.field !== undefined && { field: first.field }), + ...(first.details !== undefined && { details: first.details as Record }), + }); + } + return result as T; +} + +const trainingGovernanceAccounts: AccountStore = { + resolution: 'explicit', + resolve: async (ref, _ctx) => { + if (ref == null) { + return { + id: 'public_sandbox', + name: 'Public Sandbox', + status: 'active', + ctx_metadata: {}, + authInfo: { kind: 'public' }, + }; + } + const brandDomain = + 'brand' in ref && ref.brand && typeof ref.brand === 'object' && 'domain' in ref.brand + ? (ref.brand.domain as string | undefined) + : undefined; + const accountId = + 'account_id' in ref && typeof ref.account_id === 'string' ? ref.account_id : undefined; + const id = accountId ?? `synthetic_${brandDomain ?? 'anon'}`; + return { + id, + name: brandDomain ?? id, + status: 'active', + ...(brandDomain != null && { brand: { domain: brandDomain } }), + ...('operator' in ref && typeof ref.operator === 'string' && { operator: ref.operator }), + ctx_metadata: { brand_domain: brandDomain }, + authInfo: { kind: 'api_key' }, + }; + }, +}; + +export class TrainingGovernancePlatform + implements DecisioningPlatform +{ + capabilities = { + specialisms: [ + 'governance-spend-authority', + 'governance-delivery-monitor', + 'property-lists', + 'collection-lists', + 'content-standards', + ] as const, + creative_agents: [], + channels: [] as const, + pricingModels: ['cpm', 'cpa'] as const, + supportedBillings: ['agent', 'operator'] as const, + compliance_testing: {}, + config: { strict: false }, + }; + + statusMappers = {}; + accounts: AccountStore = trainingGovernanceAccounts; + + campaignGovernance: CampaignGovernancePlatform = { + syncPlans: async (req, ctx) => { + const result = await handleSyncPlans(req as ToolArgs, buildTrainingCtx(ctx.account)); + return translateV5Result(result); + }, + checkGovernance: async (req, ctx) => { + const result = await handleCheckGovernance(req as ToolArgs, buildTrainingCtx(ctx.account)); + return translateV5Result(result); + }, + reportPlanOutcome: async (req, ctx) => { + const result = await handleReportPlanOutcome(req as ToolArgs, buildTrainingCtx(ctx.account)); + return translateV5Result(result); + }, + getPlanAuditLogs: async (req, ctx) => { + const result = await handleGetPlanAuditLogs(req as ToolArgs, buildTrainingCtx(ctx.account)); + return translateV5Result(result); + }, + }; + + propertyLists: PropertyListsPlatform = { + createPropertyList: async (req, ctx) => { + const result = await handleCreatePropertyList(req as ToolArgs, buildTrainingCtx(ctx.account)); + return translateV5Result(result); + }, + updatePropertyList: async (req, ctx) => { + const result = await handleUpdatePropertyList(req as ToolArgs, buildTrainingCtx(ctx.account)); + return translateV5Result(result); + }, + getPropertyList: async (req, ctx) => { + const result = await handleGetPropertyList(req as ToolArgs, buildTrainingCtx(ctx.account)); + return translateV5Result(result); + }, + listPropertyLists: async (req, ctx) => { + const result = await handleListPropertyLists(req as ToolArgs, buildTrainingCtx(ctx.account)); + return translateV5Result(result); + }, + deletePropertyList: async (req, ctx) => { + const result = await handleDeletePropertyList(req as ToolArgs, buildTrainingCtx(ctx.account)); + return translateV5Result(result); + }, + }; + + collectionLists: CollectionListsPlatform = { + createCollectionList: async (req, ctx) => { + const result = await handleCreateCollectionList(req as ToolArgs, buildTrainingCtx(ctx.account)); + return translateV5Result(result); + }, + updateCollectionList: async (req, ctx) => { + const result = await handleUpdateCollectionList(req as ToolArgs, buildTrainingCtx(ctx.account)); + return translateV5Result(result); + }, + getCollectionList: async (req, ctx) => { + const result = await handleGetCollectionList(req as ToolArgs, buildTrainingCtx(ctx.account)); + return translateV5Result(result); + }, + listCollectionLists: async (req, ctx) => { + const result = await handleListCollectionLists(req as ToolArgs, buildTrainingCtx(ctx.account)); + return translateV5Result(result); + }, + deleteCollectionList: async (req, ctx) => { + const result = await handleDeleteCollectionList(req as ToolArgs, buildTrainingCtx(ctx.account)); + return translateV5Result(result); + }, + }; + + contentStandards: ContentStandardsPlatform = { + listContentStandards: async (req, ctx) => { + const result = await handleListContentStandards(req as ToolArgs, buildTrainingCtx(ctx.account)); + return translateV5Result(result); + }, + getContentStandards: async (req, ctx) => { + const result = await handleGetContentStandards(req as ToolArgs, buildTrainingCtx(ctx.account)); + return translateV5Result(result); + }, + createContentStandards: async (req, ctx) => { + const result = await handleCreateContentStandards(req as ToolArgs, buildTrainingCtx(ctx.account)); + return translateV5Result(result); + }, + updateContentStandards: async (req, ctx) => { + const result = await handleUpdateContentStandards(req as ToolArgs, buildTrainingCtx(ctx.account)); + return translateV5Result(result); + }, + calibrateContent: async (req, ctx) => { + const result = await handleCalibrateContent(req as ToolArgs, buildTrainingCtx(ctx.account)); + return translateV5Result(result); + }, + validateContentDelivery: async (req, ctx) => { + const result = await handleValidateContentDelivery(req as ToolArgs, buildTrainingCtx(ctx.account)); + return translateV5Result(result); + }, + }; +} diff --git a/server/src/training-agent/v6-platform.ts b/server/src/training-agent/v6-platform.ts new file mode 100644 index 0000000000..5326868207 --- /dev/null +++ b/server/src/training-agent/v6-platform.ts @@ -0,0 +1,167 @@ +/** + * v6 DecisioningPlatform skeleton for the training agent. + * + * Spike — feature-flagged behind TRAINING_AGENT_USE_V6. Coexists with + * `framework-server.ts` (v5 createAdcpServer path); not the production + * default until v6.0 GA. + * + * Spike strategy: shim v5 handlers into v6 platform method bodies. The + * shim translates the v5 `(args, TrainingContext) → success | { errors }` + * convention into the v6 `(req, ctx) → success | throw AdcpError` + * convention. This lets us validate framework wiring + storyboard parity + * without first porting handler bodies. Native v6 throws come later, one + * handler at a time. + */ + +import { + AdcpError, + type DecisioningPlatform, + type SignalsPlatform, + type AccountStore, +} from '@adcp/sdk/server'; +import { handleGetSignals, handleActivateSignal } from './task-handlers.js'; +import type { ToolArgs, TrainingContext } from './types.js'; + +export interface TrainingConfig { + /** Strict route advertises required_for: ['create_media_buy']. */ + strict: boolean; +} + +export interface TrainingMeta { + /** brand.domain when the wire request carried a brand reference. */ + brand_domain?: string; + [key: string]: unknown; +} + +/** + * Synthetic-account constructor for the public-sandbox posture. + * + * v5 doesn't wire `resolveAccount` — sessions key off `brand.domain` directly + * inside handlers. v6 requires `accounts.resolve()` on every request, so we + * synthesize an Account from the wire reference (or from auth for no-account + * tools like `provide_performance_feedback` / `list_creative_formats`). + */ +const trainingAccounts: AccountStore = { + resolution: 'explicit', + resolve: async (ref, _ctx) => { + if (ref == null) { + return { + id: 'public_sandbox', + name: 'Public Sandbox', + status: 'active', + ctx_metadata: {}, + authInfo: { kind: 'public' }, + }; + } + const brandDomain = + 'brand' in ref && ref.brand && typeof ref.brand === 'object' && 'domain' in ref.brand + ? (ref.brand.domain as string | undefined) + : undefined; + const accountId = + 'account_id' in ref && typeof ref.account_id === 'string' ? ref.account_id : undefined; + const id = accountId ?? `synthetic_${brandDomain ?? 'anon'}`; + return { + id, + name: brandDomain ?? id, + status: 'active', + ...(brandDomain != null && { brand: { domain: brandDomain } }), + ...('operator' in ref && typeof ref.operator === 'string' && { operator: ref.operator }), + ctx_metadata: { brand_domain: brandDomain }, + authInfo: { kind: 'api_key' }, + }; + }, +}; + +/** + * Translate a v5 handler return value into a v6-shaped response. + * + * v5 handlers return either the success body OR `{ errors: [{ code, message, ... }] }` + * on failure. v6 platform methods return the success body OR throw `AdcpError`. + * This translates the envelope-error path to a throw. + */ +function translateV5Result(result: unknown): T { + const errs = (result as { errors?: Array<{ code: string; message: string; field?: string; details?: unknown; recovery?: string }> } | undefined)?.errors; + if (Array.isArray(errs) && errs.length > 0) { + const first = errs[0]!; + const recovery = (first.recovery === 'transient' || first.recovery === 'correctable' || first.recovery === 'terminal') + ? first.recovery + : 'correctable'; + throw new AdcpError(first.code, { + recovery, + message: first.message, + ...(first.field !== undefined && { field: first.field }), + ...(first.details !== undefined && { details: first.details as Record }), + }); + } + return result as T; +} + +/** + * The v6 RequestContext exposes the resolved Account (with its `authInfo: + * AuthPrincipal` already shaped by `accounts.resolve`), not the raw + * transport-level auth. For the spike we just propagate the principal name + * the resolver stamped on. Production flows would go through + * `serve({ authenticate })` which feeds `ResolvedAuthInfo` to the resolver. + */ +function buildTrainingCtx(account: { authInfo?: { principal?: string } } | undefined): TrainingContext { + return { + mode: 'open', + principal: account?.authInfo?.principal ?? 'anonymous', + }; +} + +/** + * v6 TrainingPlatform. + * + * Specialism fields are populated incrementally — currently `signals` only. + * Other domains stay in the merge seam (`opts.mediaBuy / creative / governance + * / accounts / brandRights / customTools`) until they're ported. + */ +export class TrainingPlatform implements DecisioningPlatform { + // Claim only the specialism we've ported. RequiredPlatformsFor compile- + // checks that signal-marketplace + signal-owned ⇒ this.signals exists. + capabilities = { + specialisms: ['signal-marketplace', 'signal-owned'] as const, + creative_agents: [], + channels: [] as const, + pricingModels: ['cpm', 'cpa'] as const, + targeting: { + geo_countries: true, + geo_regions: true, + geo_metros: { nielsen_dma: true }, + geo_postal_areas: { us_zip: true }, + language: true, + keyword_targets: { supported_match_types: ['broad', 'phrase', 'exact'] as const }, + negative_keywords: { supported_match_types: ['broad', 'phrase', 'exact'] as const }, + }, + audience_targeting: { + supported_identifier_types: ['hashed_email' as const], + minimum_audience_size: 100, + }, + conversion_tracking: { + supported_event_types: ['purchase' as const, 'add_to_cart' as const, 'lead' as const, 'page_view' as const], + supported_hashed_identifiers: ['hashed_email' as const], + supported_action_sources: ['website' as const, 'app' as const], + }, + supportedBillings: ['agent', 'operator'] as const, + config: { strict: false }, + }; + + statusMappers = {}; + + accounts: AccountStore = trainingAccounts; + + signals: SignalsPlatform = { + getSignals: async (req, ctx) => { + const trainingCtx = buildTrainingCtx(ctx.account); + const result = await handleGetSignals(req as ToolArgs, trainingCtx); + return translateV5Result(result); + }, + + activateSignal: async (req, ctx) => { + const trainingCtx = buildTrainingCtx(ctx.account); + const result = await handleActivateSignal(req as ToolArgs, trainingCtx); + return translateV5Result(result); + }, + }; +} diff --git a/server/src/training-agent/v6-sales-platform.ts b/server/src/training-agent/v6-sales-platform.ts new file mode 100644 index 0000000000..94c304d339 --- /dev/null +++ b/server/src/training-agent/v6-sales-platform.ts @@ -0,0 +1,207 @@ +/** + * v6 SalesPlatform for the `/sales` tenant. + * + * Single-specialism platform claiming `sales-non-guaranteed` + + * `sales-guaranteed`. Implements `SalesPlatform` (5 required methods + + * 4 optional read-side methods). + * + * Spike-grade port: bodies shim through to existing v5 handlers via + * `translateV5Result`. Same approach as `/signals` — validates framework + * wiring against the storyboard suite first; native porting (handler + * bodies that throw `AdcpError` directly) is a follow-up. + */ + +import { + AdcpError, + type DecisioningPlatform, + type SalesPlatform, + type AccountStore, +} from '@adcp/sdk/server'; +import { + handleGetProducts, + handleCreateMediaBuy, + handleUpdateMediaBuy, + handleGetMediaBuys, + handleGetMediaBuyDelivery, + handleSyncCreatives, + handleListCreatives, + handleListCreativeFormats, +} from './task-handlers.js'; +import { handleProvidePerformanceFeedback } from './catalog-event-handlers.js'; +import type { ToolArgs, TrainingContext } from './types.js'; + +interface TrainingSalesMeta { + brand_domain?: string; + [key: string]: unknown; +} + +interface TrainingSalesConfig { + strict: boolean; +} + +/** Build a TrainingContext from a v6 RequestContext.Account.authInfo. */ +function buildTrainingCtx(account: { authInfo?: { principal?: string } } | undefined): TrainingContext { + return { + mode: 'open', + principal: account?.authInfo?.principal ?? 'anonymous', + }; +} + + +/** + * v5 → v6 envelope translator. v5 handlers return `{ errors: [...] }` for + * structured rejection; v6 platform methods throw `AdcpError`. + */ +function translateV5Result(result: unknown): T { + const errs = (result as { + errors?: Array<{ + code: string; + message: string; + field?: string; + details?: unknown; + recovery?: string; + }>; + } | undefined)?.errors; + if (Array.isArray(errs) && errs.length > 0) { + const first = errs[0]!; + const recovery = (first.recovery === 'transient' || first.recovery === 'correctable' || first.recovery === 'terminal') + ? first.recovery + : 'correctable'; + throw new AdcpError(first.code, { + recovery, + message: first.message, + ...(first.field !== undefined && { field: first.field }), + ...(first.details !== undefined && { details: first.details as Record }), + }); + } + return result as T; +} + +/** + * Synthetic-account constructor — same posture as the signals tenant. + * v6 mandates `accounts.resolve()` on every request; we synthesize an + * Account from the wire reference (or from auth for no-account tools + * like `provide_performance_feedback` and `list_creative_formats`). + */ +const trainingSalesAccounts: AccountStore = { + resolution: 'explicit', + resolve: async (ref, _ctx) => { + if (ref == null) { + return { + id: 'public_sandbox', + name: 'Public Sandbox', + status: 'active', + ctx_metadata: {}, + authInfo: { kind: 'public' }, + }; + } + const brandDomain = + 'brand' in ref && ref.brand && typeof ref.brand === 'object' && 'domain' in ref.brand + ? (ref.brand.domain as string | undefined) + : undefined; + const accountId = + 'account_id' in ref && typeof ref.account_id === 'string' ? ref.account_id : undefined; + const id = accountId ?? `synthetic_${brandDomain ?? 'anon'}`; + return { + id, + name: brandDomain ?? id, + status: 'active', + ...(brandDomain != null && { brand: { domain: brandDomain } }), + ...('operator' in ref && typeof ref.operator === 'string' && { operator: ref.operator }), + ctx_metadata: { brand_domain: brandDomain }, + authInfo: { kind: 'api_key' }, + }; + }, +}; + +export class TrainingSalesPlatform + implements DecisioningPlatform +{ + capabilities = { + specialisms: ['sales-non-guaranteed', 'sales-guaranteed'] as const, + creative_agents: [], + channels: [] as const, + pricingModels: ['cpm', 'cpa'] as const, + targeting: { + geo_countries: true, + geo_regions: true, + geo_metros: { nielsen_dma: true }, + geo_postal_areas: { us_zip: true }, + language: true, + keyword_targets: { supported_match_types: ['broad', 'phrase', 'exact'] as const }, + negative_keywords: { supported_match_types: ['broad', 'phrase', 'exact'] as const }, + }, + audience_targeting: { + supported_identifier_types: ['hashed_email' as const], + minimum_audience_size: 100, + }, + conversion_tracking: { + supported_event_types: ['purchase' as const, 'add_to_cart' as const, 'lead' as const, 'page_view' as const], + supported_hashed_identifiers: ['hashed_email' as const], + supported_action_sources: ['website' as const, 'app' as const], + }, + supportedBillings: ['agent', 'operator'] as const, + // Auto-derives `compliance_testing.scenarios[]` from the adapters + // wired in `serverOptions.complyTest`. Empty block opts in; the + // capability/adapter consistency check at construction throws if + // adapters aren't supplied alongside. + compliance_testing: {}, + config: { strict: false }, + }; + + statusMappers = {}; + accounts: AccountStore = trainingSalesAccounts; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sales: SalesPlatform = { + getProducts: async (req, ctx) => { + const result = await handleGetProducts(req as ToolArgs, buildTrainingCtx(ctx.account)); + return translateV5Result(result); + }, + + createMediaBuy: async (req, ctx) => { + const v5Result = await handleCreateMediaBuy(req as ToolArgs, buildTrainingCtx(ctx.account)); + return translateV5Result(v5Result); + }, + + updateMediaBuy: async (buyId, patch, ctx) => { + const args = { media_buy_id: buyId, ...(patch as unknown as Record) }; + const v5Result = await handleUpdateMediaBuy(args as ToolArgs, buildTrainingCtx(ctx.account)); + return translateV5Result(v5Result); + }, + + syncCreatives: async (creatives, ctx) => { + const v5Result = await handleSyncCreatives({ creatives } as unknown as ToolArgs, buildTrainingCtx(ctx.account)); + // v5 returns wire-wrapped `{ creatives: [...] }`; v6 SalesPlatform + // wants rows directly — framework re-wraps. + const wrapped = translateV5Result<{ creatives?: unknown[] }>(v5Result); + return (wrapped.creatives ?? []) as Awaited>>; + }, + + getMediaBuyDelivery: async (filter, ctx) => { + const result = await handleGetMediaBuyDelivery(filter as ToolArgs, buildTrainingCtx(ctx.account)); + return translateV5Result(result); + }, + + // Optional read-side methods. + getMediaBuys: async (req, ctx) => { + const result = await handleGetMediaBuys(req as ToolArgs, buildTrainingCtx(ctx.account)); + return translateV5Result(result); + }, + + listCreativeFormats: async (req, ctx) => { + const result = await handleListCreativeFormats(req as ToolArgs, buildTrainingCtx(ctx.account)); + return translateV5Result(result); + }, + + listCreatives: async (req, ctx) => { + const result = await handleListCreatives(req as ToolArgs, buildTrainingCtx(ctx.account)); + return translateV5Result(result); + }, + + providePerformanceFeedback: async (req, ctx) => { + const result = await handleProvidePerformanceFeedback(req as ToolArgs, buildTrainingCtx(ctx.account)); + return translateV5Result(result); + }, + }; +} diff --git a/server/tests/integration/training-agent-demo-key.test.ts b/server/tests/integration/training-agent-demo-key.test.ts index b4f8341147..5e461ee2a1 100644 --- a/server/tests/integration/training-agent-demo-key.test.ts +++ b/server/tests/integration/training-agent-demo-key.test.ts @@ -31,7 +31,7 @@ const { stopSessionCleanup } = await import('../../src/training-agent/state.js') function postList(app: express.Application, authHeader: string | undefined) { const req = request(app) - .post('/api/training-agent/mcp') + .post('/api/training-agent/sales/mcp') .set('Content-Type', 'application/json') .set('Accept', 'application/json, text/event-stream'); if (authHeader) req.set('Authorization', authHeader); diff --git a/server/tests/integration/training-agent-legacy-mcp.test.ts b/server/tests/integration/training-agent-legacy-mcp.test.ts new file mode 100644 index 0000000000..8d8506504f --- /dev/null +++ b/server/tests/integration/training-agent-legacy-mcp.test.ts @@ -0,0 +1,155 @@ +/** + * Legacy `/api/training-agent/mcp` route — back-compat alias to the v5 + * single-URL training agent. Mounted alongside the per-tenant routes so + * existing AAO entries, Sage/Addie configs, docs, and external storyboard + * runners keep working while references migrate to per-tenant URLs. + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import express from 'express'; +import request from 'supertest'; + +vi.hoisted(() => { + process.env.PUBLIC_TEST_AGENT_TOKEN = 'test-token-for-legacy-mcp'; +}); + +vi.mock('../../src/logger.js', () => ({ + createLogger: () => ({ + info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn(), + }), +})); + +const { createTrainingAgentRouter } = await import('../../src/training-agent/index.js'); +const { stopSessionCleanup } = await import('../../src/training-agent/state.js'); + +const AUTH = 'Bearer test-token-for-legacy-mcp'; + +describe('Training Agent legacy /mcp back-compat alias', () => { + let app: express.Application; + + beforeAll(() => { + app = express(); + app.use(express.json()); + app.use('/api/training-agent', createTrainingAgentRouter()); + }); + + afterAll(() => { + stopSessionCleanup(); + }); + + it('serves tools/list on /api/training-agent/mcp', async () => { + const res = await request(app) + .post('/api/training-agent/mcp') + .set('Authorization', AUTH) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json, text/event-stream') + .send({ jsonrpc: '2.0', id: 1, method: 'tools/list' }); + expect(res.status).toBe(200); + const tools = (res.body.result?.tools ?? []) as Array<{ name: string }>; + // v5 monolith advertises every tool on one URL — confirm a sampling + // from each specialism shows up. + const names = new Set(tools.map(t => t.name)); + expect(names.has('get_signals')).toBe(true); + expect(names.has('get_products')).toBe(true); + expect(names.has('list_creative_formats')).toBe(true); + }); + + it('emits Deprecation header to nudge callers toward per-tenant URLs', async () => { + const res = await request(app) + .post('/api/training-agent/mcp') + .set('Authorization', AUTH) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json, text/event-stream') + .send({ jsonrpc: '2.0', id: 1, method: 'tools/list' }); + expect(res.status).toBe(200); + expect(res.headers['deprecation']).toBe('true'); + expect(res.headers['link']).toContain('successor-version'); + }); + + it('rejects unauthenticated requests with 401 + WWW-Authenticate', async () => { + const res = await request(app) + .post('/api/training-agent/mcp') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json, text/event-stream') + .send({ jsonrpc: '2.0', id: 1, method: 'tools/list' }); + expect(res.status).toBe(401); + expect(res.headers['www-authenticate']).toMatch(/^Bearer /); + }); + + it('returns 405 on GET with Allow: POST, OPTIONS', async () => { + const res = await request(app).get('/api/training-agent/mcp'); + expect(res.status).toBe(405); + expect(res.headers['allow']).toBe('POST, OPTIONS'); + }); + + it('returns 204 on OPTIONS preflight with CORS headers', async () => { + const res = await request(app).options('/api/training-agent/mcp'); + expect(res.status).toBe(204); + expect(res.headers['access-control-allow-origin']).toBe('*'); + expect(res.headers['access-control-allow-methods']).toContain('POST'); + }); +}); + +/** + * Host-based dispatch — `test-agent.adcontextprotocol.org//mcp` + * production routing. The training-agent router is mounted both at + * `/api/training-agent` (legacy path) AND directly on the canonical + * hostname (host-based dispatch in `http.ts:1214`). Tenant resolution + * must work for both. + */ +describe('Tenant routes via host-based dispatch (no /api/training-agent prefix)', () => { + let app: express.Application; + + beforeAll(() => { + app = express(); + app.use(express.json()); + // Simulate `test-agent.adcontextprotocol.org/` routing — the + // router is mounted at root. + app.use('/', createTrainingAgentRouter()); + }); + + afterAll(() => { + stopSessionCleanup(); + }); + + it('routes /sales/mcp to the sales tenant', async () => { + const res = await request(app) + .post('/sales/mcp') + .set('Authorization', AUTH) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json, text/event-stream') + .send({ jsonrpc: '2.0', id: 1, method: 'tools/list' }); + expect(res.status).toBe(200); + const tools = (res.body.result?.tools ?? []) as Array<{ name: string }>; + const names = new Set(tools.map(t => t.name)); + // Sales tenant carries the media-buy tools. + expect(names.has('get_products')).toBe(true); + expect(names.has('create_media_buy')).toBe(true); + }); + + it('routes /signals/mcp to the signals tenant', async () => { + const res = await request(app) + .post('/signals/mcp') + .set('Authorization', AUTH) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json, text/event-stream') + .send({ jsonrpc: '2.0', id: 1, method: 'tools/list' }); + expect(res.status).toBe(200); + const tools = (res.body.result?.tools ?? []) as Array<{ name: string }>; + const names = new Set(tools.map(t => t.name)); + expect(names.has('get_signals')).toBe(true); + expect(names.has('activate_signal')).toBe(true); + }); + + it('routes /brand/mcp to the brand tenant', async () => { + const res = await request(app) + .post('/brand/mcp') + .set('Authorization', AUTH) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json, text/event-stream') + .send({ jsonrpc: '2.0', id: 1, method: 'tools/list' }); + expect(res.status).toBe(200); + const tools = (res.body.result?.tools ?? []) as Array<{ name: string }>; + const names = new Set(tools.map(t => t.name)); + expect(names.has('get_brand_identity')).toBe(true); + }); +}); diff --git a/server/tests/integration/training-agent-sse.test.ts b/server/tests/integration/training-agent-sse.test.ts deleted file mode 100644 index 8d191a4fbd..0000000000 --- a/server/tests/integration/training-agent-sse.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; -import express from 'express'; -import request from 'supertest'; -import http from 'http'; -import { EventSource } from 'eventsource'; - -// Set token before module loads (constantTimeEqual reads it at import time) -vi.hoisted(() => { - process.env.PUBLIC_TEST_AGENT_TOKEN = 'test-token-for-sse'; -}); - -// Suppress noisy logs during tests -vi.mock('../../src/logger.js', () => ({ - createLogger: () => ({ - info: vi.fn(), - debug: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }), -})); - -// Dynamic import after env is set -const { createTrainingAgentRouter } = await import('../../src/training-agent/index.js'); -const { stopSessionCleanup } = await import('../../src/training-agent/state.js'); - -const AUTH = 'Bearer test-token-for-sse'; - -describe('Training Agent SSE Transport', () => { - let app: express.Application; - - beforeAll(() => { - app = express(); - app.use(express.json()); - app.use('/api/training-agent', createTrainingAgentRouter()); - }); - - afterAll(() => { - stopSessionCleanup(); - }); - - // ── SSE error handling ──────────────────────────────────────────── - - it('GET /sse without auth returns 401', async () => { - await request(app) - .get('/api/training-agent/sse') - .expect(401); - }); - - it('POST /message without sessionId returns 400', async () => { - const res = await request(app) - .post('/api/training-agent/message') - .set('Authorization', AUTH) - .set('Content-Type', 'application/json') - .send({ jsonrpc: '2.0', id: 1, method: 'tools/list' }) - .expect(400); - - expect(res.body.error.message).toMatch(/sessionId/i); - }); - - it('POST /message with unknown sessionId returns 404', async () => { - const res = await request(app) - .post('/api/training-agent/message?sessionId=nonexistent') - .set('Authorization', AUTH) - .set('Content-Type', 'application/json') - .send({ jsonrpc: '2.0', id: 1, method: 'tools/list' }) - .expect(404); - - expect(res.body.error.message).toMatch(/not found/i); - }); - - it('OPTIONS /sse returns 204 with CORS headers', async () => { - const res = await request(app) - .options('/api/training-agent/sse') - .expect(204); - - expect(res.headers['access-control-allow-origin']).toBe('*'); - }); - - it('OPTIONS /message returns 204 with CORS headers', async () => { - const res = await request(app) - .options('/api/training-agent/message') - .expect(204); - - expect(res.headers['access-control-allow-origin']).toBe('*'); - }); - - // ── Full SSE round-trip ────────────────────────────────────────── - - describe('SSE round-trip', () => { - let server: http.Server; - let baseUrl: string; - - beforeAll(async () => { - server = http.createServer(app); - await new Promise((resolve) => server.listen(0, resolve)); - const port = (server.address() as any).port; - baseUrl = `http://localhost:${port}/api/training-agent`; - }); - - afterAll(async () => { - server.closeAllConnections?.(); - await new Promise((resolve) => server.close(() => resolve())); - }); - - it('establishes SSE connection and receives endpoint event', async () => { - const endpointUrl = await new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error('SSE connection timeout')), 5000); - const es = new EventSource(`${baseUrl}/sse`, { - fetch: (input, init) => fetch(input, { - ...init, - headers: { ...Object.fromEntries(new Headers(init?.headers).entries()), Authorization: AUTH }, - }), - }); - - es.addEventListener('endpoint', (event) => { - clearTimeout(timeout); - es.close(); - resolve(event.data); - }); - - es.addEventListener('error', (err) => { - clearTimeout(timeout); - es.close(); - reject(new Error(`SSE error: ${err.message}`)); - }); - }); - - expect(endpointUrl).toContain('/message'); - expect(endpointUrl).toContain('sessionId='); - }); - - it('accepts POST /message for an active SSE session', async () => { - // Establish SSE connection - let es: EventSource; - const endpointUrl = await new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error('SSE connection timeout')), 5000); - es = new EventSource(`${baseUrl}/sse`, { - fetch: (input, init) => fetch(input, { - ...init, - headers: { ...Object.fromEntries(new Headers(init?.headers).entries()), Authorization: AUTH }, - }), - }); - - es.addEventListener('endpoint', (event) => { - clearTimeout(timeout); - resolve(event.data); - }); - - es.addEventListener('error', (err) => { - clearTimeout(timeout); - es.close(); - reject(new Error(`SSE error: ${err.message}`)); - }); - }); - - try { - const messageUrl = endpointUrl.startsWith('http') - ? endpointUrl - : `http://localhost:${(server.address() as any).port}${endpointUrl}`; - - const res = await fetch(messageUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: AUTH, - }, - body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), - }); - - // SSE transport returns 202 Accepted for messages - expect(res.status).toBe(202); - } finally { - es!.close(); - } - }); - }); -}); diff --git a/server/tests/integration/training-agent-strict.test.ts b/server/tests/integration/training-agent-strict.test.ts deleted file mode 100644 index 1b8559c304..0000000000 --- a/server/tests/integration/training-agent-strict.test.ts +++ /dev/null @@ -1,272 +0,0 @@ -/** - * Integration tests for the `/mcp-strict` grader-targeted route. - * - * Covers the capability declaration difference vs. `/mcp` and the presence- - * gated enforcement of `required_for`. Signed-request verification is - * exercised end-to-end by the storyboard runner against the signing vectors; - * these tests focus on the route-level plumbing. - */ -import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; -import express from 'express'; -import request from 'supertest'; - -// Set token before module loads so the static-key authenticator picks it up. -vi.hoisted(() => { - process.env.PUBLIC_TEST_AGENT_TOKEN = 'test-token-for-strict'; -}); - -vi.mock('../../src/logger.js', () => ({ - createLogger: () => ({ - info: vi.fn(), - debug: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }), -})); - -const { createTrainingAgentRouter } = await import('../../src/training-agent/index.js'); -const { stopSessionCleanup } = await import('../../src/training-agent/state.js'); -const { resetRequestSigning } = await import('../../src/training-agent/request-signing.js'); - -const AUTH = 'Bearer test-token-for-strict'; - -/** Call a tool via MCP JSON-RPC and return the parsed inner response. */ -async function callTool( - app: express.Application, - route: '/mcp' | '/mcp-strict' | '/mcp-strict-required' | '/mcp-strict-forbidden', - tool: string, - args: Record, - opts: { auth?: boolean } = { auth: true }, -) { - const req = request(app) - .post(`/api/training-agent${route}`) - .set('Content-Type', 'application/json') - .set('Accept', 'application/json, text/event-stream'); - if (opts.auth !== false) req.set('Authorization', AUTH); - return req.send({ - jsonrpc: '2.0', - id: 1, - method: 'tools/call', - params: { name: tool, arguments: args }, - }); -} - -/** Parse a StreamableHTTP response. Depending on Accept negotiation the - * transport returns either SSE-framed (`event: message\ndata: {...}`) or - * plain JSON; handle both so the test harness isn't brittle to transport - * changes. */ -function parseEnvelope(res: request.Response): Record { - const text = res.text ?? ''; - const sseMatch = text.match(/^data: (.*)$/m); - const raw = sseMatch ? sseMatch[1] : text; - return JSON.parse(raw) as Record; -} - -/** Extract the tool's inner response. Prefers structuredContent (the - * authoritative body on success / error paths) and falls back to parsing - * content[0].text for legacy wire shapes. */ -function innerResponse(res: request.Response): Record { - const envelope = parseEnvelope(res) as { result?: { structuredContent?: Record; content?: Array<{ text?: string }> } }; - if (envelope.result?.structuredContent) return envelope.result.structuredContent; - const text = envelope.result?.content?.[0]?.text; - if (!text) throw new Error(`No structuredContent or content text in envelope: ${JSON.stringify(envelope)}`); - return JSON.parse(text) as Record; -} - -describe('Training Agent /mcp-strict route', () => { - let app: express.Application; - - beforeAll(() => { - app = express(); - // Mirror production http.ts: populate req.rawBody via the verify callback - // so requireTokenStrict's resolveOperation can identify the tool name and - // apply the required_for gate without falling back to req.body. - app.use(express.json({ - verify: (req, _res, buf) => { - (req as unknown as { rawBody?: string }).rawBody = buf.toString('utf8'); - }, - })); - app.use('/api/training-agent', createTrainingAgentRouter()); - }); - - afterAll(() => { - stopSessionCleanup(); - }); - - describe('capability declaration', () => { - it('/mcp returns required_for: [] (sandbox)', async () => { - const res = await callTool(app, '/mcp', 'get_adcp_capabilities', {}); - expect(res.status).toBe(200); - const inner = innerResponse(res) as { - request_signing: { supported: boolean; required_for: string[] }; - specialisms?: string[]; - }; - expect(inner.request_signing.supported).toBe(true); - expect(inner.request_signing.required_for).toEqual([]); - expect(inner.specialisms ?? []).not.toContain('signed-requests'); - }); - - it('/mcp-strict returns required_for: ["create_media_buy"] (grader target)', async () => { - const res = await callTool(app, '/mcp-strict', 'get_adcp_capabilities', {}); - expect(res.status).toBe(200); - const inner = innerResponse(res) as { - request_signing: { supported: boolean; required_for: string[] }; - specialisms?: string[]; - }; - expect(inner.request_signing.supported).toBe(true); - expect(inner.request_signing.required_for).toEqual(['create_media_buy']); - expect(inner.specialisms ?? []).not.toContain('signed-requests'); - }); - }); - - describe('presence-gated enforcement', () => { - // Regression gate for #3338: presence-gated signing on /mcp-strict must - // fire `request_signature_required` for unsigned mutations when the - // strict capability advertises `required_for: ['create_media_buy']`. - it('unsigned create_media_buy on /mcp-strict returns 401 request_signature_required', async () => { - // auth: false — bearer bypass (SDK #2586) short-circuits required_for; graders send no bearer. - const res = await callTool(app, '/mcp-strict', 'create_media_buy', { - account: { brand: { domain: 'strict-test.example.com' }, sandbox: true }, - idempotency_key: '550e8400-e29b-41d4-a716-446655440000', - }, { auth: false }); - expect(res.status).toBe(401); - expect(res.body.error).toBe('request_signature_required'); - expect(res.body.error_description).toMatch(/Signature required for create_media_buy/); - expect(res.headers['www-authenticate']).toMatch(/error="request_signature_required"/); - }); - - it('unsigned create_media_buy on /mcp still accepted (bearer fallthrough)', async () => { - // Not asserting success (depends on product catalog state); only that - // the auth layer doesn't reject it with request_signature_required. - const res = await callTool(app, '/mcp', 'create_media_buy', { - account: { brand: { domain: 'strict-test.example.com' }, sandbox: true }, - idempotency_key: '550e8400-e29b-41d4-a716-446655440001', - }); - expect(res.status).not.toBe(401); - }); - - // signed-requests vector 011 (negative/011-malformed-header): a syntactically - // invalid Signature-Input header MUST fail closed even when a valid bearer - // is present. Silent fallthrough to bearer would be the exact downgrade - // attack the RFC 9421 verifier-checklist pre-check exists to prevent. - it('malformed Signature-Input on /mcp rejects despite valid bearer (vector 011)', async () => { - const res = await request(app) - .post('/api/training-agent/mcp') - .set('Content-Type', 'application/json') - .set('Accept', 'application/json, text/event-stream') - .set('Authorization', AUTH) - .set('Signature-Input', 'this-is-not-a-valid-rfc-9421-signature-input') - .set('Signature', 'sig1=:AAAA:') - .send({ - jsonrpc: '2.0', - id: 1, - method: 'tools/call', - params: { - name: 'get_products', - arguments: { - account: { brand: { domain: 'strict-test.example.com' }, sandbox: true }, - brand: { domain: 'strict-test.example.com' }, - buying_mode: 'wholesale', - }, - }, - }); - expect(res.status).toBe(401); - }); - - it('unsigned get_products on /mcp-strict is allowed (not in required_for)', async () => { - const res = await callTool(app, '/mcp-strict', 'get_products', { - account: { brand: { domain: 'strict-test.example.com' }, sandbox: true }, - brand: { domain: 'strict-test.example.com' }, - buying_mode: 'wholesale', - }); - expect(res.status).toBe(200); - }); - }); - - describe('route contract', () => { - it('GET /mcp-strict returns 405', async () => { - const res = await request(app).get('/api/training-agent/mcp-strict'); - expect(res.status).toBe(405); - expect(res.headers['allow']).toMatch(/POST/); - }); - - it('POST /mcp-strict without auth returns 401', async () => { - const res = await callTool(app, '/mcp-strict', 'get_adcp_capabilities', {}, { auth: false }); - expect(res.status).toBe(401); - }); - }); -}); - -describe('Training Agent /mcp-strict-required and /mcp-strict-forbidden routes', () => { - let app: express.Application; - - beforeAll(() => { - resetRequestSigning(); - app = express(); - app.use(express.json({ - verify: (req, _res, buf) => { - (req as unknown as { rawBody?: string }).rawBody = buf.toString('utf8'); - }, - })); - app.use('/api/training-agent', createTrainingAgentRouter()); - }); - - afterAll(() => { - stopSessionCleanup(); - }); - - describe('capability declaration — covers_content_digest mode', () => { - it('/mcp-strict-required advertises covers_content_digest: required', async () => { - const res = await callTool(app, '/mcp-strict-required', 'get_adcp_capabilities', {}); - expect(res.status).toBe(200); - const inner = innerResponse(res) as { - request_signing: { supported: boolean; covers_content_digest: string; required_for: string[] }; - }; - expect(inner.request_signing.covers_content_digest).toBe('required'); - expect(inner.request_signing.required_for).toEqual(['create_media_buy']); - }); - - it('/mcp-strict-forbidden advertises covers_content_digest: forbidden', async () => { - const res = await callTool(app, '/mcp-strict-forbidden', 'get_adcp_capabilities', {}); - expect(res.status).toBe(200); - const inner = innerResponse(res) as { - request_signing: { supported: boolean; covers_content_digest: string; required_for: string[] }; - }; - expect(inner.request_signing.covers_content_digest).toBe('forbidden'); - expect(inner.request_signing.required_for).toEqual(['create_media_buy']); - }); - - it('/mcp-strict still advertises covers_content_digest: either (unchanged)', async () => { - const res = await callTool(app, '/mcp-strict', 'get_adcp_capabilities', {}); - expect(res.status).toBe(200); - const inner = innerResponse(res) as { - request_signing: { covers_content_digest: string }; - }; - expect(inner.request_signing.covers_content_digest).toBe('either'); - }); - }); - - describe('route contract', () => { - it('GET /mcp-strict-required returns 405', async () => { - const res = await request(app).get('/api/training-agent/mcp-strict-required'); - expect(res.status).toBe(405); - expect(res.headers['allow']).toMatch(/POST/); - }); - - it('GET /mcp-strict-forbidden returns 405', async () => { - const res = await request(app).get('/api/training-agent/mcp-strict-forbidden'); - expect(res.status).toBe(405); - expect(res.headers['allow']).toMatch(/POST/); - }); - - it('POST /mcp-strict-required without auth returns 401', async () => { - const res = await callTool(app, '/mcp-strict-required', 'get_adcp_capabilities', {}, { auth: false }); - expect(res.status).toBe(401); - }); - - it('POST /mcp-strict-forbidden without auth returns 401', async () => { - const res = await callTool(app, '/mcp-strict-forbidden', 'get_adcp_capabilities', {}, { auth: false }); - expect(res.status).toBe(401); - }); - }); -}); diff --git a/server/tests/integration/training-agent-webhooks.test.ts b/server/tests/integration/training-agent-webhooks.test.ts index a3da0450a8..41f52383d4 100644 --- a/server/tests/integration/training-agent-webhooks.test.ts +++ b/server/tests/integration/training-agent-webhooks.test.ts @@ -84,7 +84,7 @@ describe('Training Agent webhook emission', () => { const catalog = buildCatalog(); const product = catalog[0].product as { product_id: string; pricing_options: Array<{ pricing_option_id: string }> }; return request(app) - .post('/api/training-agent/mcp') + .post('/api/training-agent/sales/mcp') .set('Authorization', AUTH) .set('Content-Type', 'application/json') .set('Accept', 'application/json, text/event-stream') @@ -167,7 +167,7 @@ describe('Training Agent webhook emission', () => { it('does not emit when push_notification_config is absent', async () => { // Nothing to receive — just verify the MCP call succeeds without webhook plumbing. const response = await request(app) - .post('/api/training-agent/mcp') + .post('/api/training-agent/sales/mcp') .set('Authorization', AUTH) .set('Content-Type', 'application/json') .set('Accept', 'application/json, text/event-stream') diff --git a/server/tests/manual/run-storyboards.ts b/server/tests/manual/run-storyboards.ts index 0e38194a41..f56a09c51c 100644 --- a/server/tests/manual/run-storyboards.ts +++ b/server/tests/manual/run-storyboards.ts @@ -35,6 +35,9 @@ import type { AdcpJsonWebKey } from '@adcp/sdk/signing'; // below. const AUTH_TOKEN = process.env.PUBLIC_TEST_AGENT_TOKEN ?? 'storyboard-runner-test-token'; process.env.PUBLIC_TEST_AGENT_TOKEN = AUTH_TOKEN; +// SDK refuses the in-memory task registry outside dev/test. The runner is a +// local dev convenience; opt in explicitly so the SDK accepts the default. +if (!process.env.NODE_ENV) process.env.NODE_ENV = 'test'; // Silence pino logger noise so the progress table stays readable. Set // LOG_STORYBOARDS=1 to get full log output for diagnosis. if (!process.env.LOG_STORYBOARDS) process.env.LOG_LEVEL = 'silent'; @@ -88,8 +91,17 @@ async function startLocalAgent(): Promise<{ url: string; close: () => Promise/mcp). Required — there's no + // single-URL fallback after the v5 monolith was retired. + // Common values: signals, sales, governance, creative, + // creative-builder, brand. + const tenantPath = process.env.TENANT_PATH; + if (!tenantPath) { + throw new Error('TENANT_PATH env required (one of: signals, sales, governance, creative, creative-builder, brand)'); + } resolve({ - url: `http://127.0.0.1:${addr.port}/api/training-agent/mcp`, + url: `http://127.0.0.1:${addr.port}/api/training-agent/${tenantPath}/mcp`, close: () => new Promise(res => { stopSessionCleanup(); srv.close(() => res()); @@ -213,7 +225,11 @@ function applyStepSkipList(storyboardId: string, result: StoryboardResult): void } } -function stepStatus(s: { passed?: boolean; skipped?: boolean; not_applicable?: boolean; validations?: Array<{ passed: boolean }>; error?: string }): 'passed' | 'failed' | 'skipped' | 'not_applicable' { +function stepStatus(s: { passed?: boolean; skipped?: boolean; not_applicable?: boolean; skip_reason?: string; skip?: { detail?: string }; validations?: Array<{ passed: boolean }>; error?: string }): 'passed' | 'failed' | 'skipped' | 'not_applicable' { + if (verbose && s.skipped) { + // eslint-disable-next-line no-console + console.log(` [skip] ${(s as { id?: string }).id ?? '?'} — ${s.skip_reason ?? '(no reason)'} :: ${s.skip?.detail ?? '(no detail)'}`); + } if (s.not_applicable) return 'not_applicable'; if (s.skipped) return 'skipped'; if (s.passed === false || s.error) return 'failed'; diff --git a/server/tests/unit/storyboard-fix-plan-e2e.test.ts b/server/tests/unit/storyboard-fix-plan-e2e.test.ts index b3c5e7d8ca..1c7f361849 100644 --- a/server/tests/unit/storyboard-fix-plan-e2e.test.ts +++ b/server/tests/unit/storyboard-fix-plan-e2e.test.ts @@ -15,7 +15,7 @@ */ import { describe, it, expect } from 'vitest'; import { runAgainstLocalAgent } from '@adcp/sdk/testing'; -import { createAdcpServer } from '@adcp/sdk/server'; +import { createAdcpServer } from '@adcp/sdk/server/legacy/v5'; import type { Storyboard } from '@adcp/sdk/testing'; import { renderAllHintFixPlans } from '../../src/addie/services/storyboard-fix-plan.js'; diff --git a/server/tests/unit/training-agent-framework-comply.test.ts b/server/tests/unit/training-agent-framework-comply.test.ts deleted file mode 100644 index f87626d639..0000000000 --- a/server/tests/unit/training-agent-framework-comply.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import crypto from 'node:crypto'; -import { createFrameworkTrainingAgentServer } from '../../src/training-agent/framework-server.js'; -import { clearSessions, getSession } from '../../src/training-agent/state.js'; -import { clearIdempotencyCache } from '../../src/training-agent/idempotency.js'; -import type { TrainingContext } from '../../src/training-agent/types.js'; - -type AnyServer = ReturnType; - -const ACCOUNT = { brand: { domain: 'comply-fw.example.com' }, operator: 'tester', sandbox: true }; -const BRAND = { domain: 'comply-fw.example.com' }; - -async function callTool(server: AnyServer, name: string, args: Record): Promise> { - const res = await server.dispatchTestRequest({ - method: 'tools/call', - params: { name, arguments: args }, - }); - const text = res.content?.[0]?.text; - const parsed = typeof text === 'string' ? JSON.parse(text) : {}; - return parsed.adcp_error ?? parsed; -} - -async function syncCreative(server: AnyServer): Promise { - const creativeId = `cr-fw-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; - const result = await callTool(server, 'sync_creatives', { - idempotency_key: crypto.randomUUID(), - account: ACCOUNT, - brand: BRAND, - creatives: [{ - creative_id: creativeId, - name: 'FW Test Creative', - format_id: { agent_url: 'https://example.com', id: 'display_300x250' }, - assets: { - image: { - asset_type: 'image', - url: 'https://via.placeholder.com/300x250', - width: 300, - height: 250, - mime_type: 'image/png', - }, - }, - }], - }); - if ((result as { errors?: unknown[] }).errors) { - throw new Error(`sync_creatives failed: ${JSON.stringify(result)}`); - } - return creativeId; -} - -describe('framework-server comply_test_controller', () => { - let server: AnyServer; - - beforeEach(async () => { - await clearSessions(); - clearIdempotencyCache(); - const ctx: TrainingContext = { mode: 'open', principal: 'anonymous' }; - server = createFrameworkTrainingAgentServer(ctx); - }); - - it('returns UNKNOWN_SCENARIO with context echoed on unrecognized scenario', async () => { - const correlationId = 'fw-unknown-scenario-test'; - const result = await callTool(server, 'comply_test_controller', { - scenario: 'nonexistent_scenario', - params: {}, - account: ACCOUNT, - brand: BRAND, - context: { correlation_id: correlationId }, - }); - expect(result.success).toBe(false); - expect(result.error).toBe('UNKNOWN_SCENARIO'); - expect((result.context as { correlation_id?: string })?.correlation_id).toBe(correlationId); - }); - - it('returns INVALID_TRANSITION with context echoed when forcing a terminal creative back', async () => { - const creativeId = await syncCreative(server); - // approved -> archived (valid) - const archived = await callTool(server, 'comply_test_controller', { - scenario: 'force_creative_status', - params: { creative_id: creativeId, status: 'archived' }, - account: ACCOUNT, - brand: BRAND, - }); - expect(archived.success).toBe(true); - - // Lock in the mechanism: archived state must be persisted to the session - // store between requests, otherwise the next probe hits NOT_FOUND. If a - // refactor ever changes session scoping so forceCreativeStatus reads from - // a different store than the creative was synced into, this assertion - // fires before the error-code check below. - const session = await getSession('open:comply-fw.example.com'); - expect(session.creatives.get(creativeId)?.status).toBe('archived'); - - // archived -> processing (invalid; archived only allows -> approved) - const correlationId = 'fw-invalid-transition-test'; - const invalid = await callTool(server, 'comply_test_controller', { - scenario: 'force_creative_status', - params: { creative_id: creativeId, status: 'processing' }, - account: ACCOUNT, - brand: BRAND, - context: { correlation_id: correlationId }, - }); - expect(invalid.success).toBe(false); - expect(invalid.error).toBe('INVALID_TRANSITION'); - expect((invalid.context as { correlation_id?: string })?.correlation_id).toBe(correlationId); - }); - - it('seeded products flow into get_products response on sandbox (testController bridge)', async () => { - // seed_product writes to session.complyExtensions.seededProducts; the - // createAdcpServer testController.getSeededProducts callback loads that - // store per-request and runs fixtures through mergeSeedProduct before - // the framework dispatcher merges them into the get_products response. - const seed = await callTool(server, 'comply_test_controller', { - scenario: 'seed_product', - account: ACCOUNT, - brand: BRAND, - params: { - product_id: 'seeded_fw_product', - fixture: { - name: 'Seeded FW Product', - description: 'Sandbox fixture product for bridge testing', - channels: ['display'], - publisher_properties: [{ - publisher_domain: 'comply-fw.example.com', - selection_type: 'all', - }], - format_ids: [{ agent_url: 'https://example.com', id: 'display_300x250' }], - delivery_type: 'non_guaranteed', - pricing_options: [{ - pricing_option_id: 'po_default', - pricing_model: 'cpm', - currency: 'USD', - fixed_price: 10, - }], - reporting_capabilities: { - available_metrics: ['impressions'], - available_reporting_frequencies: ['daily'], - expected_delay_minutes: 60, - timezone: 'UTC', - supports_webhooks: false, - date_range_support: 'date_range', - }, - }, - }, - }); - expect(seed.success).toBe(true); - - const res = await server.dispatchTestRequest({ - method: 'tools/call', - params: { name: 'get_products', arguments: { buying_mode: 'wholesale', account: ACCOUNT, brand: BRAND } }, - }); - // After the framework's bridge re-wraps the response, structuredContent is - // authoritative; content[0].text becomes a human summary ("Found N products") - // rather than the JSON payload, so tests must read structuredContent here. - const products = (res.structuredContent ?? {}) as { products?: Array<{ product_id?: string }>; sandbox?: boolean }; - const list = products.products ?? []; - expect(list.some(p => p.product_id === 'seeded_fw_product')).toBe(true); - expect(products.sandbox).toBe(true); - }); - -}); diff --git a/server/tests/unit/training-agent.test.ts b/server/tests/unit/training-agent.test.ts index d86804e0b6..9f27f8e9ed 100644 --- a/server/tests/unit/training-agent.test.ts +++ b/server/tests/unit/training-agent.test.ts @@ -2372,7 +2372,7 @@ describe('build_creative pricing', () => { // To test without pricing, we'd need a different session setup }); - it('returns NOT_FOUND for unknown creative_id', async () => { + it('returns CREATIVE_NOT_FOUND for unknown creative_id', async () => { const server = createTrainingAgentServer(DEFAULT_CTX); const { result, isError } = await simulateCallTool(server, 'build_creative', { account, @@ -2381,7 +2381,7 @@ describe('build_creative pricing', () => { // MCP layer wraps errors via adcpError, simulateCallTool unwraps adcp_error expect(isError).toBe(true); - expect(result.code).toBe('NOT_FOUND'); + expect(result.code).toBe('CREATIVE_NOT_FOUND'); }); it('video formats get higher CPM', async () => { @@ -2491,7 +2491,7 @@ describe('report_usage handler', () => { // All records rejected → MCP wraps as error expect(isError).toBe(true); - expect(result.code).toBe('NOT_FOUND'); + expect(result.code).toBe('CREATIVE_NOT_FOUND'); }); it('returns INVALID_PRICING_OPTION when pricing_option_id mismatches', async () => { @@ -2577,10 +2577,10 @@ describe('report_usage handler', () => { expect(result.accepted).toBe(1); const rejected = result.rejected as Array>; expect(rejected).toHaveLength(1); - expect(rejected[0].code).toBe('NOT_FOUND'); + expect(rejected[0].code).toBe('CREATIVE_NOT_FOUND'); }); - it('returns NOT_FOUND for unknown signal_agent_segment_id', async () => { + it('returns SIGNAL_NOT_FOUND for unknown signal_agent_segment_id', async () => { const server = createTrainingAgentServer(DEFAULT_CTX); const { result, isError } = await simulateCallTool(server, 'report_usage', { account, @@ -2596,7 +2596,7 @@ describe('report_usage handler', () => { }); expect(isError).toBe(true); - expect(result.code).toBe('NOT_FOUND'); + expect(result.code).toBe('SIGNAL_NOT_FOUND'); expect(result.message).toContain('nonexistent_segment'); }); @@ -4970,7 +4970,7 @@ describe('sync_plans input validation', () => { }); expect(isError).toBe(true); - expect(result.code).toBe('validation_error'); + expect(result.code).toBe('VALIDATION_ERROR'); }); it('returns validation error when plan is missing budget', async () => { @@ -4985,7 +4985,7 @@ describe('sync_plans input validation', () => { }); expect(isError).toBe(true); - expect(result.code).toBe('validation_error'); + expect(result.code).toBe('VALIDATION_ERROR'); }); it('returns validation error when flight is empty object', async () => { @@ -5001,7 +5001,7 @@ describe('sync_plans input validation', () => { }); expect(isError).toBe(true); - expect(result.code).toBe('validation_error'); + expect(result.code).toBe('VALIDATION_ERROR'); expect(result.message).toContain('flight requires start and end'); }); @@ -5018,7 +5018,7 @@ describe('sync_plans input validation', () => { }); expect(isError).toBe(true); - expect(result.code).toBe('validation_error'); + expect(result.code).toBe('VALIDATION_ERROR'); expect(result.message).toContain('budget requires total (number) and currency (string)'); }); @@ -7320,7 +7320,7 @@ describe('get_brand_identity handler', () => { brand_id: 'does_not_exist', }); - expect(result.code).toBe('brand_not_found'); + expect(result.code).toBe('BRAND_NOT_FOUND'); }); }); @@ -7486,7 +7486,7 @@ describe('human_review_required auto-flip and enforcement', () => { plans: [{ ...PLAN_BASE, policy_categories: ['fair_housing'], human_review_required: false }], }); expect(isError).toBe(true); - expect(result.code).toBe('validation_error'); + expect(result.code).toBe('VALIDATION_ERROR'); expect(result.message).toContain('fair_housing'); }); @@ -7636,7 +7636,7 @@ describe('human_review_required auto-flip and enforcement', () => { }], }); expect(isError).toBe(true); - expect(result.code).toBe('validation_error'); + expect(result.code).toBe('VALIDATION_ERROR'); expect(result.message).toContain('human_override'); }); diff --git a/tests/addie/brand-sandbox-tools.test.ts b/tests/addie/brand-sandbox-tools.test.ts index a2518c7a93..ef9472f3e1 100644 --- a/tests/addie/brand-sandbox-tools.test.ts +++ b/tests/addie/brand-sandbox-tools.test.ts @@ -74,7 +74,7 @@ describe('brand protocol tools (training agent)', () => { it('returns error for unknown brand', async () => { const result = await call('get_brand_identity', { brand_id: 'nonexistent' }); expect(result.errors).toBeDefined(); - expect((result.errors as Array<{ code: string }>)[0].code).toBe('brand_not_found'); + expect((result.errors as Array<{ code: string }>)[0].code).toBe('BRAND_NOT_FOUND'); }); it('omits available_fields when talent lacks the requested authorized field', async () => { @@ -340,7 +340,7 @@ describe('brand protocol tools (training agent)', () => { buyer: baseBuyer, campaign: { description: 'test', uses: ['likeness'] }, }); - expect((result.errors as Array<{ code: string }>)[0].code).toBe('rights_not_found'); + expect((result.errors as Array<{ code: string }>)[0].code).toBe('REFERENCE_NOT_FOUND'); }); it('returns error for invalid pricing option', async () => { @@ -350,7 +350,7 @@ describe('brand protocol tools (training agent)', () => { buyer: baseBuyer, campaign: { description: 'test', uses: ['likeness'] }, }); - expect((result.errors as Array<{ code: string }>)[0].code).toBe('invalid_pricing_option'); + expect((result.errors as Array<{ code: string }>)[0].code).toBe('INVALID_REQUEST'); }); it('returns error when buyer is missing', async () => { @@ -359,7 +359,7 @@ describe('brand protocol tools (training agent)', () => { pricing_option_id: 'cpm_endorsement', campaign: { description: 'test', uses: ['likeness'] }, }); - expect((result.errors as Array<{ code: string }>)[0].code).toBe('invalid_request'); + expect((result.errors as Array<{ code: string }>)[0].code).toBe('INVALID_REQUEST'); }); it('rejects cosmetics for Yuki Tanaka', async () => { @@ -440,7 +440,7 @@ describe('brand protocol tools (training agent)', () => { rights_id: 'nonexistent', }); expect(result.errors).toBeDefined(); - expect((result.errors as Array<{ code: string }>)[0].code).toBe('rights_not_found'); + expect((result.errors as Array<{ code: string }>)[0].code).toBe('REFERENCE_NOT_FOUND'); expect((result.errors as Array<{ message: string }>)[0].message).toContain('nonexistent'); }); @@ -450,7 +450,7 @@ describe('brand protocol tools (training agent)', () => { impression_cap: 10000, }); expect(result.errors).toBeDefined(); - expect((result.errors as Array<{ code: string }>)[0].code).toBe('invalid_update'); + expect((result.errors as Array<{ code: string }>)[0].code).toBe('INVALID_REQUEST'); expect((result.errors as Array<{ message: string }>)[0].message).toContain('10000'); expect((result.errors as Array<{ message: string }>)[0].message).toContain('50000'); }); @@ -461,7 +461,7 @@ describe('brand protocol tools (training agent)', () => { end_date: '2025-01-01', }); expect(result.errors).toBeDefined(); - expect((result.errors as Array<{ code: string }>)[0].code).toBe('invalid_update'); + expect((result.errors as Array<{ code: string }>)[0].code).toBe('INVALID_REQUEST'); expect((result.errors as Array<{ message: string }>)[0].message).toContain('end_date'); }); }); From f1a3e096dc3c6f07c3f57bbc4862b3b2eaa92bef Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 30 Apr 2026 21:10:57 -0400 Subject: [PATCH 2/5] fix(training-agent): expert review punch list on multi-tenant migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address findings from code-reviewer, security-reviewer, ad-tech-protocol-expert, adtech-product-expert, docs-expert, and dx-expert review of 7974aeccd8. **Schema-conformant adagents.json** - /signals/mcp authorization_type changed inline_properties → signal_tags (schema discriminator for signals agents). - _training_agent_tenants discovery extension lists all six per-specialism tenants with URLs and specialisms. Surfaces governance / creative / creative-builder / brand tenants that don't fit authorized_agents' discriminated union (they're not inventory or data sellers). **Security hardening** - noopJwksValidator throws at boot under NODE_ENV=production unless ALLOW_NOOP_JWKS_VALIDATOR=1 is set; prevents accidental import into a production tenant registry that should be enforcing JWKS validation. - Per-tenant signing kid now uses randomBytes(4).toString('hex') instead of Math.random(). Cosmetic — kid is non-secret — but cleaner. - comply.ts hardcoded principal documented as an SDK gap (ComplyControllerContext doesn't expose authInfo). registry.ts header comment refreshed to be honest about cross-tenant shared session 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 for callers that need a different tenant. - Addie's member-tools.ts INTERNAL_PATH_AGENT_URL redirect targets the legacy back-compat alias (preserves single-URL multi-tool semantics) rather than 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. - Static brand storyboard's allowed_values augmented with REFERENCE_NOT_FOUND (additive — strict superset of the SDK's bundled fixture). **Deferred to upstream (filed as SDK feedback)** - Wrong-tenant DX hint (Tool 'X' lives on /sales/mcp): first attempt regressed creative-builder storyboards because the runner's missing-tool detection doesn't classify result.isError, JSON-RPC error, or adcp_error-wrapped responses as a graceful skip. Needs SDK adjustment first. - BRAND_NOT_FOUND vs REFERENCE_NOT_FOUND: SDK-bundled brand storyboard explicitly enumerates BRAND_NOT_FOUND as canonical, contradicting universal error-handling.mdx which puts brands in REFERENCE_NOT_FOUND fallback list. Kept BRAND_NOT_FOUND for storyboard conformance; filed feedback. 373 tests passing (+2 from the round 13 baseline of 371: adds adagents.json discovery test + brand-sandbox-tools.test.ts already updated at PR #1 commit time). Storyboard regression unchanged across all six tenants (59/55/57/58/55/59 clean — identical to round 13). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../training-agent-sdk-6-review-fixes.md | 25 +++++++++ server/src/addie/mcp/member-tools.ts | 16 ++++-- server/src/config/test-agent.ts | 31 ++++++++++- server/src/training-agent/brand-handlers.ts | 6 ++ server/src/training-agent/index.ts | 45 +++++++++++++-- server/src/training-agent/tenants/comply.ts | 9 +++ server/src/training-agent/tenants/registry.ts | 55 ++++++++++++++----- server/src/training-agent/tenants/signing.ts | 4 +- .../tenants/tenant-smoke.test.ts | 9 +-- .../training-agent-legacy-mcp.test.ts | 18 ++++++ .../source/protocols/brand/index.yaml | 3 +- 11 files changed, 185 insertions(+), 36 deletions(-) create mode 100644 .changeset/training-agent-sdk-6-review-fixes.md diff --git a/.changeset/training-agent-sdk-6-review-fixes.md b/.changeset/training-agent-sdk-6-review-fixes.md new file mode 100644 index 0000000000..f6e50612b9 --- /dev/null +++ b/.changeset/training-agent-sdk-6-review-fixes.md @@ -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. diff --git a/server/src/addie/mcp/member-tools.ts b/server/src/addie/mcp/member-tools.ts index 57d6cbb00c..2a730b0c9e 100644 --- a/server/src/addie/mcp/member-tools.ts +++ b/server/src/addie/mcp/member-tools.ts @@ -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'; @@ -221,15 +221,21 @@ async function resolveAgentAuth( ): Promise { let resolvedUrl = agentUrl; - // Redirect internal path URL to canonical hostname + // 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 (resolvedUrl.toLowerCase() === INTERNAL_PATH_AGENT_URL.toLowerCase()) { - resolvedUrl = PUBLIC_TEST_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 lowerUrl = resolvedUrl.toLowerCase(); + const publicTestAgentUrls: string[] = Object.values(PUBLIC_TEST_AGENT_URLS).map(u => u.toLowerCase()); + if (publicTestAgentUrls.includes(lowerUrl)) { return { authToken: PUBLIC_TEST_AGENT.token, authType: 'bearer', source: 'public', resolvedUrl }; } diff --git a/server/src/config/test-agent.ts b/server/src/config/test-agent.ts index 1dc44be3e6..31eccc097b 100644 --- a/server/src/config/test-agent.ts +++ b/server/src/config/test-agent.ts @@ -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'; diff --git a/server/src/training-agent/brand-handlers.ts b/server/src/training-agent/brand-handlers.ts index a5788169e2..75d1069f71 100644 --- a/server/src/training-agent/brand-handlers.ts +++ b/server/src/training-agent/brand-handlers.ts @@ -670,6 +670,12 @@ export function handleGetBrandIdentity( const talent = BRAND_MAP.get(brandId); if (!talent) { + // 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}'` }] }; } diff --git a/server/src/training-agent/index.ts b/server/src/training-agent/index.ts index ca10a9d0d0..ab765d3513 100644 --- a/server/src/training-agent/index.ts +++ b/server/src/training-agent/index.ts @@ -175,6 +175,24 @@ function getBaseUrl(req: Request): string { const TENANT_IDS = ['signals', 'sales', 'governance', 'creative', 'creative-builder', 'brand'] as const; +/** Specialisms each tenant declares — surfaced in the adagents.json + * `_training_agent_tenants` discovery extension. Mirrors the per-tenant + * config builders in `tenants/.ts`. */ +const TENANT_SPECIALISMS: Record = { + sales: ['sales-non-guaranteed', 'sales-guaranteed'], + signals: ['signal-marketplace', 'signal-owned'], + governance: [ + 'governance-spend-authority', + 'governance-delivery-monitor', + 'property-lists', + 'collection-lists', + 'content-standards', + ], + creative: ['creative-ad-server'], + 'creative-builder': ['creative-template', 'creative-generative'], + brand: ['brand-rights'], +}; + export function createTrainingAgentRouter(): Router { const router = Router(); @@ -298,9 +316,16 @@ export function createTrainingAgentRouter(): Router { res.json(getPublicJwks()); }); - // adagents.json discovery — lists each per-tenant URL with the - // properties / signals it serves. Per-tenant entries replace the - // single-URL `authorized_agents[0].url` from the v5 monolith. + // adagents.json discovery. Schema-conformant per + // `static/schemas/source/adagents.json`: + // - `authorized_agents[]` is a discriminated union — sales agents use + // `inline_properties`/`property_list_id`, signals agents use + // `signal_ids`/`signal_tags`. Governance/creative/brand tenants don't + // fit this shape (they're not inventory or data sellers) and are + // surfaced via the `_training_agent_tenants` discovery extension below. + // - `signals` and `signal_tags` are top-level catalog declarations the + // signals agent's `signal_tags` entry references. + const SIGNAL_TAG_VALUES = ['automotive', 'geo', 'retail', 'demographic', 'identity', 'contextual', 'first_party']; router.get('/.well-known/adagents.json', (req: Request, res: Response) => { const baseUrl = getBaseUrl(req); const agentUrl = `${baseUrl}${req.baseUrl}`; @@ -329,8 +354,8 @@ export function createTrainingAgentRouter(): Router { { url: `${agentUrl}/signals/mcp`, authorized_for: 'AdCP training — signals (marketplace + owned)', - authorization_type: 'inline_properties', - properties: [], + authorization_type: 'signal_tags', + signal_tags: SIGNAL_TAG_VALUES, }, ], signals: SIGNAL_PROVIDERS.flatMap(provider => @@ -353,6 +378,16 @@ export function createTrainingAgentRouter(): Router { contextual: { name: 'Contextual signals', description: 'Content category, sentiment, and page-level signals' }, first_party: { name: 'First-party signals', description: 'Publisher subscriber and CDP audience signals' }, }, + // Custom extension (allowed under schema's additionalProperties:true). + // Lists all six per-specialism tenants so a developer hitting + // adagents.json gets the full multi-tenant picture in one request — + // even for tenants that don't fit the schema's authorized_agents + // discriminator (governance, creative, creative-builder, brand). + _training_agent_tenants: TENANT_IDS.map(tenantId => ({ + tenant_id: tenantId, + url: `${agentUrl}/${tenantId}/mcp`, + specialisms: TENANT_SPECIALISMS[tenantId], + })), last_updated: STARTUP_TIME, }); }); diff --git a/server/src/training-agent/tenants/comply.ts b/server/src/training-agent/tenants/comply.ts index ac0bc1f206..8350d016db 100644 --- a/server/src/training-agent/tenants/comply.ts +++ b/server/src/training-agent/tenants/comply.ts @@ -25,6 +25,15 @@ import { TOOL_INPUT_SHAPE } from '@adcp/sdk/server'; import { handleComplyTestController } from '../comply-test-controller.js'; import type { ToolArgs, TrainingContext } from '../types.js'; +// Hardcoded principal — `ComplyControllerContext` doesn't carry authInfo, +// so the comply adapter has no per-call principal to forward. Effect: comply +// state set by one caller is visible to another caller using the same +// brand.domain. This is acceptable for the training agent (the entire surface +// is shared sandbox fixtures by design — different orgs intentionally see the +// same mock data while running storyboards) but is NOT a pattern production +// agents should copy. SDK feedback filed: surface authInfo on +// ComplyControllerContext so adopters that want partition-by-caller have the +// hook to do it. const trainingCtx: TrainingContext = { mode: 'open', principal: 'static:public' }; /** diff --git a/server/src/training-agent/tenants/registry.ts b/server/src/training-agent/tenants/registry.ts index 7b78d8bcd0..f064641045 100644 --- a/server/src/training-agent/tenants/registry.ts +++ b/server/src/training-agent/tenants/registry.ts @@ -1,13 +1,24 @@ /** * Multi-tenant TenantRegistry setup. * - * Five tenants — `/sales`, `/signals`, `/creative`, `/governance`, `/brand` — - * each with its own `DecisioningPlatform` impl, signing key, and specialism - * declarations. Path-based routing per `cbff7773` (`resolveByRequest(host, - * pathname)`). + * Six per-specialism tenants — `/sales`, `/signals`, `/governance`, + * `/creative`, `/creative-builder`, `/brand` — each with its own + * `DecisioningPlatform` impl, ephemeral signing key, and specialism + * declarations. Path-routed: tenants register with `agentUrl` like + * `${CANONICAL_BASE}/`, the router binds tenantId at route + * definition and dispatches via `registry.resolveByRequest(canonicalHost, + * '//mcp')` — independent of the actual request URL so the same + * handlers work under host-based dispatch + * (`test-agent.adcontextprotocol.org/sales/mcp`) and under the local + * Express mount (`/api/training-agent/sales/mcp` — Express strips the + * prefix before the router runs). * - * Today's wedge: only `/signals` is registered. Other tenants follow as - * specialism platforms get ported. + * Sandbox semantics: session state and idempotency keys are partitioned + * by `account.brand.domain`/`account.account_id`, NOT by tenantId. Cross- + * tenant scenarios (a buyer creating a media buy on `/sales/mcp` and + * checking governance on `/governance/mcp` for the same brand) intentionally + * share session state. Production sellers that need tenant isolation should + * key by their authenticated principal upstream of the training agent. */ import type { Request } from 'express'; @@ -29,20 +40,34 @@ import { createLogger } from '../../logger.js'; const logger = createLogger('training-agent-tenants'); /** - * No-op JWKS validator for the spike. The SDK's default validator fetches - * `{agentUrl}/.well-known/brand.json` which resolves to host root — but our - * brand.json sits under `/api/training-agent/.well-known/brand.json`, not - * server root. Pre-flight validation can't succeed against the wrong URL. + * No-op JWKS validator for the training agent. The SDK's default validator + * fetches `{agentUrl}/.well-known/brand.json`, which for path-routed tenants + * resolves to the host-root brand.json (RFC 5785) — our aggregated brand.json + * lives under the parent training-agent router rather than the host root, + * so the default validator can't reach it. * * Without a passing validator, tenants are stuck in `'pending'` and the - * registry refuses traffic. The spike trades pre-flight validation for - * functionality; AAO certification needs either: - * (a) brand.json hosted at server root (path-routed multi-tenant - * requires this anyway per RFC 5785), OR - * (b) a custom validator that knows our mount path. + * registry refuses traffic. We trade pre-flight validation for functionality + * because the training agent is a sandbox where the public keys are already + * advertised at the parent router. Production agents that ship their own + * deployment should write a path-aware validator (or move brand.json to + * host root and drop the no-op). + * + * Production guard: if NODE_ENV=production AND the agent is not the training + * agent (signaled by ALLOW_NOOP_JWKS_VALIDATOR), throw at boot. This + * prevents accidental import of the no-op validator into a production tenant + * registry that should be enforcing JWKS validation. */ const noopJwksValidator = { async validate() { + if (process.env.NODE_ENV === 'production' && !process.env.ALLOW_NOOP_JWKS_VALIDATOR) { + throw new Error( + 'noopJwksValidator refused in production. Set ALLOW_NOOP_JWKS_VALIDATOR=1 ' + + 'on the training agent deployment to acknowledge that path-routed multi-tenant ' + + 'agents skip pre-flight JWKS validation by design (the public keys are still ' + + 'advertised in the aggregated brand.json at the parent router).', + ); + } return { ok: true as const }; }, }; diff --git a/server/src/training-agent/tenants/signing.ts b/server/src/training-agent/tenants/signing.ts index 3765c381fa..4790b9f89f 100644 --- a/server/src/training-agent/tenants/signing.ts +++ b/server/src/training-agent/tenants/signing.ts @@ -15,7 +15,7 @@ * KMS-backed keys. */ -import { generateKeyPairSync } from 'node:crypto'; +import { generateKeyPairSync, randomBytes } from 'node:crypto'; import type { TenantSigningKey } from '@adcp/sdk/server'; import type { AdcpJsonWebKey } from '@adcp/sdk/signing'; import { createLogger } from '../../logger.js'; @@ -40,7 +40,7 @@ function generateEphemeralKey(tenantId: string): TenantMaterial { // JsonWebKey expectation. const privateJwk = privateKey.export({ format: 'jwk' }) as Record; const publicJwk = publicKey.export({ format: 'jwk' }) as Record; - const kid = `training-${tenantId}-${Math.random().toString(16).slice(2, 10)}`; + const kid = `training-${tenantId}-${randomBytes(4).toString('hex')}`; const signingKey: TenantSigningKey = { keyId: kid, diff --git a/server/src/training-agent/tenants/tenant-smoke.test.ts b/server/src/training-agent/tenants/tenant-smoke.test.ts index 2d08d185c2..c331dd84e9 100644 --- a/server/src/training-agent/tenants/tenant-smoke.test.ts +++ b/server/src/training-agent/tenants/tenant-smoke.test.ts @@ -51,8 +51,9 @@ describe('tenant routing smoke', () => { }, }), }); - // eslint-disable-next-line no-console - console.log('init status:', initR.status, 'body[:200]:', (await initR.text()).slice(0, 300)); + // Body content irrelevant — we just need the init handshake to settle + // before discovery so the JWKS is populated. + await initR.text(); const r = await fetch(`${baseUrl}/.well-known/brand.json`); expect(r.status).toBe(200); const body = await r.json() as { jwks: { keys: Array<{ kid: string; alg: string }> } }; @@ -60,8 +61,6 @@ describe('tenant routing smoke', () => { expect(body.jwks.keys.length).toBeGreaterThan(0); const signalsKid = body.jwks.keys.find(k => k.kid?.includes('signals')); expect(signalsKid).toBeDefined(); - // eslint-disable-next-line no-console - console.log('brand.json keys:', body.jwks.keys.map(k => ({ kid: k.kid, alg: k.alg }))); } finally { await close(); } @@ -94,8 +93,6 @@ describe('tenant routing smoke', () => { }); const body = await list.json() as { result?: { tools?: Array<{ name: string }> } }; const toolNames = (body.result?.tools ?? []).map(t => t.name).sort(); - // eslint-disable-next-line no-console - console.log('/signals tools:', toolNames); expect(toolNames).toContain('get_signals'); expect(toolNames).toContain('activate_signal'); // Tenant should NOT expose mediaBuy / governance tools diff --git a/server/tests/integration/training-agent-legacy-mcp.test.ts b/server/tests/integration/training-agent-legacy-mcp.test.ts index 8d8506504f..cb3db472d6 100644 --- a/server/tests/integration/training-agent-legacy-mcp.test.ts +++ b/server/tests/integration/training-agent-legacy-mcp.test.ts @@ -140,6 +140,24 @@ describe('Tenant routes via host-based dispatch (no /api/training-agent prefix)' expect(names.has('activate_signal')).toBe(true); }); + it('exposes all six tenants via _training_agent_tenants in adagents.json', async () => { + const res = await request(app).get('/.well-known/adagents.json'); + expect(res.status).toBe(200); + const body = res.body as { + authorized_agents: Array<{ url: string; authorization_type: string }>; + _training_agent_tenants: Array<{ tenant_id: string; url: string; specialisms: string[] }>; + }; + // Schema-conformant authorized_agents covers sales (inline_properties) + // and signals (signal_tags). Other tenants surface via the extension. + expect(body.authorized_agents.find(a => a.url.endsWith('/sales/mcp'))?.authorization_type).toBe('inline_properties'); + expect(body.authorized_agents.find(a => a.url.endsWith('/signals/mcp'))?.authorization_type).toBe('signal_tags'); + // Discovery extension lists every tenant with its specialism declaration. + const ids = body._training_agent_tenants.map(t => t.tenant_id).sort(); + expect(ids).toEqual(['brand', 'creative', 'creative-builder', 'governance', 'sales', 'signals']); + expect(body._training_agent_tenants.find(t => t.tenant_id === 'brand')?.specialisms).toContain('brand-rights'); + expect(body._training_agent_tenants.find(t => t.tenant_id === 'governance')?.specialisms).toContain('content-standards'); + }); + it('routes /brand/mcp to the brand tenant', async () => { const res = await request(app) .post('/brand/mcp') diff --git a/static/compliance/source/protocols/brand/index.yaml b/static/compliance/source/protocols/brand/index.yaml index 9598013fe3..551cf987db 100644 --- a/static/compliance/source/protocols/brand/index.yaml +++ b/static/compliance/source/protocols/brand/index.yaml @@ -157,7 +157,8 @@ phases: validations: - check: error_code allowed_values: + - "REFERENCE_NOT_FOUND" - "brand_not_found" - "BRAND_NOT_FOUND" - "NOT_FOUND" - description: "Error code indicates brand-not-found" + description: "Error code indicates brand-not-found. REFERENCE_NOT_FOUND is the canonical fallback per error-handling.mdx (brands lack a dedicated *_NOT_FOUND code); the legacy lowercase / BRAND_NOT_FOUND / generic NOT_FOUND values are tolerated for back-compat." From 03d9d164b355ef7cce90a56de47fa4e471d2981b Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 30 Apr 2026 21:26:32 -0400 Subject: [PATCH 3/5] feat(training-agent): surface per-tenant tool list in adagents.json discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item #4 from the expert review (wrong-tenant DX hint) — direct request-path interception broke storyboards because the runner's missing-tool detection doesn't classify any custom error format as a graceful skip. Surface the catalog at the discovery layer instead: each entry in `_training_agent_tenants` now carries a `tools[]` list of canonical AdCP tool names that tenant serves. A developer hitting `/.well-known/adagents.json` gets the full picture in one document — which URL to call for `get_products` vs `get_signals` vs `check_governance` — without trial-and-error against six different MCP endpoints. Coding agents reading the manifest can build a tool→URL map at import time. Multi-tenant tools (e.g., `list_creative_formats` served by sales, creative, and creative-builder) appear under each tenant's list. Tools that AREN'T on a tenant simply aren't listed — `creative-builder.tools` omits `list_creatives` so a developer can see immediately that the template/transformation surface is read-only on creative format discovery. Backed by a static `tool-catalog.ts` so the inverse `toolsForTenant` view stays in sync with the per-platform code. 372 tests passing, storyboards unchanged across all six tenants. Co-Authored-By: Claude Opus 4.7 (1M context) --- server/src/training-agent/index.ts | 2 + .../training-agent/tenants/tool-catalog.ts | 80 +++++++++++++++++++ .../training-agent-legacy-mcp.test.ts | 13 ++- 3 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 server/src/training-agent/tenants/tool-catalog.ts diff --git a/server/src/training-agent/index.ts b/server/src/training-agent/index.ts index ab765d3513..a919246f60 100644 --- a/server/src/training-agent/index.ts +++ b/server/src/training-agent/index.ts @@ -31,6 +31,7 @@ import { } from '@adcp/sdk/server'; import { createLogger } from '../logger.js'; import { mountTenantRoutes } from './tenants/router.js'; +import { toolsForTenant } from './tenants/tool-catalog.js'; import { createTrainingAgentServer } from './task-handlers.js'; import { runWithSessionContext, flushDirtySessions, startSessionCleanup } from './state.js'; import type { TrainingContext } from './types.js'; @@ -387,6 +388,7 @@ export function createTrainingAgentRouter(): Router { tenant_id: tenantId, url: `${agentUrl}/${tenantId}/mcp`, specialisms: TENANT_SPECIALISMS[tenantId], + tools: toolsForTenant(tenantId), })), last_updated: STARTUP_TIME, }); diff --git a/server/src/training-agent/tenants/tool-catalog.ts b/server/src/training-agent/tenants/tool-catalog.ts new file mode 100644 index 0000000000..2381f28ff3 --- /dev/null +++ b/server/src/training-agent/tenants/tool-catalog.ts @@ -0,0 +1,80 @@ +/** + * Static catalog mapping each canonical AdCP tool name to the tenants that + * serve it. Surfaces in the `_training_agent_tenants` discovery extension + * on `/.well-known/adagents.json` so a developer can pick the right URL + * without trial-and-error. + * + * NOT used for request-path interception. The 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 graceful skips — so any custom error format breaks the + * runner. Keep the catalog as a discovery hint only until upstream SDK + * adds a wrong-tenant classifier. + * + * Multi-tenant tools (e.g., `list_creative_formats` on sales / creative / + * creative-builder) appear in multiple tenants' lists. + */ + +export const TOOL_CATALOG: Readonly> = { + // sales + get_products: ['sales'], + create_media_buy: ['sales'], + update_media_buy: ['sales'], + get_media_buys: ['sales'], + get_media_buy_delivery: ['sales'], + provide_performance_feedback: ['sales'], + report_usage: ['sales', 'creative', 'signals'], + + // creative discovery / management — exposed by multiple tenants + list_creative_formats: ['sales', 'creative', 'creative-builder'], + list_creatives: ['sales', 'creative'], + sync_creatives: ['sales', 'creative'], + build_creative: ['creative', 'creative-builder'], + preview_creative: ['creative', 'creative-builder'], + get_creative_delivery: ['creative'], + + // signals + get_signals: ['signals'], + activate_signal: ['signals'], + + // governance — campaign + sync_plans: ['governance'], + check_governance: ['governance'], + report_plan_outcome: ['governance'], + get_plan_audit_logs: ['governance'], + + // governance — property lists + create_property_list: ['governance'], + update_property_list: ['governance'], + list_property_lists: ['governance'], + get_property_list: ['governance'], + validate_property_delivery: ['governance'], + + // governance — collection lists + create_collection_list: ['governance'], + update_collection_list: ['governance'], + list_collection_lists: ['governance'], + get_collection_list: ['governance'], + + // governance — content standards + create_content_standards: ['governance'], + update_content_standards: ['governance'], + list_content_standards: ['governance'], + get_content_standards: ['governance'], + calibrate_content: ['governance'], + + // brand + get_brand_identity: ['brand'], + get_rights: ['brand'], + acquire_rights: ['brand'], + update_rights: ['brand'], + creative_approval: ['brand'], +}; + +/** Build the tool list a given tenant serves — inverse view of TOOL_CATALOG. */ +export function toolsForTenant(tenantId: string): string[] { + return Object.entries(TOOL_CATALOG) + .filter(([, tenants]) => tenants.includes(tenantId)) + .map(([tool]) => tool) + .sort(); +} diff --git a/server/tests/integration/training-agent-legacy-mcp.test.ts b/server/tests/integration/training-agent-legacy-mcp.test.ts index cb3db472d6..3ef9e0d4c2 100644 --- a/server/tests/integration/training-agent-legacy-mcp.test.ts +++ b/server/tests/integration/training-agent-legacy-mcp.test.ts @@ -145,7 +145,12 @@ describe('Tenant routes via host-based dispatch (no /api/training-agent prefix)' expect(res.status).toBe(200); const body = res.body as { authorized_agents: Array<{ url: string; authorization_type: string }>; - _training_agent_tenants: Array<{ tenant_id: string; url: string; specialisms: string[] }>; + _training_agent_tenants: Array<{ + tenant_id: string; + url: string; + specialisms: string[]; + tools: string[]; + }>; }; // Schema-conformant authorized_agents covers sales (inline_properties) // and signals (signal_tags). Other tenants surface via the extension. @@ -156,6 +161,12 @@ describe('Tenant routes via host-based dispatch (no /api/training-agent prefix)' expect(ids).toEqual(['brand', 'creative', 'creative-builder', 'governance', 'sales', 'signals']); expect(body._training_agent_tenants.find(t => t.tenant_id === 'brand')?.specialisms).toContain('brand-rights'); expect(body._training_agent_tenants.find(t => t.tenant_id === 'governance')?.specialisms).toContain('content-standards'); + // Tools surface so a developer can pick the right URL without trial. + expect(body._training_agent_tenants.find(t => t.tenant_id === 'sales')?.tools).toContain('get_products'); + expect(body._training_agent_tenants.find(t => t.tenant_id === 'signals')?.tools).toContain('get_signals'); + expect(body._training_agent_tenants.find(t => t.tenant_id === 'creative-builder')?.tools).toContain('build_creative'); + expect(body._training_agent_tenants.find(t => t.tenant_id === 'creative-builder')?.tools).not.toContain('list_creatives'); + expect(body._training_agent_tenants.find(t => t.tenant_id === 'governance')?.tools).toContain('check_governance'); }); it('routes /brand/mcp to the brand tenant', async () => { From 239b6b9d85539201da26a96a073aef6e90896386 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 30 Apr 2026 21:47:05 -0400 Subject: [PATCH 4/5] fix(training-agent): tool-catalog accuracy + URL canonicalization + drift CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 expert review caught real bugs in the tool catalog and surfaced two smaller correctness issues. All addressed: **Tool catalog matches reality.** Hand-curated catalog drifted from each v6 platform's actual `tools/list` output: - Removed `report_usage` from sales / signals / creative — handler exists in `task-handlers.ts` but isn't wired to any v6 tenant. - Removed `list_creative_formats` from creative / creative-builder — only the sales platform exposes it (the creative platforms could expose it; tracked separately as a feature gap). - Removed `validate_property_delivery`; added the actually-registered `validate_content_delivery` to governance. - Added `delete_property_list`, `delete_collection_list` to governance. - Added `list_creatives`, `sync_creatives`, `get_creative_delivery` to creative-builder (creative-builder DOES advertise these via the SDK's CreativeBuilderPlatform interface; the catalog under-listed them). **Drift detection in CI.** New `tests/integration/training-agent-tool-catalog-drift.test.ts` boots each tenant, calls `tools/list`, and asserts the live response matches `toolsForTenant(id)`. Universal MCP tools (`get_adcp_capabilities`, `comply_test_controller`, `tasks_get`) are excluded by convention. Adding a new tool to a v6 platform without updating the catalog now fails CI with a per-tenant diff message. **URL canonicalization in member-tools.ts.** `resolveAgentAuth`'s public-token check now canonicalizes URLs (strip trailing slash, query string, fragment) before comparison. Saved `agent_contexts` rows with cosmetic variations like `…/sales/mcp/` or `…/sales/mcp?retry=1` no longer fall through and miss the public-token path. **Doc-comment accuracy.** `tenants/registry.ts` header comment: - noopJwksValidator guard reworded — fires on first request that initializes the registry (lazy validation), not at import time. - Session-key partitioning comment now describes both open mode (`account.brand.domain`) and training mode (`training::`) — the prior version covered only open mode. 379 tests passing (354 unit + 6 demo-key + 3 webhook + 9 legacy/host-based + 7 catalog-drift). Storyboards unchanged across all six tenants. Co-Authored-By: Claude Opus 4.7 (1M context) --- server/src/addie/mcp/member-tools.ts | 28 +++- server/src/training-agent/tenants/registry.ts | 15 +- .../training-agent/tenants/tool-catalog.ts | 23 ++- .../training-agent-legacy-mcp.test.ts | 2 +- .../training-agent-tool-catalog-drift.test.ts | 138 ++++++++++++++++++ 5 files changed, 188 insertions(+), 18 deletions(-) create mode 100644 server/tests/integration/training-agent-tool-catalog-drift.test.ts diff --git a/server/src/addie/mcp/member-tools.ts b/server/src/addie/mcp/member-tools.ts index 2a730b0c9e..09dae5077f 100644 --- a/server/src/addie/mcp/member-tools.ts +++ b/server/src/addie/mcp/member-tools.ts @@ -221,11 +221,31 @@ async function resolveAgentAuth( ): Promise { let resolvedUrl = agentUrl; + // 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 (resolvedUrl.toLowerCase() === INTERNAL_PATH_AGENT_URL.toLowerCase()) { + if (canonicalize(resolvedUrl) === canonicalize(INTERNAL_PATH_AGENT_URL)) { resolvedUrl = PUBLIC_TEST_AGENT_URLS.legacy; } @@ -233,9 +253,9 @@ async function resolveAgentAuth( // for this URL are ignored because they're likely incorrect (the public token is // 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 lowerUrl = resolvedUrl.toLowerCase(); - const publicTestAgentUrls: string[] = Object.values(PUBLIC_TEST_AGENT_URLS).map(u => u.toLowerCase()); - if (publicTestAgentUrls.includes(lowerUrl)) { + 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 }; } diff --git a/server/src/training-agent/tenants/registry.ts b/server/src/training-agent/tenants/registry.ts index f064641045..1d4e8c4799 100644 --- a/server/src/training-agent/tenants/registry.ts +++ b/server/src/training-agent/tenants/registry.ts @@ -14,7 +14,9 @@ * prefix before the router runs). * * Sandbox semantics: session state and idempotency keys are partitioned - * by `account.brand.domain`/`account.account_id`, NOT by tenantId. Cross- + * by `account.brand.domain` / `account.account_id` in open mode, and by + * `training::` in training mode (see + * `state.ts:sessionKeyFromArgs`). Neither path includes tenantId — cross- * tenant scenarios (a buyer creating a media buy on `/sales/mcp` and * checking governance on `/governance/mcp` for the same brand) intentionally * share session state. Production sellers that need tenant isolation should @@ -53,10 +55,13 @@ const logger = createLogger('training-agent-tenants'); * deployment should write a path-aware validator (or move brand.json to * host root and drop the no-op). * - * Production guard: if NODE_ENV=production AND the agent is not the training - * agent (signaled by ALLOW_NOOP_JWKS_VALIDATOR), throw at boot. This - * prevents accidental import of the no-op validator into a production tenant - * registry that should be enforcing JWKS validation. + * Production guard: if NODE_ENV=production AND the deployment hasn't set + * ALLOW_NOOP_JWKS_VALIDATOR=1, throw on the first request that initializes + * the registry. Validation runs lazily, so the throw fires near-boot but + * not at import time — operators triaging an incident should look in the + * request stream, not the deploy log. Prevents accidental import of the + * no-op validator into a production tenant registry that should be + * enforcing JWKS validation. */ const noopJwksValidator = { async validate() { diff --git a/server/src/training-agent/tenants/tool-catalog.ts b/server/src/training-agent/tenants/tool-catalog.ts index 2381f28ff3..9ae3533b14 100644 --- a/server/src/training-agent/tenants/tool-catalog.ts +++ b/server/src/training-agent/tenants/tool-catalog.ts @@ -11,8 +11,14 @@ * runner. Keep the catalog as a discovery hint only until upstream SDK * adds a wrong-tenant classifier. * - * Multi-tenant tools (e.g., `list_creative_formats` on sales / creative / + * Multi-tenant tools (e.g., `sync_creatives` served by sales / creative / * creative-builder) appear in multiple tenants' lists. + * + * Drift detection: `tests/integration/training-agent-tool-catalog-drift.test.ts` + * boots each tenant and asserts this catalog matches the live `tools/list` + * response. Universal tools (`get_adcp_capabilities`, `comply_test_controller`, + * `tasks_get`) are excluded from the catalog by convention — they're on + * every tenant and never form a "wrong tenant" hint. */ export const TOOL_CATALOG: Readonly> = { @@ -23,15 +29,14 @@ export const TOOL_CATALOG: Readonly> = { get_media_buys: ['sales'], get_media_buy_delivery: ['sales'], provide_performance_feedback: ['sales'], - report_usage: ['sales', 'creative', 'signals'], + list_creative_formats: ['sales'], - // creative discovery / management — exposed by multiple tenants - list_creative_formats: ['sales', 'creative', 'creative-builder'], - list_creatives: ['sales', 'creative'], - sync_creatives: ['sales', 'creative'], + // creative — exposed on multiple tenants + list_creatives: ['sales', 'creative', 'creative-builder'], + sync_creatives: ['sales', 'creative', 'creative-builder'], build_creative: ['creative', 'creative-builder'], preview_creative: ['creative', 'creative-builder'], - get_creative_delivery: ['creative'], + get_creative_delivery: ['creative', 'creative-builder'], // signals get_signals: ['signals'], @@ -48,13 +53,15 @@ export const TOOL_CATALOG: Readonly> = { update_property_list: ['governance'], list_property_lists: ['governance'], get_property_list: ['governance'], - validate_property_delivery: ['governance'], + delete_property_list: ['governance'], + validate_content_delivery: ['governance'], // governance — collection lists create_collection_list: ['governance'], update_collection_list: ['governance'], list_collection_lists: ['governance'], get_collection_list: ['governance'], + delete_collection_list: ['governance'], // governance — content standards create_content_standards: ['governance'], diff --git a/server/tests/integration/training-agent-legacy-mcp.test.ts b/server/tests/integration/training-agent-legacy-mcp.test.ts index 3ef9e0d4c2..bf8440a9c9 100644 --- a/server/tests/integration/training-agent-legacy-mcp.test.ts +++ b/server/tests/integration/training-agent-legacy-mcp.test.ts @@ -165,7 +165,7 @@ describe('Tenant routes via host-based dispatch (no /api/training-agent prefix)' expect(body._training_agent_tenants.find(t => t.tenant_id === 'sales')?.tools).toContain('get_products'); expect(body._training_agent_tenants.find(t => t.tenant_id === 'signals')?.tools).toContain('get_signals'); expect(body._training_agent_tenants.find(t => t.tenant_id === 'creative-builder')?.tools).toContain('build_creative'); - expect(body._training_agent_tenants.find(t => t.tenant_id === 'creative-builder')?.tools).not.toContain('list_creatives'); + expect(body._training_agent_tenants.find(t => t.tenant_id === 'creative-builder')?.tools).not.toContain('get_products'); expect(body._training_agent_tenants.find(t => t.tenant_id === 'governance')?.tools).toContain('check_governance'); }); diff --git a/server/tests/integration/training-agent-tool-catalog-drift.test.ts b/server/tests/integration/training-agent-tool-catalog-drift.test.ts new file mode 100644 index 0000000000..4f076b4581 --- /dev/null +++ b/server/tests/integration/training-agent-tool-catalog-drift.test.ts @@ -0,0 +1,138 @@ +/** + * Drift detection for `tool-catalog.ts`. + * + * The catalog is hand-maintained — adding a tool to a `v6-*-platform.ts` + * file without updating the catalog silently breaks the discovery + * extension (`_training_agent_tenants[].tools[]`) on `adagents.json`. + * + * This test boots a fresh router instance and queries `tools/list` on + * every tenant. For each tenant, it asserts: + * 1. Every tool the platform actually advertises is in the catalog + * under that tenant. + * 2. Every tool the catalog claims for the tenant is actually + * advertised. + * + * Run before merging changes to platform files or the catalog. Failures + * print the exact diff per tenant so the fix is mechanical. + */ + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import express from 'express'; +import http from 'node:http'; +import { AddressInfo } from 'node:net'; + +vi.hoisted(() => { + process.env.PUBLIC_TEST_AGENT_TOKEN = 'tool-catalog-drift-token'; + process.env.NODE_ENV = 'test'; +}); + +vi.mock('../../src/logger.js', () => ({ + createLogger: () => ({ + info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn(), + }), +})); + +const { createTrainingAgentRouter } = await import('../../src/training-agent/index.js'); +const { stopSessionCleanup } = await import('../../src/training-agent/state.js'); +const { toolsForTenant, TOOL_CATALOG } = await import('../../src/training-agent/tenants/tool-catalog.js'); + +const TENANT_IDS = ['signals', 'sales', 'governance', 'creative', 'creative-builder', 'brand'] as const; +const AUTH = 'Bearer tool-catalog-drift-token'; + +interface ToolListResponse { + result?: { tools?: Array<{ name: string }> }; +} + +async function listTools(baseUrl: string, tenantId: string): Promise { + const url = `${baseUrl}/api/training-agent/${tenantId}/mcp`; + // MCP requires an `initialize` handshake before tools/list works. + await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + authorization: AUTH, + }, + body: JSON.stringify({ + jsonrpc: '2.0', id: 1, method: 'initialize', + params: { protocolVersion: '2025-03-26', clientInfo: { name: 'drift', version: '1' }, capabilities: {} }, + }), + }); + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + authorization: AUTH, + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }), + }); + const body = await res.json() as ToolListResponse; + // Strip universal utility tools that the SDK auto-registers on every + // tenant. The catalog deliberately doesn't track these because they + // never form part of a "wrong tenant" hint — they're available + // everywhere. `tasks_get` is the MCP transport-level task-poll helper. + const NON_PROTOCOL_TOOLS = new Set([ + 'get_adcp_capabilities', + 'comply_test_controller', + 'tasks_get', + ]); + return (body.result?.tools ?? []) + .map(t => t.name) + .filter(name => !NON_PROTOCOL_TOOLS.has(name)) + .sort(); +} + +describe('tool-catalog drift detection', () => { + let server: http.Server; + let baseUrl: string; + + beforeAll(async () => { + const app = express(); + app.use(express.json()); + app.use('/api/training-agent', createTrainingAgentRouter()); + server = http.createServer(app); + await new Promise(resolve => server.listen(0, '127.0.0.1', () => resolve())); + const port = (server.address() as AddressInfo).port; + baseUrl = `http://127.0.0.1:${port}`; + }); + + afterAll(async () => { + stopSessionCleanup(); + await new Promise(resolve => server.close(() => resolve())); + }); + + it.each(TENANT_IDS)('catalog matches actual tools/list for /%s', async (tenantId) => { + const advertised = await listTools(baseUrl, tenantId); + const catalog = toolsForTenant(tenantId); + + const missingFromCatalog = advertised.filter(t => !catalog.includes(t)); + const stale = catalog.filter(t => !advertised.includes(t)); + + if (missingFromCatalog.length > 0 || stale.length > 0) { + const message = [ + `Tool catalog drift on tenant '${tenantId}':`, + missingFromCatalog.length + ? ` missing from catalog (advertised but not listed): ${JSON.stringify(missingFromCatalog)}` + : null, + stale.length + ? ` stale catalog entries (catalog claims tenant has, but tools/list omits): ${JSON.stringify(stale)}` + : null, + '', + `Fix in server/src/training-agent/tenants/tool-catalog.ts so the entry for '${tenantId}'`, + `matches the live tools/list output, then re-run.`, + ].filter(Boolean).join('\n'); + throw new Error(message); + } + expect(advertised.sort()).toEqual([...catalog].sort()); + }); + + it('every tool in the catalog references a known tenant id', () => { + const validTenants = new Set(TENANT_IDS); + for (const [tool, tenants] of Object.entries(TOOL_CATALOG)) { + for (const t of tenants) { + expect(validTenants.has(t), `tool '${tool}' references unknown tenant '${t}'`).toBe(true); + } + } + }); +}); From df122fcac2e70d0815b37d2640715c071a1d6e94 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 30 Apr 2026 22:08:06 -0400 Subject: [PATCH 5/5] ci(training-agent): per-tenant storyboard matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's storyboard workflow ran the legacy single-URL runner with TRAINING_AGENT_USE_FRAMEWORK=0/1 to compare framework vs legacy dispatch on the same /api/training-agent/mcp endpoint. The multi-tenant migration removed that endpoint — the runner now requires TENANT_PATH and runs against one tenant per invocation. Replace the legacy/framework matrix with a per-tenant matrix (signals, sales, governance, creative, creative-builder, brand). Each tenant gets its own min_clean_storyboards / min_passing_steps floor, seeded from the post-migration baseline. The asymmetric-invariant-check job (compared framework vs legacy step counts) is dropped — there is only one dispatch model now. Floors: /signals 59 clean / 23 passing steps /sales 55 clean / 159 passing steps /governance 57 clean / 62 passing steps /creative 58 clean / 44 passing steps /creative-builder 55 clean / 37 passing steps /brand 59 clean / 14 passing steps Co-Authored-By: Claude Opus 4.7 (1M context) --- .../workflows/training-agent-storyboards.yml | 248 ++++-------------- 1 file changed, 51 insertions(+), 197 deletions(-) diff --git a/.github/workflows/training-agent-storyboards.yml b/.github/workflows/training-agent-storyboards.yml index bed2e72915..893ad0e9af 100644 --- a/.github/workflows/training-agent-storyboards.yml +++ b/.github/workflows/training-agent-storyboards.yml @@ -24,64 +24,36 @@ concurrency: jobs: storyboards: - name: Storyboards (${{ matrix.mode }} dispatch) + name: Storyboards (/${{ matrix.tenant }}) runs-on: ubuntu-latest timeout-minutes: 10 strategy: fail-fast: false matrix: - mode: [legacy, framework] + # The training agent is split into six per-specialism tenants; each + # tenant runs the full conformance suite and is graded against its + # own floors. Floors below are the post-migration baseline (PR #3713, + # SDK 6.0.0). Rising is fine; regressing fails CI. + tenant: [signals, sales, governance, creative, creative-builder, brand] include: - - mode: legacy - flag_value: '0' - # Baseline on main — keep in sync with the last clean run reported - # in the PR description. Rising is fine; regressing fails CI. - # 378→380 after bumping @adcp/client to 5.15 (5.14 fixture-wins - # refactor exposed training-agent catalog gaps; aliased - # outdoor_ctv_q2/local_display_dynamic products and renamed - # two prism signal pricing ids to match the spec test-kit). - # 380→384 / 52→53 after implementing force_create_media_buy_arm - # in the training-agent: the create_media_buy_async storyboard - # (#3081) now passes 4 steps instead of grading not_applicable. - # 384→388 after bumping @adcp/client to 5.18.0 (#3191) and - # implementing force_task_completion (#3194). - # The 53/388 floors had drifted below the actual baseline - # (true main was 64 storyboards / 439 steps); #3777 brings - # the count to 65 / 446 by adding the - # media_buy_seller/provenance_enforcement scenario (six - # phases exercising PROVENANCE_REQUIRED, - # PROVENANCE_DIGITAL_SOURCE_TYPE_MISSING, - # PROVENANCE_VERIFIER_NOT_ACCEPTED, - # PROVENANCE_DISCLOSURE_MISSING, and an accept path). - # Passing-step asymmetry with framework mode is intentional: - # the two modes declare different capabilities, so the - # storyboard runner skips a different subset of steps in each. - # Both modes pass every step they run. - min_clean_storyboards: 65 - min_passing_steps: 446 - - mode: framework - flag_value: '1' - # 390→393 after bumping @adcp/client to 5.15 + seller catalog - # alignment (see legacy-mode comment). - # 393→370 / 52→41 after bumping @adcp/client to 5.17.0: the - # framework dispatch tightened request-side schema validation - # and the bundled storyboards sent additional properties their - # own request schemas rejected (sync_plans, list_property_lists, - # delete_property_list). Tracked at adcontextprotocol/adcp-client#940. - # 370→374 / 41→42 after implementing force_create_media_buy_arm - # (#3081 follow-up) — same +1 storyboard / +4 steps as legacy. - # 374→401 / 42→53 after bumping @adcp/client to 5.18.0 (#3191) - # which brought in adcp-client#943's schema-aware brand/account - # injection — the upstream fix for #940. Framework parity with - # legacy fully restored; passing-step asymmetry remains because - # framework declares more capabilities (more steps run, all pass). - # The 53/401 floors had drifted below the actual baseline - # (true main was 64 storyboards / 457 steps); #3777 brings the - # count to 65 / 464 — same six-phase provenance_enforcement - # scenario as legacy; framework picks up the extra steps from - # its broader capability declaration. - min_clean_storyboards: 65 - min_passing_steps: 464 + - tenant: signals + min_clean_storyboards: 59 + min_passing_steps: 23 + - tenant: sales + min_clean_storyboards: 55 + min_passing_steps: 159 + - tenant: governance + min_clean_storyboards: 57 + min_passing_steps: 62 + - tenant: creative + min_clean_storyboards: 58 + min_passing_steps: 44 + - tenant: creative-builder + min_clean_storyboards: 55 + min_passing_steps: 37 + - tenant: brand + min_clean_storyboards: 59 + min_passing_steps: 14 steps: - uses: actions/checkout@v6 @@ -97,14 +69,14 @@ jobs: PUPPETEER_SKIP_DOWNLOAD: 'true' - name: Overlay in-repo compliance source onto SDK cache - # The storyboard runner loads from @adcp/client's bundled - # compliance cache, which is a snapshot synced from this repo's - # static/compliance/source/ on each SDK publish. To validate - # spec changes in this PR before waiting for an SDK republish, - # mirror the current source tree onto the cache — both edits to - # existing files AND new files added in this PR. mkdir -p creates - # any new subdirectories the source introduces so the cache stays - # in sync with whatever lands here. + # The storyboard runner loads from @adcp/sdk's bundled compliance + # cache, which is a snapshot synced from this repo's + # static/compliance/source/ on each SDK publish. To validate spec + # changes in this PR before waiting for an SDK republish, mirror + # the current source tree onto the cache — both edits to existing + # files AND new files added in this PR. mkdir -p creates any new + # subdirectories the source introduces so the cache stays in sync + # with whatever lands here. # # Caveat: this overlay does NOT regenerate the SDK's cache index # (e.g., index.json bundles enumerated at build time). New files @@ -115,7 +87,7 @@ jobs: # are the common case and work fine. run: | SRC="static/compliance/source" - # 5.13 moved the cache dir from `latest` to the AdCP version + # SDK 5.13 moved the cache dir from `latest` to the AdCP version # string (e.g. `3.0.0`). 5.23.0 renamed the package # `@adcp/client` -> `@adcp/sdk` and the compliance bundle moved # with it. Resolve whichever subdir exists so the overlay step @@ -127,9 +99,6 @@ jobs: exit 0 fi echo "Overlaying onto $DST" - # Mirror every source file onto the cache. New files (storyboards, - # specialisms) get added; existing files are overwritten so the - # branch's edits always win. (cd "$SRC" && find . -type f) | while read -r rel; do target="$DST/${rel#./}" mkdir -p "$(dirname "$target")" @@ -137,25 +106,25 @@ jobs: done echo "Overlay complete." - - name: Run storyboards (${{ matrix.mode }}) + - name: Run storyboards against /${{ matrix.tenant }} id: run env: - TRAINING_AGENT_USE_FRAMEWORK: ${{ matrix.flag_value }} + TENANT_PATH: ${{ matrix.tenant }} PUBLIC_TEST_AGENT_TOKEN: storyboard-ci-token run: | - set -euo pipefail - # `set -o pipefail` makes the storyboards-runner exit code propagate - # through the `tee` instead of getting swallowed. Without this a - # crash inside run-storyboards.ts would still report success here. - npx tsx server/tests/manual/run-storyboards.ts 2>&1 | tee /tmp/storyboards.log - # Disable -e for the parse pipelines — `grep` returning empty is - # not a fatal error, we want to detect that explicitly below. - set +e + set -uo pipefail + # The runner exits 1 when any storyboard step fails (intentional — + # dev ergonomics). CI grades pass/fail via the floor check below, + # not the runner's exit code. `|| true` keeps the step alive past + # the runner so the parse + regression-check stages can run. + npx tsx server/tests/manual/run-storyboards.ts 2>&1 | tee /tmp/storyboards.log || true + # `grep` returning empty is not a fatal error here — we want to + # detect that explicitly below. clean=$(grep -oE 'storyboards: [0-9]+/[0-9]+' /tmp/storyboards.log | tail -1 | grep -oE '^storyboards: [0-9]+' | grep -oE '[0-9]+$') passed=$(grep -oE 'steps: [0-9]+ passed' /tmp/storyboards.log | tail -1 | grep -oE '[0-9]+') set -e if [ -z "${clean}" ] || [ -z "${passed}" ]; then - echo "::error::Failed to parse storyboard count from log — the runner's output format may have changed. Download the storyboards-log-${{ matrix.mode }} artifact from this run to inspect the raw output and update the grep patterns in this workflow." + echo "::error::Failed to parse storyboard count from log — the runner's output format may have changed. Download the storyboards-log-${{ matrix.tenant }} artifact from this run to inspect the raw output and update the grep patterns in this workflow." exit 1 fi echo "clean=${clean}" >> "$GITHUB_OUTPUT" @@ -165,12 +134,12 @@ jobs: if: failure() uses: actions/upload-artifact@v7 with: - name: storyboards-log-${{ matrix.mode }} + name: storyboards-log-${{ matrix.tenant }} path: /tmp/storyboards.log retention-days: 14 if-no-files-found: ignore - - name: Enforce non-regression (${{ matrix.mode }}) + - name: Enforce non-regression (/${{ matrix.tenant }}) env: CLEAN: ${{ steps.run.outputs.clean }} PASSED: ${{ steps.run.outputs.passed }} @@ -178,27 +147,22 @@ jobs: MIN_PASSED: ${{ matrix.min_passing_steps }} run: | set -euo pipefail - echo "Mode: ${{ matrix.mode }} (flag=${{ matrix.flag_value }})" + echo "Tenant: /${{ matrix.tenant }}" echo "Clean storyboards: ${CLEAN} (floor: ${MIN_CLEAN})" echo "Passing steps: ${PASSED} (floor: ${MIN_PASSED})" - # Helper that prints a uniform actionable next-step block whenever - # a floor is breached. Tells the contributor exactly which file - # and matrix key to edit, and where to download the log artifact - # to see *which* storyboards regressed. regression_help() { local metric="$1" local observed="$2" local floor="$3" cat <"). - • If unintentional: download the storyboards-log-${{ matrix.mode }} artifact + find the matrix entry where tenant == ${{ matrix.tenant }}, and update + the floor to ${observed}. + • If unintentional: download the storyboards-log-${{ matrix.tenant }} artifact from this run to see which storyboards regressed and why. HELP } @@ -210,113 +174,3 @@ jobs: regression_help "passing steps" "${PASSED}" "${MIN_PASSED}" exit 1 fi - # Persist this matrix's results to an artifact so the summary - # job can compare framework vs legacy without rerunning anything. - mkdir -p /tmp/storyboard-results - printf '%s\n%s\n' "${CLEAN}" "${PASSED}" > /tmp/storyboard-results/results.txt - - - name: Verify required-clean storyboards (${{ matrix.mode }}) - # Floors above gate on counts — they catch *regressions* (count drops) - # but not *rebalancing*: a future change could break load-bearing - # scenario A while a new scenario B passes, leaving the count flat. - # This step pins specific scenario IDs whose conformance is wire- - # load-bearing — failure of any listed scenario fails CI regardless - # of total count. Don't pin all 65 — that's just KNOWN_FAILING - # inverted and gets noisy. Pin only the contracts whose conformance - # is genuinely load-bearing for the spec wire surface. - # Tracked at adcp#3803. - run: | - set -euo pipefail - REQUIRED=( - # Provenance enforcement contract from #3468 — buyer/seller wire - # surface for creative_policy.{provenance_required, - # provenance_requirements, accepted_verifiers} and the six - # PROVENANCE_* rejection codes. - "media_buy_seller/provenance_enforcement" - # HTTP request signing per RFC 9421 — auth conformance. - "signed_requests" - # Universal rejection vocabulary — every seller must use the - # canonical error codes, not custom strings. - "error_compliance" - # At-most-once execution under retry — idempotency_key contract. - "idempotency" - # Schema validation contract — request/response shape conformance. - "schema_validation" - # Capability discovery — get_adcp_capabilities is the entry point - # for every other contract; if this fails nothing else grades. - "capability_discovery" - ) - # Approach: check the runner's failure inventory rather than trying - # to detect "id followed by ✓ on the same line." The latter doesn't - # work in legacy dispatch — when the runner emits warnings during a - # storyboard run (e.g., "[AdCP] Stripping fields not declared in agent - # schema..."), the warning fills the id-prefixed column and the ✓ - # summary lands on the next line without the id. The runner's - # `--- Failures ---` block is the authoritative list of failures. - # A required-clean scenario passes the gate iff it does NOT appear - # there AND is NOT on the KNOWN_FAILING_STORYBOARDS skip list. - failures_block=$(awk '/^--- Failures ---/{flag=1; next} /^--- Totals ---/{flag=0} flag' /tmp/storyboards.log) - known_failing_block=$(awk '/^Skipping storyboards on the known-failing list:/{flag=1; next} /^[^ ]/{flag=0} flag' /tmp/storyboards.log) - missing=() - for id in "${REQUIRED[@]}"; do - if echo "${failures_block}" | grep -qE "^[[:space:]]+${id}: "; then - missing+=("${id} (failed)") - continue - fi - if echo "${known_failing_block}" | grep -qE "${id}:"; then - missing+=("${id} (on KNOWN_FAILING_STORYBOARDS — would silently skip)") - continue - fi - done - if [ "${#missing[@]}" -gt 0 ]; then - echo "::error::Required-clean storyboards did not pass on ${{ matrix.mode }} dispatch:" - for id in "${missing[@]}"; do - echo "::error:: - ${id}" - done - cat <= legacy)." - fi