diff --git a/.changeset/two-layer-error-emission-1606.md b/.changeset/two-layer-error-emission-1606.md new file mode 100644 index 000000000..075be1e3f --- /dev/null +++ b/.changeset/two-layer-error-emission-1606.md @@ -0,0 +1,40 @@ +--- +'@adcp/sdk': minor +--- + +`adcpError()` and the `{errors:[...]}` typed Error arm now both ship the +two-layer wire shape required by 18 AdCP response schemas +(`{adcp_error: {...}, errors: [{...}]}` instead of envelope-only or +payload-only). Adopters keep calling `adcpError()` and returning typed +Error arms exactly as before — the framework dispatcher derives the +affected tools from the bundled schema cache at server build, then +mirrors `adcp_error` ↔ `errors[]` in the `finalize()` seam on the +failure path so both spec-mandated layers ride on every failing +response. + +The wire change is on the failure path only and is strictly additive: +responses that previously failed schema validation against the +`*Error` arm of their `oneOf` now pass it. Adopters who already emit +both layers manually are detected and pass through unchanged +(idempotent — no duplicate or replacement). Tools whose response +schema does NOT declare an Error arm (e.g. `get_products`, +`get_signals`, `tasks/get`) are untouched. No adopter code changes +required. + +Eighteen tools auto-wrap: `create_media_buy`, `update_media_buy`, +`provide_performance_feedback`, `build_creative`, `sync_audiences`, +`sync_catalogs`, `sync_event_sources`, `log_event`, `activate_signal`, +`sync_creatives`, `get_creative_features`, `validate_content_delivery`, +`list_content_standards`, `get_media_buy_artifacts`, +`get_content_standards`, `create_content_standards`, +`update_content_standards`, `calibrate_content`. The set is derived +dynamically — future AdCP minors that add Error-arm tools join +automatically. + +`update_content_standards` is the lone tool whose Error arm carries a +`success: false` discriminator alongside `errors[]`; the dispatcher +stamps the constant when synthesising so the payload satisfies its +`oneOf` discriminator. + +Migration recipe: `docs/migration-6.14-to-6.15.md`. RFC: +`docs/proposals/adcperror-two-layer-emission.md`. Closes #1606. diff --git a/docs/migration-6.14-to-6.15.md b/docs/migration-6.14-to-6.15.md new file mode 100644 index 000000000..57b8a84c9 --- /dev/null +++ b/docs/migration-6.14-to-6.15.md @@ -0,0 +1,86 @@ +# Migrating from `@adcp/sdk` 6.14 to 6.15 + +> **Status:** Minor release. The single behavior change is on the failure +> path of 18 AdCP tools and is **wire-additive only** — no adopter code +> changes are required, and the new wire shape is what the AdCP response +> schemas have always required. Adopter integration tests that snapshot +> error-response shape may need a one-line update; everything else +> continues to work unchanged. + +## Recipe 1: `adcpError()` now emits both wire layers automatically + +**TL;DR.** The framework dispatcher auto-wraps the payload-layer +`errors: [{code, message}, ...]` array alongside the existing +envelope-layer `{adcp_error: {code, message, ...}}` block on the failure +path of every tool whose response schema declares a typed Error arm. +Eighteen tools are affected: + +| Track | Tool | +| ----------------- | ------------------------------- | +| media-buy | `create_media_buy` | +| media-buy | `update_media_buy` | +| media-buy | `provide_performance_feedback` | +| media-buy | `build_creative` | +| event-tracking | `sync_audiences` | +| event-tracking | `sync_catalogs` | +| event-tracking | `sync_event_sources` | +| event-tracking | `log_event` | +| signals | `activate_signal` | +| creative | `sync_creatives` | +| creative | `get_creative_features` | +| content-standards | `validate_content_delivery` | +| content-standards | `list_content_standards` | +| content-standards | `get_media_buy_artifacts` | +| content-standards | `get_content_standards` | +| content-standards | `create_content_standards` | +| content-standards | `update_content_standards` | +| content-standards | `calibrate_content` | + +The set is derived dynamically from the bundled schema cache at server +build, so future AdCP minors that add Error-arm tools join automatically. + +### Why this changes + +The AdCP spec (`error-code.json#GOVERNANCE_DENIED`) requires both the +envelope marker and the typed Error arm on tasks whose response defines +an Error arm but no structured rejection arm. `adcpError()` emitted only +the envelope; `wrapErrorArm` emitted only the payload. Both paths now +ship the two-layer wire shape the spec has required since 3.0.6. + +### What you don't need to do + +- **Don't change call sites of `adcpError()`.** Keep calling it exactly + as before. The framework synthesises the payload-layer `errors[]` from + the same `{code, message, field, ...}` data on the way out. +- **Don't change handlers that return `{errors: [...]}` arms directly.** + The framework synthesises the envelope from the first item. +- **Don't manually emit both layers.** Adopters who already produce a + fully-formed two-layer response (envelope AND payload) pass through + unchanged — the dispatcher detects existing layers and does not + duplicate or overwrite them. This is the documented idempotency + policy: `adcp_error` and `errors[]` together are the canonical shape; + the framework only fills in whichever side is missing. + +### What might need attention + +If your integration tests assert on `structuredContent` shape for one of +the 18 tools above, expect a new top-level `errors[]` field on the +failure path. Update the snapshot or relax the assertion. Tools NOT in +the table (e.g. `get_products`, `get_signals`, `tasks/get`) are +untouched — their response schemas don't declare an Error arm, so the +framework leaves them alone. + +### One subtle case: `update_content_standards` + +`update_content_standards` is the only Error-arm tool whose response +schema discriminates Success vs Error via a `success: boolean` field +(rather than the presence of `errors[]`). The dispatcher stamps +`success: false` on the synthesised error response so the payload +satisfies the Error arm's `oneOf` discriminator. Adopters who already +emit `success: false` keep that value; adopters who emit `adcpError()` +or a typed Error arm get `success: false` synthesised automatically. + +### Where to read more + +- Issue: [`adcontextprotocol/adcp-client#1606`](https://github.com/adcontextprotocol/adcp-client/issues/1606) +- RFC: `docs/proposals/adcperror-two-layer-emission.md` diff --git a/src/lib/server/create-adcp-server.ts b/src/lib/server/create-adcp-server.ts index 66526cef9..632c42beb 100644 --- a/src/lib/server/create-adcp-server.ts +++ b/src/lib/server/create-adcp-server.ts @@ -41,6 +41,7 @@ import { parseAdcpMajorVersion, type AdcpVersion } from '../version'; import { resolveAdcpVersion } from '../utils/adcp-version-config'; import { resolveBundleKey } from '../validation/schema-loader'; import { bundleSupportsAdcpVersionField } from '../protocols'; +import { getToolsWithErrorArm, type ErrorArmDescriptor } from './error-arm-tools'; import type { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { ZodRawShapeCompat, AnySchema } from '@modelcontextprotocol/sdk/server/zod-compat.js'; import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'; @@ -2276,6 +2277,175 @@ function sanitizeAdcpErrorEnvelope(response: McpToolResponse): void { } } +/** + * Fields valid on a payload-layer `errors[]` item per the bundled + * `core/error.json` schema. Both layers of the AdCP error model carry + * the same conceptual fields, so the dispatcher projects from one to + * the other 1:1 — but the projection still needs to know which keys + * are part of the contract. + * + * `recovery` is included even though the schema marks it optional: it + * is the autonomous-buyer dispatch signal, and dropping it on the + * payload while keeping it on the envelope would force callers to read + * both layers to classify the failure. Mirroring matches the spec's + * intent (both layers carry the same data) without surprising adopters + * who rely on either side. + */ +const PAYLOAD_ERROR_FIELDS: ReadonlySet = new Set([ + 'code', + 'message', + 'recovery', + 'field', + 'suggestion', + 'retry_after', + 'issues', + 'details', +]); + +function projectEnvelopeToPayloadError(envelope: Record): Record { + const out: Record = {}; + for (const key of Object.keys(envelope)) { + if (PAYLOAD_ERROR_FIELDS.has(key)) out[key] = envelope[key]; + } + return out; +} + +function projectPayloadErrorToEnvelope(payloadError: Record): Record { + const out: Record = {}; + for (const key of Object.keys(payloadError)) { + if (PAYLOAD_ERROR_FIELDS.has(key)) out[key] = payloadError[key]; + } + return out; +} + +/** + * Two-layer error emission for tools whose response schema declares a + * typed Error arm (`errors[]` required) at the top level. + * + * The AdCP spec (RFC `docs/proposals/adcperror-two-layer-emission.md`, + * `error-code.json#GOVERNANCE_DENIED`) requires both layers on the + * failure path: + * + * - **Envelope layer**: `structuredContent.adcp_error: {code, message, ...}` + * — the cross-protocol marker, programmatic extraction. + * - **Payload layer**: `structuredContent.errors: [{code, message, ...}]` + * — the typed Error arm of the response union. + * + * `adcpError()` emits the envelope only; `wrapErrorArm()` emits the + * payload only. This dispatcher seam fills in whichever layer is + * missing so the wire is two-layer regardless of which helper the + * adopter used. Idempotent on already-two-layer payloads (presence of + * both `adcp_error` and `errors[]` is a no-op). + * + * Gated on `toolsWithErrorArm` derived from the bundled schema cache: + * tools whose schema doesn't define `errors[]` (`get_adcp_capabilities`, + * `tasks/get`, `get_products`, etc.) are left unchanged. + * + * Scope is the failure path only — early returns when neither layer is + * present. Success-arm responses pass through untouched. + */ +function enrichErrorTwoLayer( + response: McpToolResponse, + toolName: string, + toolsWithErrorArm: ReadonlyMap +): void { + const descriptor = toolsWithErrorArm.get(toolName); + if (!descriptor) return; + const sc = response.structuredContent as Record | undefined; + if (!sc || typeof sc !== 'object') return; + + const env = sc.adcp_error as Record | undefined; + const envValid = env != null && typeof env === 'object' && typeof env.code === 'string'; + const payloadList = sc.errors; + const payloadValid = Array.isArray(payloadList) && payloadList.length > 0; + + // Path A: envelope present, payload missing → synthesise payload-layer + // `errors[]` from the envelope. The `adcpError()` builder always lands + // here; hand-rolled `{adcp_error: {...}}` envelopes too. + if (envValid && !payloadValid) { + sc.errors = [projectEnvelopeToPayloadError(env)]; + applyArmDiscriminators(sc, descriptor); + syncContentJsonText(response, sc); + return; + } + + // Path B: payload present, envelope missing → synthesise envelope from + // the first payload item. `wrapErrorArm()` lands here when adopters + // return a typed Error arm directly. + if (!envValid && payloadValid) { + const first = (payloadList as unknown[])[0]; + if (first && typeof first === 'object') { + const projected = projectPayloadErrorToEnvelope(first as Record); + // Only stamp the envelope if the payload's first item carries the + // minimum spec-required `code` + `message`. A malformed Error arm + // (already warned about by the dispatcher's `isErrorArm` branch) + // shouldn't synthesise a half-formed envelope. + if (typeof projected.code === 'string' && typeof projected.message === 'string') { + sc.adcp_error = projected; + applyArmDiscriminators(sc, descriptor); + syncContentJsonText(response, sc); + } + } + return; + } + + // Path C: both layers present (handler emitted a fully-formed + // two-layer payload). Idempotent passthrough — adopters that already + // ship the correct shape are not mutated. Discriminator constants + // are NOT stamped here: an adopter that built both layers also chose + // their own discriminator value, and overriding it could break the + // arm they intended to land in. +} + +/** + * Stamp a tool's Error-arm discriminator constants on the response + * payload. For most tools the descriptor is empty (the arm requires + * `errors[]` only) and this is a no-op. `update_content_standards` is + * the canonical case: its Error arm declares `success: { const: false }` + * — without this stamp, the synthesised payload would still match the + * Success arm's `success: false` value (absent → undefined, neither + * `const: true` nor `const: false`), so the schema would reject the + * response on either branch. + * + * Only stamps fields that are NOT already set on the payload — adopters + * who deliberately set the discriminator (e.g. mid-migration) keep + * their choice. + */ +function applyArmDiscriminators(sc: Record, descriptor: ErrorArmDescriptor): void { + for (const [key, value] of Object.entries(descriptor.extraRequired)) { + if (!(key in sc)) sc[key] = value; + } +} + +/** + * Mirror a mutated `structuredContent` back into the L2 JSON text + * fallback so MCP clients reading either transport layer see the same + * shape. Same pattern as `sanitizeAdcpErrorEnvelope` and + * `injectContextIntoResponse`. Silent no-op when the L2 text isn't a + * JSON envelope (legitimate for non-JSON `content[0].text` summaries + * from `wrapErrorArm`). + */ +function syncContentJsonText(response: McpToolResponse, structuredContent: Record): void { + if (!Array.isArray(response.content)) return; + const first = response.content[0]; + if (!first || first.type !== 'text' || typeof first.text !== 'string') return; + let parsed: unknown; + try { + parsed = JSON.parse(first.text); + } catch { + // Not a JSON-bodied L2 fallback (e.g. wrapErrorArm's "CODE: message" + // summary). Leave it alone — the L3 structuredContent is the + // authoritative carrier; readers that fall back to L2 prose can + // still extract the code via pattern match. + return; + } + if (parsed == null || typeof parsed !== 'object') return; + const obj = parsed as Record; + if ('adcp_error' in structuredContent) obj.adcp_error = structuredContent.adcp_error; + if ('errors' in structuredContent) obj.errors = structuredContent.errors; + first.text = JSON.stringify(obj); +} + // Echo the request context into a formatted MCP tool response so buyers can // trace correlation_id across both success and error responses. Only plain // objects are echoed: `si_get_offering` and `si_initiate_session` override @@ -2634,6 +2804,15 @@ export function createAdcpServer(config: AdcpServerConfig(config: AdcpServerConfig { sanitizeAdcpErrorEnvelope(response); + // Two-layer error emission: when the tool's response schema + // declares an Error arm (`errors: [...]` required), mirror + // `adcp_error` ↔ `errors[]` so both spec-mandated layers are + // present on the wire. Order: AFTER sanitize so we project + // the allowlist-filtered envelope; BEFORE context/version + // injection so those run on the final two-layer payload. + enrichErrorTwoLayer(response, toolName, toolsWithErrorArm); injectContextIntoResponse(response, params.context); injectVersionIntoResponse(response, servedAdcpVersion); return response; diff --git a/src/lib/server/error-arm-tools.ts b/src/lib/server/error-arm-tools.ts new file mode 100644 index 000000000..90deb91a4 --- /dev/null +++ b/src/lib/server/error-arm-tools.ts @@ -0,0 +1,231 @@ +/** + * Per-AdCP-version registry of tool names whose response schema declares + * a typed Error arm — i.e. an arm of the top-level `oneOf` / `anyOf` + * whose `required` includes `"errors"`. + * + * The dispatcher uses this set to decide whether to auto-emit the + * payload-layer `errors[]` array alongside the envelope-layer + * `{adcp_error}` block on the failure path. Tools NOT in this set keep + * the envelope-only shape — surfacing `errors[]` on a tool whose + * response schema doesn't define it would add a key the schema + * doesn't expect. + * + * Built lazily at first use from the bundled schema cache (the same + * tree `schema-loader.ts` reads). The set is computed once per bundle + * key (`resolveBundleKey('3.0.0')` → `'3.0'`) and memoised — the schema + * tree is read-only at runtime, so a single scan is sufficient. + * + * Spec basis: `error-code.json#GOVERNANCE_DENIED` and + * `#GOVERNANCE_UNAVAILABLE` both prescribe "populate `errors[].code` in + * the payload AND `adcp_error.code` on the envelope per the two-layer + * model" for tasks whose response defines a typed Error arm but no + * structured rejection arm. RFC: `docs/proposals/adcperror-two-layer-emission.md`. + */ + +import { readdirSync, readFileSync, existsSync } from 'fs'; +import path from 'path'; +import { ADCP_VERSION } from '../version'; +import { resolveBundleKey } from '../validation/schema-loader'; + +/** + * Resolve the schema-cache root for a given AdCP version. + * + * Mirrors `schema-loader.ts:resolveSchemaRoot` so the two-layer scan + * reads from the same directory the AJV validators compile against. + * Kept as a private duplicate (rather than exporting from schema-loader) + * to keep the schema-loader's public surface narrow — that module is + * about validators, not generic cache traversal. + * + * Returns `undefined` when no bundle exists for the version. The + * dispatcher gates on a non-empty set, so a missing bundle silently + * disables the auto-wrap (callers without schemas can't have tools to + * wrap anyway). + */ +function resolveBundledRoot(version: string): string | undefined { + const key = resolveBundleKey(version); + // Built layout (dist): dist/lib/schemas-data//bundled + const distCandidate = path.join(__dirname, '..', 'schemas-data', key, 'bundled'); + if (existsSync(distCandidate)) return distCandidate; + + // Source-tree layout (dev): schemas/cache//bundled + const cacheRoot = path.join(__dirname, '..', '..', '..', 'schemas', 'cache'); + const exactCandidate = path.join(cacheRoot, version, 'bundled'); + if (existsSync(exactCandidate)) return exactCandidate; + + // Latest-patch fallback for stable minor pins (matches schema-loader). + const minorMatch = key.match(/^(\d+)\.(\d+)$/); + if (minorMatch && existsSync(cacheRoot)) { + const [, major, minor] = minorMatch; + const prefix = `${major}.${minor}.`; + const cached = readdirSync(cacheRoot, { withFileTypes: true }) + .filter( + e => + e.isDirectory() && + e.name.startsWith(prefix) && + !e.name.endsWith('.previous') && + /^\d+\.\d+\.\d+$/.test(e.name) + ) + .map(e => ({ name: e.name, patch: parseInt(e.name.slice(prefix.length), 10) })) + .filter(c => Number.isFinite(c.patch)) + .sort((a, b) => b.patch - a.patch); + if (cached.length > 0) { + return path.join(cacheRoot, cached[0]!.name, 'bundled'); + } + } + return undefined; +} + +/** + * Per-tool descriptor for two-layer auto-emission. + * + * `extraRequired` carries any `oneOf`-discriminator constants the error + * arm requires beyond `errors[]` itself — e.g. `update_content_standards` + * declares `required: ["success", "errors"]` with `success: { const: false }`. + * The dispatcher applies these as constants when synthesising the error + * arm so the resulting payload satisfies its own response schema. + * + * The vast majority of Error-arm tools have `required: ["errors"]` only + * (no discriminator), so `extraRequired` is empty and the wrap is a + * trivial `{errors: [...]}` projection. + */ +export interface ErrorArmDescriptor { + /** `{[fieldName]: constValue}` for each `const`-typed required field. */ + extraRequired: Readonly>; +} + +/** + * Resolve a `oneOf`/`anyOf` arm one level of `$ref` indirection. Bundled + * schemas inline most refs but a few domains keep an extra hop into + * `$defs`/`definitions`. + */ +function resolveBranch(branch: unknown, rootSchema: Record): Record | undefined { + if (!branch || typeof branch !== 'object') return undefined; + const obj = branch as Record; + const ref = obj.$ref; + if (typeof ref === 'string') { + const match = ref.match(/^#\/(\$defs|definitions)\/(.+)$/); + if (match && match[1] && match[2]) { + const root = rootSchema[match[1]] as Record | undefined; + const target = root?.[match[2]]; + if (target && typeof target === 'object') return target as Record; + } + return undefined; + } + return obj; +} + +/** + * Inspect a branch and, if it declares `required: [..., "errors", ...]`, + * return its descriptor (capturing any sibling `const`-typed required + * fields). Returns undefined for branches that don't require `errors[]` + * or for rejection arms (`AcquireRightsRejected` / `CreativeRejected`) + * which declare `not: { required: ["errors"] }` and explicitly forbid + * the two-layer wire (RFC § 1.2). + */ +function describeErrorArm(branch: unknown, rootSchema: Record): ErrorArmDescriptor | undefined { + const resolved = resolveBranch(branch, rootSchema); + if (!resolved) return undefined; + const required = resolved.required; + if (!Array.isArray(required) || !required.includes('errors')) return undefined; + + const extraRequired: Record = {}; + const properties = resolved.properties as Record | undefined; + if (properties) { + for (const fieldName of required) { + if (typeof fieldName !== 'string' || fieldName === 'errors') continue; + const propSchema = properties[fieldName] as Record | undefined; + if (propSchema && 'const' in propSchema) { + extraRequired[fieldName] = propSchema.const; + } + // Required fields without a `const` discriminator (e.g. dynamic + // values) can't be auto-synthesised. The spec convention for + // Error arms is `errors[]` plus optional `success`/`response_type` + // constants — so far no tool requires anything else. + } + } + return { extraRequired: Object.freeze(extraRequired) }; +} + +/** + * Read every `*-response.json` under the bundled cache root and return + * a tool-name → descriptor map for tools whose top-level `oneOf`/`anyOf` + * declares an Error arm. + * + * Tool names are derived from the filename stem (`create-media-buy` → + * `create_media_buy`) to match the dispatcher's `toolName` key. + */ +function scanForErrorArmTools(bundledRoot: string): Map { + const map = new Map(); + const files: string[] = []; + function walk(dir: string): void { + if (!existsSync(dir)) return; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + } else if (entry.isFile() && entry.name.endsWith('-response.json')) { + files.push(full); + } + } + } + walk(bundledRoot); + + for (const file of files) { + let schema: Record; + try { + schema = JSON.parse(readFileSync(file, 'utf-8')) as Record; + } catch { + continue; + } + const branches = (schema.oneOf ?? schema.anyOf) as unknown[] | undefined; + if (!Array.isArray(branches)) continue; + let descriptor: ErrorArmDescriptor | undefined; + for (const branch of branches) { + const candidate = describeErrorArm(branch, schema); + if (candidate) { + descriptor = candidate; + break; + } + } + if (!descriptor) continue; + const base = path.basename(file, '-response.json'); + const toolName = base.replace(/-/g, '_'); + map.set(toolName, descriptor); + } + return map; +} + +/** Memoised per-bundle-key result. Empty map when the bundle is missing. */ +const cachedMaps = new Map>(); + +/** + * Per-tool descriptors for tools whose response schema (at `version`) + * declares a top-level Error arm requiring `errors[]`. Dispatcher-side + * gate for two-layer auto-emission, plus the per-arm constants the + * dispatcher needs to set when synthesising the wrap. + * + * Scans the bundled schema cache lazily on first call per bundle key + * (one read per minor version per process). Returns a frozen, empty map + * when no schema bundle is reachable — the dispatcher early-returns on + * `map.size === 0`, so a missing bundle silently disables the wrap + * rather than breaking server startup. + */ +export function getToolsWithErrorArm(version: string = ADCP_VERSION): ReadonlyMap { + const key = resolveBundleKey(version); + const cached = cachedMaps.get(key); + if (cached) return cached; + const root = resolveBundledRoot(version); + if (!root) { + const empty: ReadonlyMap = new Map(); + cachedMaps.set(key, empty); + return empty; + } + const map = scanForErrorArmTools(root); + cachedMaps.set(key, map); + return map; +} + +/** Test hook: clear the per-bundle-key cache. */ +export function _resetErrorArmToolsCache(): void { + cachedMaps.clear(); +} diff --git a/src/lib/server/errors.ts b/src/lib/server/errors.ts index afc927a73..aa59140b3 100644 --- a/src/lib/server/errors.ts +++ b/src/lib/server/errors.ts @@ -113,6 +113,16 @@ export interface AdcpErrorResponse { * the envelope can't become a stolen-key read oracle. Codes without a * registered allowlist pass through unchanged. * + * **Two-layer wire shape.** This builder emits the envelope layer + * (`structuredContent.adcp_error`) only. For tools whose response + * schema declares a typed Error arm (`errors[]` required at the top + * level), the framework dispatcher synthesises the payload-layer + * `errors[]` from the same data at finalize time, so the wire carries + * both the envelope marker and the typed Error arm together — no + * adopter code change required. The list of affected tools is derived + * at server build from the bundled schema cache. RFC: + * `docs/proposals/adcperror-two-layer-emission.md`. + * * @example * ```typescript * import { adcpError } from '@adcp/sdk'; diff --git a/test/server-error-arm-two-layer.test.js b/test/server-error-arm-two-layer.test.js new file mode 100644 index 000000000..bba107c74 --- /dev/null +++ b/test/server-error-arm-two-layer.test.js @@ -0,0 +1,281 @@ +// Schema-driven regression test for issue #1606 / RFC +// docs/proposals/adcperror-two-layer-emission.md. +// +// For every AdCP tool whose response schema declares a top-level Error +// arm (`required: ["errors"]`), assert that the SDK's failure path +// emits a response satisfying the tool's response schema. Both error +// paths are covered: +// +// 1. Adopter calls `adcpError(code, options)` — emits envelope only; +// framework auto-wraps `errors[]` from the same data. +// 2. Adopter returns `{errors: [...]}` arm directly — emits payload +// only; framework auto-wraps `adcp_error` from the first item. +// +// The set of tools is derived dynamically from the bundled schema cache +// so adding a new Error-arm tool in a future AdCP version automatically +// extends coverage. + +const { describe, it } = require('node:test'); +const assert = require('node:assert'); + +const { createAdcpServer: _createAdcpServer } = require('../dist/lib/server/create-adcp-server'); +const { adcpError } = require('../dist/lib/server/errors'); +const { getToolsWithErrorArm } = require('../dist/lib/server/error-arm-tools'); +const { getValidator } = require('../dist/lib/validation/schema-loader'); + +// Disable schema validation in the dispatcher itself so the test only +// asserts wire-shape-correctness of the dispatcher's emitted response, +// not request-side gating that some tools have stricter shapes for. +function createAdcpServer(config) { + return _createAdcpServer({ + ...config, + validation: { requests: 'off', responses: 'off', ...(config?.validation ?? {}) }, + }); +} + +async function callToolRaw(server, toolName, params) { + return server.dispatchTestRequest({ + method: 'tools/call', + params: { name: toolName, arguments: params ?? {} }, + }); +} + +// Maps tool name → (handlerKey, domainKey, requestArgs). The dispatcher +// routes handlers via `domainKey.handlerKey` keyed off the tool name. +// Request args are the minimum shape that won't trip request-side +// schema validation when it's enabled — for this test it stays off, so +// `{}` is sufficient for most tools, with a few that need an `account` +// to satisfy the framework's account-resolution gate. +const TOOL_REGISTRATION = { + create_media_buy: { + domain: 'mediaBuy', + key: 'createMediaBuy', + args: { + account: { account_id: 'a1' }, + brand: { brand_id: 'b1' }, + start_time: '2026-01-01T00:00:00Z', + end_time: '2026-02-01T00:00:00Z', + }, + }, + update_media_buy: { domain: 'mediaBuy', key: 'updateMediaBuy', args: { media_buy_id: 'mb_1' } }, + sync_creatives: { + domain: 'creative', + key: 'syncCreatives', + args: { account: { account_id: 'a1' }, creatives: [], idempotency_key: '11111111-1111-1111-1111-111111111111' }, + }, + build_creative: { domain: 'creative', key: 'buildCreative', args: {} }, + provide_performance_feedback: { domain: 'mediaBuy', key: 'providePerformanceFeedback', args: {} }, + sync_event_sources: { domain: 'eventTracking', key: 'syncEventSources', args: {} }, + log_event: { domain: 'eventTracking', key: 'logEvent', args: {} }, + sync_audiences: { domain: 'eventTracking', key: 'syncAudiences', args: {} }, + sync_catalogs: { domain: 'eventTracking', key: 'syncCatalogs', args: {} }, + activate_signal: { domain: 'signals', key: 'activateSignal', args: {} }, + list_content_standards: { domain: 'governance', key: 'listContentStandards', args: {} }, + get_content_standards: { domain: 'governance', key: 'getContentStandards', args: {} }, + create_content_standards: { domain: 'governance', key: 'createContentStandards', args: {} }, + update_content_standards: { domain: 'governance', key: 'updateContentStandards', args: {} }, + calibrate_content: { domain: 'governance', key: 'calibrateContent', args: {} }, + validate_content_delivery: { domain: 'governance', key: 'validateContentDelivery', args: {} }, + get_media_buy_artifacts: { domain: 'governance', key: 'getMediaBuyArtifacts', args: {} }, + get_creative_features: { domain: 'governance', key: 'getCreativeFeatures', args: {} }, +}; + +const TOOLS = [...getToolsWithErrorArm().keys()].sort(); + +describe('two-layer error emission (RFC #1608, issue #1606)', () => { + it('schema audit: 18 tools have a top-level Error arm', () => { + // Lock the count — drift here means a new AdCP minor changed which + // tools require two-layer emission. Update this assertion AND the + // RFC's affected-tools list together. + assert.strictEqual(TOOLS.length, 18, `expected 18 Error-arm tools, got ${TOOLS.length}: ${TOOLS.join(', ')}`); + }); + + it('coverage: every Error-arm tool has a registration entry in this test', () => { + // The registration table must enumerate every Error-arm tool — + // otherwise a new tool ships without a regression test. + const missing = TOOLS.filter(t => !TOOL_REGISTRATION[t]); + assert.deepStrictEqual(missing, [], `add registration for: ${missing.join(', ')}`); + }); + + describe('Path A: handler calls adcpError() — framework synthesises payload errors[]', () => { + for (const toolName of TOOLS) { + const reg = TOOL_REGISTRATION[toolName]; + if (!reg) continue; + it(`${toolName} emits both adcp_error envelope and errors[] payload`, async () => { + const server = createAdcpServer({ + name: 'Test', + version: '1.0.0', + [reg.domain]: { + [reg.key]: async () => + adcpError('VALIDATION_ERROR', { + message: `${toolName}: synthetic error for two-layer test`, + field: 'synthetic_field', + }), + }, + }); + const result = await callToolRaw(server, toolName, reg.args); + + assert.strictEqual(result.isError, true, 'isError flag set'); + + // Envelope layer present. + const sc = result.structuredContent; + assert.ok(sc, 'structuredContent present'); + assert.ok(sc.adcp_error, 'adcp_error envelope present'); + assert.strictEqual(sc.adcp_error.code, 'VALIDATION_ERROR'); + + // Payload layer auto-synthesised from the envelope. + assert.ok(Array.isArray(sc.errors), 'errors[] payload synthesised'); + assert.strictEqual(sc.errors.length, 1); + assert.strictEqual(sc.errors[0].code, 'VALIDATION_ERROR'); + assert.strictEqual(sc.errors[0].message, sc.adcp_error.message); + assert.strictEqual(sc.errors[0].field, sc.adcp_error.field); + + // Schema validates against the bundled response schema. + const validate = getValidator(toolName, 'sync'); + assert.ok(validate, `validator exists for ${toolName}`); + const valid = validate(sc); + assert.ok(valid, `${toolName} response failed schema validation: ${JSON.stringify(validate.errors, null, 2)}`); + + // L2 text fallback mirrors structuredContent (JSON envelope from + // adcpError() — wrapErrorArm uses prose, covered separately). + const parsed = JSON.parse(result.content[0].text); + assert.deepStrictEqual(parsed.adcp_error, sc.adcp_error); + assert.deepStrictEqual(parsed.errors, sc.errors); + }); + } + }); + + describe('Path B: handler returns {errors:[...]} arm — framework synthesises adcp_error envelope', () => { + for (const toolName of TOOLS) { + const reg = TOOL_REGISTRATION[toolName]; + if (!reg) continue; + it(`${toolName} synthesises adcp_error envelope from typed Error arm`, async () => { + const server = createAdcpServer({ + name: 'Test', + version: '1.0.0', + [reg.domain]: { + [reg.key]: async () => ({ + errors: [ + { + code: 'PRODUCT_NOT_FOUND', + message: `${toolName}: synthetic error for two-layer test`, + field: 'synthetic_field', + }, + ], + }), + }, + }); + const result = await callToolRaw(server, toolName, reg.args); + + assert.strictEqual(result.isError, true); + + const sc = result.structuredContent; + assert.ok(Array.isArray(sc.errors)); + assert.strictEqual(sc.errors[0].code, 'PRODUCT_NOT_FOUND'); + + // Envelope auto-synthesised from the first payload item. + assert.ok(sc.adcp_error, 'adcp_error envelope synthesised'); + assert.strictEqual(sc.adcp_error.code, 'PRODUCT_NOT_FOUND'); + assert.strictEqual(sc.adcp_error.message, sc.errors[0].message); + assert.strictEqual(sc.adcp_error.field, sc.errors[0].field); + + // Schema validates against the bundled response schema. + const validate = getValidator(toolName, 'sync'); + assert.ok(validate); + const valid = validate(sc); + assert.ok(valid, `${toolName} response failed schema validation: ${JSON.stringify(validate.errors, null, 2)}`); + }); + } + }); + + describe('idempotency: handlers that already emit both layers pass through unchanged', () => { + it('does not duplicate or replace pre-emitted errors[]', async () => { + const server = createAdcpServer({ + name: 'Test', + version: '1.0.0', + mediaBuy: { + createMediaBuy: async () => ({ + isError: true, + content: [ + { + type: 'text', + text: JSON.stringify({ + adcp_error: { code: 'CUSTOM_CODE', message: 'pre-emitted by adopter' }, + errors: [{ code: 'CUSTOM_CODE', message: 'pre-emitted by adopter', recovery: 'transient' }], + }), + }, + ], + structuredContent: { + adcp_error: { code: 'CUSTOM_CODE', message: 'pre-emitted by adopter' }, + errors: [{ code: 'CUSTOM_CODE', message: 'pre-emitted by adopter', recovery: 'transient' }], + }, + }), + }, + }); + const result = await callToolRaw(server, 'create_media_buy', TOOL_REGISTRATION.create_media_buy.args); + const sc = result.structuredContent; + assert.strictEqual(sc.errors.length, 1, 'errors[] not duplicated'); + assert.strictEqual(sc.errors[0].recovery, 'transient', 'adopter recovery preserved'); + assert.strictEqual(sc.adcp_error.code, 'CUSTOM_CODE'); + }); + }); + + describe('non-Error-arm tools are not wrapped', () => { + it('get_products error response stays envelope-only (no spurious errors[])', async () => { + const server = createAdcpServer({ + name: 'Test', + version: '1.0.0', + mediaBuy: { + getProducts: async () => adcpError('RATE_LIMITED', { message: 'slow down', retry_after: 30 }), + }, + }); + const result = await callToolRaw(server, 'get_products', { brief: 'test', buying_mode: 'brief' }); + const sc = result.structuredContent; + assert.ok(sc.adcp_error, 'envelope present'); + assert.strictEqual(sc.adcp_error.code, 'RATE_LIMITED'); + assert.ok(!('errors' in sc), 'errors[] NOT synthesised on non-Error-arm tool'); + }); + + it('get_signals error response stays envelope-only', async () => { + const server = createAdcpServer({ + name: 'Test', + version: '1.0.0', + signals: { + getSignals: async () => adcpError('PERMISSION_DENIED', { message: 'no access' }), + }, + }); + const result = await callToolRaw(server, 'get_signals', {}); + const sc = result.structuredContent; + assert.ok(sc.adcp_error); + assert.ok(!('errors' in sc), 'errors[] NOT synthesised on get_signals'); + }); + }); + + describe('comply_test_controller is NOT in the wrap set', () => { + // The test controller is framework-internal and not part of the AdCP + // schema cache, so the gate's schema-derivation never picks it up. + // Locking this in case someone bundles a synthetic schema for it. + it('comply_test_controller is not in TOOLS_WITH_ERROR_ARM', () => { + const map = getToolsWithErrorArm(); + assert.ok(!map.has('comply_test_controller')); + assert.ok(!map.has('comply_test_controller_simulate')); + }); + }); + + describe('success path is untouched on Error-arm tools', () => { + it('successful create_media_buy response carries no adcp_error/errors layers', async () => { + const server = createAdcpServer({ + name: 'Test', + version: '1.0.0', + mediaBuy: { + createMediaBuy: async () => ({ media_buy_id: 'mb_1', packages: [] }), + }, + }); + const result = await callToolRaw(server, 'create_media_buy', TOOL_REGISTRATION.create_media_buy.args); + const sc = result.structuredContent; + assert.notStrictEqual(result.isError, true); + assert.strictEqual(sc.adcp_error, undefined); + assert.strictEqual(sc.errors, undefined); + }); + }); +});