Skip to content

fix: never strip canonical AdCP request fields for partial-schema agents#2271

Draft
EmmaLouise2018 wants to merge 1 commit into
mainfrom
EmmaLouise2018/adcp-preserve-canonical-fields
Draft

fix: never strip canonical AdCP request fields for partial-schema agents#2271
EmmaLouise2018 wants to merge 1 commit into
mainfrom
EmmaLouise2018/adcp-preserve-canonical-fields

Conversation

@EmmaLouise2018

Copy link
Copy Markdown
Contributor

Production symptom

The 3rd-party AdCP sales agent Open Ads (https://api.openads.ai/mcp) advertises a partial tool inputSchema via tools/list. Production stderr (last 3 days, agent "33"/Open Ads) showed the SDK stripping canonical request fields before the request ever left the client:

[AdCP] Stripping fields not declared in agent "..." schema for update_media_buy: creative_ids
[AdCP] Stripping fields not declared in agent "..." schema for sync_creatives: media_buy_id
[AdCP] Stripping fields not declared in agent "..." schema for get_media_buy_delivery: media_buy_id

This silently broke media-buy updates and delivery polling against any seller that under-declares its schema — including dropping the canonical-required media_buy_id on update_media_buy.

Root cause

SingleAgentClient.adaptRequest (the strip loop around src/lib/core/SingleAgentClient.ts:2154) kept a top-level field only when it was declaredFields.has(key) || envelopeFields.has(key) — i.e. it intersected the outgoing request with only the agent's self-declared (partial) schema plus ADCP_ENVELOPE_FIELDS. The strip fails open only when the cached schema has zero properties; a non-empty but partial schema fails closed, dropping legitimate canonical protocol fields the agent simply forgot to declare.

The fix

Union the keep-condition with the canonical AdCP request schema for the task, via the existing schemaAllowsTopLevelField helper in src/lib/validation/schema-loader.ts:

if (
  declaredFields.has(key) ||
  envelopeFields.has(key) ||
  schemaAllowsTopLevelField(taskType, key, this.resolvedAdcpVersion)
) {
  filtered[key] = value;
} else {
  schemaStripped.push(key);
}

A field is now preserved when it is declared by the agent schema, OR is a protocol envelope field, OR is a canonical top-level field for that task in the resolved AdCP version. Only fields unknown to both the agent schema and the canonical request schema are stripped (genuine junk / unknown vendor extensions).

Why it's safe

  • Fail-open preserves. schemaAllowsTopLevelField returns true when no canonical schema is indexed for the tool (custom/unsynced tools), so the change is preserve-on-doubt — it never strips more than before.
  • Junk still stripped. A field that is neither agent-declared nor canonical (e.g. totally_made_up_field) is still dropped and still logged via console.warn + the input_schema_field_stripped debug log.
  • Envelope unaffected. ADCP_ENVELOPE_FIELDS membership is still honored.
  • Mapping verified. taskType is the snake_case tool name (update_media_buy, get_media_buy_delivery, sync_creatives) — exactly the toolName key the loader looks up as ${toolName}::request in its fileIndex (same names used by existing schemaAllowsTopLevelField('sync_creatives', ...) callers and by TaskExecutor.validateRequest). The version arg is the raw this.resolvedAdcpVersion pin; the loader resolves the bundle key internally via ensureInit/resolveBundleKey, matching TaskExecutor.validateRequest(taskName, params, ..., this.config.adcpVersion). A runtime probe confirmed: for 3.1.0, media_buy_id/media_buy_ids/creative_idstrue (preserved); totally_made_up_fieldfalse (stripped). A wrong mapping would have made the helper return true for everything (fail-open no-op); it does not.

Tests

test/lib/request-validation.test.js:

  • Updated the existing partial-schema get_products test: brand is canonical for get_products, so it is now preserved (the old test encoded the buggy strip); a genuinely-unknown totally_made_up_field is the one asserted stripped.
  • Added Open Ads regression cases for update_media_buy (canonical media_buy_id preserved), get_media_buy_delivery (canonical media_buy_ids preserved), and sync_creatives (canonical creative_ids preserved) — each against an agent whose cachedToolSchemas declares only a subset, asserting the canonical field survives adaptRequest while totally_made_up_field is still stripped.

Verification: npm run typecheck clean; npm run build:lib clean; targeted file 39/39 pass; full fast lib suite 9628 pass / 0 fail / 7 skipped; prettier --check clean on changed files. Changeset added (@adcp/sdk: patch).

Partial-schema sales agents (e.g. Open Ads, https://api.openads.ai/mcp)
advertise an incomplete tool inputSchema via tools/list. adaptRequest
intersected outgoing top-level fields with ONLY the agent's self-declared
schema (plus ADCP_ENVELOPE_FIELDS), so canonical — sometimes required —
AdCP request fields were stripped before the request left the client:
update_media_buy.media_buy_id, get_media_buy_delivery.media_buy_ids, and
sync_creatives.creative_ids. This silently broke media-buy updates and
delivery polling against under-declaring sellers.

Union the keep-condition with the canonical request schema via
schemaAllowsTopLevelField(taskType, key, resolvedAdcpVersion): a field is
kept when declared by the agent schema, present in the protocol envelope,
OR canonical for the task in the resolved AdCP version. Only fields unknown
to BOTH schemas are stripped. The helper fails open when no canonical schema
is indexed, so behavior is preserve-on-doubt and genuine junk is still
dropped.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant