Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .changeset/two-layer-error-emission-1606.md
Original file line number Diff line number Diff line change
@@ -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.
86 changes: 86 additions & 0 deletions docs/migration-6.14-to-6.15.md
Original file line number Diff line number Diff line change
@@ -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`
186 changes: 186 additions & 0 deletions src/lib/server/create-adcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string> = new Set([
'code',
'message',
'recovery',
'field',
'suggestion',
'retry_after',
'issues',
'details',
]);

function projectEnvelopeToPayloadError(envelope: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const key of Object.keys(envelope)) {
if (PAYLOAD_ERROR_FIELDS.has(key)) out[key] = envelope[key];
}
return out;
}

function projectPayloadErrorToEnvelope(payloadError: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
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<string, ErrorArmDescriptor>
): void {
const descriptor = toolsWithErrorArm.get(toolName);
if (!descriptor) return;
const sc = response.structuredContent as Record<string, unknown> | undefined;
if (!sc || typeof sc !== 'object') return;

const env = sc.adcp_error as Record<string, unknown> | 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<string, unknown>);
// 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<string, unknown>, 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<string, unknown>): 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<string, unknown>;
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
Expand Down Expand Up @@ -2634,6 +2804,15 @@ export function createAdcpServer<TAccount = unknown>(config: AdcpServerConfig<TA
return bundleSupportsAdcpVersionField(bundleKey) ? bundleKey : undefined;
})();

// Tool-name set for two-layer error emission. Computed once at server
// build from the bundled response schemas: any tool whose top-level
// `oneOf`/`anyOf` declares an arm with `required: ["errors"]` joins
// the set, and the dispatcher mirrors `errors[]` ↔ `adcp_error` on
// the failure path so both spec-mandated layers ride on every
// failing response. Tools without an Error arm are untouched.
// RFC: docs/proposals/adcperror-two-layer-emission.md.
const toolsWithErrorArm = getToolsWithErrorArm(adcpVersion);

// Defaults gated on `process.env.NODE_ENV`:
// - Production → both sides `'off'` (zero AJV overhead; trust the
// handler after its test suite has exercised it).
Expand Down Expand Up @@ -3034,6 +3213,13 @@ export function createAdcpServer<TAccount = unknown>(config: AdcpServerConfig<TA
// { adcp_error: ... } }` would otherwise ship unfiltered.
const finalize = (response: McpToolResponse): McpToolResponse => {
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;
Expand Down
Loading
Loading