Skip to content

fix(server): declared-input tools echo context on validation errors (#3666) - #3670

Merged
bokelley merged 6 commits into
mainfrom
fix/3666-custom-tools-context-echo
Nov 9, 2026
Merged

bokelley merged 6 commits into
mainfrom
fix/3666-custom-tools-context-echo

Conversation

@bokelley

@bokelley bokelley commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #3666. Unblocks the known-gap marker in adcontextprotocol/adcp#8834. Follow-up on error codes: #3674.

Safety-critical. This PR touches identity33 authorization ordering on every custom-tool dispatch path. A review found a P1 spend-authorization bypass in an earlier revision (721270ae9), and 96e64ec20 fixes it (details below). a3febfc99 resolves comply visibility once per valid call, as before this PR. Per docs/development/REVIEW-STACKS.md, it needs a Claude security review plus the Codex pass before merge.

Problem

customTools (and so tasks_get on createAdcpServerFromPlatform) and comply_test_controller registered their zod input schemas with the MCP SDK. The SDK checked the arguments before any AdCP code ran and answered a failure with a bare -32602 text result: no structuredContent, no adcp_error, no context. That breaks the context-sessions rule that failures echo context. Framework tools avoid this by registering a passthrough schema and validating at the AdCP layer.

Fix

New internal module src/lib/server/declared-input-schema.ts. It is used at every zod-declared registration site:

  • customTools in createAdcpServer, which covers tasks_get
  • registerTestController
  • createComplyController().register()
  • the complyTest registration in createAdcpServerFromPlatform

How it works:

  1. Register. The tool is registered with the declared schema, so the SDK normalizes it and still throws on a malformed schema. validateToolInputAtAdcpLayer then records the normalized schema and swaps the registered one to the framework's PASSTHROUGH_INPUT_SCHEMA. That constant moved into this module and is reused, not duplicated.
  2. Parse exactly once. parseDeclaredToolInput uses the same normalizeObjectSchema + safeParseAsync as the SDK's validateToolInput. A valid call therefore produces exactly the value the handler got before: coerced, with defaults and transforms applied and undeclared keys stripped.
  3. Decide on the parsed value only. identity33 auxiliary authorization, release selection (adcp_version, adcp_major_version) and the handler all read that one parsed value. Nothing reads the raw arguments after the parse, except to capture context for echo.
  4. Reject with an AdCP error. A failed parse returns adcp_error INVALID_REQUEST, built with buildAdcpValidationErrorPayload (the framework's payload builder). It carries field (the first failing path), issues and details.issues, and the finish step adds the caller's context, status: 'failed' and the served adcp_version.
  5. Every rejection echoes context, even when the schema strips it. That covers validation, VERSION_UNSUPPORTED (bad version, or the tool isn't in the release), identity, comply_test_controller's account rejections, and failures a handler returns or throws.

I used INVALID_REQUEST, which the spec defines as "missing required fields, or violates schema constraints". Framework tools emit VALIDATION_ERROR for the same class of failure. Both codes are in the spec enum, and the spec reserves VALIDATION_ERROR for failures "beyond schema validation". #3674 recommends converging framework tools; this PR doesn't change them.

The P1 found in review (fixed in 96e64ec20)

At 721270ae9, custom tools ran identity33 authorization and release selection on the raw arguments, then parsed. A declared default or transform could therefore change the request after it was authorized:

  • refine: z.object({ action: z.literal('finalize').default('finalize') }) called with { refine: {} }
  • action: z.string().trim().pipe(z.literal('finalize')) called with 'finalize '

In both cases the call was authorized as read-only and then finalized. Before this PR, and now, both return PERMISSION_DENIED. Both are regression tests running over a real MCP Client, with a real Web Bot Auth signer, createIdentity33Authorization, and an identity-established principal that isn't onboarded. I confirmed both tests fail when the compiled call is reverted to raw args.

The same restructure fixes a P2: release selection had moved ahead of defaults and coercion. Release selection now matches pre-PR MCP behavior:

  • A declared adcp_version: z.string().default('3.1.31') selects 3.1.
  • z.coerce.string() called with 3.1 selects 3.1.
  • An adcp_version the schema doesn't declare is stripped, so the default release applies.

Every read of the arguments before the parse

Path Read before the parse Behavior
Legacy MCP tools/call (all declared tools) SDK validateToolInput against the passthrough schema Shape only: accepts any object, and enforces the SDK's maxToolInputElements size cap. No decision on content.
Legacy MCP, outer createAdcpServer wrappers Web Bot Auth failure check; identity33 schema scope; structured-content fallback authInfo, request scope or response only. No arguments.
Request signing (RFC 9421) Raw body bytes Integrity of the raw request, as before. It must bind the raw bytes.
customTools / tasks_get handler context captured for echo Never feeds a decision. On a parse failure, the release that labels the INVALID_REQUEST is read from the raw arguments (no parsed value exists, and nothing runs).
AdcpServer.invoke() (A2A, the 2026-07-28 adapter) Web Bot Auth failure check authInfo only. It then calls the same handler, which parses.
A2A adapter skill name, the requirement that input is an object, signature-gate operation name, task-continuation operation Routing on names and shape. The content of input isn't read.
2026-07-28 adapter v2 SDK validation against the passthrough schema; isRegisteredToolVisible(toolName, authInfo) Shape only; visibility has no input.
dispatchTestRequest() None Calls the handler, which parses.
registerTestController None The controller receives the parsed value.
createComplyController().register() None sandboxGate and the controller receive the parsed value.
createAdcpServerFromPlatform({ complyTest }) The existing tools/call visibility wrapper, on raw arguments Deny-only (JSON-RPC MethodNotFound). The handler parses first. On a valid call, the principal authority and visibility are resolved once, on the parsed value, and account resolution, the sandbox gate and the controller all use that value. When parsing fails, a deny-only visibility check on the raw arguments runs before the INVALID_REQUEST is returned, so a hidden controller never reveals its schema.

Comply echo cap

The platform comply_test_controller always capped its PERMISSION_DENIED echo of context and ext at 8 KiB serialized. Its other rejections, including the new parse-failure INVALID_REQUEST, now share that cap: cappedComplyEcho in from-platform.ts, applied through the new echoContext option on parseDeclaredToolInput. Custom tools echo uncapped, as framework tools do.

A2A version selection

A2A has no AdCP version negotiation at the envelope or transport level. A2A-Version only selects the A2A wire (0.x or 1.0), the agent card uses the server's default release, and extractInvocation reads only skill and input from the DataPart. The AdCP release is selected inside the tool from the tool arguments, the same as on MCP. This PR doesn't touch that envelope path.

What does change, for custom tools on A2A (and invoke() / dispatchTestRequest()):

Tool Before this PR Now
Framework tools (get_products, …) Raw adcp_version selects the release Unchanged
tasks_get (declares adcp_version / adcp_major_version) Selected from the arguments Unchanged (parsed value, same result)
Custom tool that declares adcp_version Raw value selects the release Parsed value selects it, so a declared default or coercion now applies
Custom tool that does not declare adcp_version Raw value selected the release (for example, 3.1) Behavior change: stripped by the schema, so the server's default release applies, as on MCP before and after this PR

Checked on this head through invoke(), A2A's dispatch path: an undeclared adcp_version: '3.1' now serves 3.3-beta.5 (the default), a declared one serves 3.1, and framework get_products serves 3.1.

This is deliberate. Reading an undeclared raw adcp_version after the parse would let a caller steer a custom tool to an older release whose path may not apply identity33 authorization. A custom tool that should let buyers pick the release declares adcp_version (and adcp_major_version) in its schema. The changeset and the migration note describe this under the parsed-value change.

Keeping tools/list on the real schema

The MCP SDK reads one inputSchema both to validate and to advertise, so the separation happens in discovery. The media-buy profile already does this to project real schemas for framework tools.

  • Legacy MCP. One tools/list wrapper per server replaces each declared tool's inputSchema with toJsonSchemaCompat(normalizeObjectSchema(declared), { strictUnions: true, pipeStrategy: 'input' }), the SDK's own conversion. Tests check that it deep-equals what a bare McpServer advertises.
  • 2026-07-28 adapter. It validates against the passthrough schema and advertises the declared one, converted by the v2 SDK's toolInputSchemaJson on a probe server (the same conversion as before). A schema the v2 SDK can't convert (Zod v3) is advertised as { type: 'object', properties: {} }, as before.
  • Not an SDK server. On a test double or proxy with no registration record or no tools/list handler, nothing is swapped, and the SDK keeps validating.

Behavior changes (why the changeset is minor)

  • Error shape. Buyers now get adcp_error INVALID_REQUEST where they used to get the -32602 text. Anything that matched the text must read adcp_error.
  • invoke(), dispatchTestRequest() and A2A. These call handlers directly, so custom-tool handlers there used to get raw, unvalidated arguments, and authorization and release selection read them raw. Now they validate, and handlers get the parsed, stripped value. A handler that read undeclared keys must declare them, or use z.looseObject(...). The changeset and the new "Custom tool input validation" section of docs/migration-16-to-17.md spell this out.
  • context on returned failures. A handler's returned or thrown failure now gets the caller's context added if it lacks one.

Tests

test/lib/declared-input-validation.test.js has 50 cases, over a real MCP Client and over invoke():

  • validation errors and the parsed value
  • tools/list parity with a bare McpServer
  • all three comply paths plus complyTest, and tasks_get
  • invoke() / dispatchTestRequest()
  • the 2026-07-28 adapter, including the Zod v3 fallback
  • both authorization-bypass regressions, over MCP and over invoke()
  • the hidden-controller MethodNotFound on the comply parse-failure path over invoke(), where no tools/call wrapper runs first
  • the 8 KiB cap on comply rejection echoes
  • the version default, coercion, undeclared and garbage cases
  • a context-echo assertion for each rejection type
  • hidden-controller MethodNotFound with invalid arguments

Mutation checks: reverting the compiled identity call to raw arguments fails all four identity-order tests, and disabling the comply parse-failure visibility check fails the invoke() hidden-controller test. That test catches the schema error the check prevents from leaking.

Existing suites pass locally:

Suite Result
server-create-adcp-server 185/185
server-test-controller 134/134
comply-controller 62/62
server-decisioning-comply-test 11/11
mcp-modern-negotiation 15/15
server-a2a-adapter 34/34
server-decisioning-from-platform 251/251
all 20 identity / web-bot-auth / wba suites pass

🤖 Generated with Claude Code

aao-secretariat[bot]
aao-secretariat Bot previously approved these changes Nov 9, 2026

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Approve

Approve.

Moves zod-input validation for customTools / tasks_get / comply_test_controller from the MCP SDK layer to the AdCP layer so a rejected call returns an INVALID_REQUEST adcp_error echoing context, closing the -32602 gap (#3666).

Checked:

  • declared-input-schema.ts — validateToolInputAtAdcpLayer (L337-342) safe; wrapSdkRequestHandler returns boolean, tools/list closure reads state.schemas live so later swaps still advertise, missing tools/list handler returns false → early return leaves SDK validating and parseDeclaredToolInput passes through. required/type/additionalProperties projection (L436-457) covered by tests.
  • Context echoed from raw input (L400), not parsed, so a strict schema rejecting context still echoes it; injectContextIntoResponse idempotent (no-ops when context already present).
  • Modern 2026-07-28 adapter restores declared schema the passthrough swap would flatten; non-swapped tools fall back to modern.toolInputSchemaJson, no discovery regression.
  • Behavior change on invoke()/dispatchTestRequest()/A2A is deliberate transport-consistency and documented. Changeset present, appropriate for pre-1.0 beta, error-shape change spelled out.

No Critical/High/Medium findings. high_risk false; gated_paths false (review_decision REVIEW_REQUIRED but gate inactive); no team gate. Falls through to row 9.

…3666)

customTools, tasks_get and comply_test_controller registered zod input
schemas with the MCP SDK, which rejected bad arguments with a bare -32602
text result before any AdCP code ran: no structuredContent, no adcp_error,
no context. That breaks the context-sessions echo-on-error rule.

These tools now register a passthrough schema like framework tools and
validate the declared schema inside the handler. A failure returns
INVALID_REQUEST with field, issues, the caller's context, status and the
served adcp_version. A valid call reaches the handler with the parsed
value, so coercion, defaults and transforms still apply. tools/list keeps
advertising the declared schema on the legacy SDK and the 2026-07-28
adapter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ls (#3666)

The changeset and the 16 -> 17 migration guide now say that invoke(),
dispatchTestRequest() and A2A pass custom-tool handlers the parsed,
stripped value instead of the raw arguments, and how to keep undeclared
keys. They also link #3674 on the VALIDATION_ERROR / INVALID_REQUEST
split.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@bokelley

bokelley commented Nov 9, 2026

Copy link
Copy Markdown
Contributor Author

Do not merge. The independent review confirmed a P1 security finding: auxiliary identity authorization runs on raw args before schema parsing, so a schema default can turn an authorized read-only call into a finalize after authorization. The fix is in progress (parse once, then authorize, select the release and dispatch on the parsed value). Under REVIEW-STACKS it gets a security review and a Codex pass before merge.

aao-secretariat[bot]
aao-secretariat Bot previously approved these changes Nov 9, 2026

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Approve

Approve — clean pass, no blocking or medium findings.

This PR moves AdCP-layer validation to zod-declared tools (customTools, tasks_get, comply_test_controller) so a schema-failing call returns an INVALID_REQUEST adcp_error echoing context instead of the SDK's bare -32602 text. The reviewer traced declared-input-schema.ts and its integration across create-adcp-server.ts, from-platform.ts, test-controller.ts, comply-controller.ts, and the 2026-07-28 adapter.

Verified safe: schema-swap is fail-closed (only swaps to passthrough after the tools/list wrapper installs; otherwise the SDK keeps validating); parseDeclaredToolInput mirrors the SDK's own validateToolInput so valid calls parse identically; the comply controller runs its visibility gate against rawInput before validation, so a hidden controller cannot leak its schema. No fabrication or wire reshape — witness-not-translator preserved. Changeset minor is correct for the in-flight SDK 17 pre-mode major.

Decision table: no critical/high/medium findings (row 1 n/a); gated_paths false (row 2 n/a); high_risk false (rows 3/5 n/a); no data-loss/schema/infra medium (row 4 n/a); prior decision was approve (row 6 n/a); no no-auto-approve team match (row 7 n/a); zero medium findings (row 8 n/a). Falls through to row 9 → approve. Note: review_decision is REVIEW_REQUIRED, but row 2 does not apply because gated_paths is false.

…ol input (#3666)

Custom tools ran identity33 auxiliary authorization and release
selection on the raw arguments, then parsed them against the declared
schema. A default, coercion or transform could therefore change the
request after it was authorized: `{ refine: {} }` with
`action: z.literal('finalize').default('finalize')`, or a trimmed
`'finalize '`, passed as read-only and then finalized.

The custom-tool handler now parses exactly once, then runs
authorization, release selection and the handler on that one parsed
value. Release selection matches pre-PR MCP behavior again, so a
declared adcp_version default or coercion selects the release. The
platform comply_test_controller keeps its deny-only visibility gate
before parsing and re-resolves the principal on the parsed value.

Every rejection on these paths echoes the caller's context, including
the version, identity, account and handler-returned failures. The
modern adapter advertises an empty object again for a declared schema
it cannot convert (Zod v3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
// every rejection on the custom-tool path echoes the caller's context.
// ---------------------------------------------------------------------------

const { createRequire } = require('node:module');
// ---------------------------------------------------------------------------

const { createRequire } = require('node:module');
const path = require('node:path');
aao-secretariat[bot]
aao-secretariat Bot previously approved these changes Nov 9, 2026

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Approve

Approve — subsequent pass on a prior clean approve, no blocking findings.

This delta (7 files, 837 lines) refines the parse-once rejection path so every custom-tool / comply_test_controller / tasks_get rejection echoes the caller's context. Verified against repo invariants:

  • Witness-not-translator: context/version/status injections are idempotent, so the catch-block pre-inject + finishFrameworkRejection does not double-write; no fabricated or re-shaped wire fields introduced.
  • Account-identity routing: context is declared in TOOL_INPUT_SHAPE (test-controller.ts:1789), so comply-controller parsing preserves input.context.account routing and the reject() context echo.
  • The pre-parse visibility gate on rawInput plus re-resolve on parsed input is a deliberate fail-closed double-check.
  • The mcp-modern-server ?? { type:'object', properties:{} } fallback keeps Zod-v3 tools advertised as empty objects rather than leaking passthrough (tested L800–837).
  • Changeset: present (minor, consistent with SDK 17 beta pre-mode) — matches wire impact.
  • Test delta (declared-input-validation.test.js, +418 lines) validated; identity-auth test names mildly overclaim parsed-vs-raw but the assertions hold either way (Low only); the genuine parsed-value-feeds-decision guarantee is covered by the release-selection suite.

No Critical/High/Medium findings. gated_paths is false and high_risk is false, so rows 1–8 do not fire; outcome falls through to row 9 (approve). review_decision REVIEW_REQUIRED does not block since gated_paths is false.

The platform comply_test_controller resolved the principal twice per
call: a deny-only gate on the raw arguments, then again on the parsed
value. That doubled the accounts.resolve calls the discovery-context
contract counts.

A valid call now resolves visibility once, on the parsed value. The
raw-arguments check runs only when parsing fails, so a principal that
cannot see the controller still gets MethodNotFound and learns nothing
about its schema.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
aao-secretariat[bot]
aao-secretariat Bot previously approved these changes Nov 9, 2026

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Approve

Approve — clean reorder with security property preserved.

The delta since the prior approve is a single 32-line reorder in from-platform.ts: the hidden-controller visibility gate moves from before parse into the parse-failure branch. Externally observable behavior is unchanged — invisible principals still get MethodNotFound on both valid and invalid input (success-path gate at L408 intact; failure-path deny-only check reads rawInput as before). The reorder lets the post-parse reject/context-echo machinery operate on a parsed value, which is the PR's purpose.

No critical/high/medium findings. Changeset present (.changeset/custom-tool-input-errors-echo-context.md). No high-risk flag, no gated paths, no author team gate. Falls through to row 9.

bokelley and others added 2 commits November 9, 2026 19:49
…ion (#3666)

A2A selects the AdCP release from tool arguments, not the envelope. A
custom tool whose schema does not declare adcp_version used to let the
raw value select the release on A2A, invoke() and dispatchTestRequest();
the schema now strips it and the default release applies, as on MCP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Security-review follow-ups on #3670:

- The platform comply_test_controller's parse-failure and reject() paths
  echoed context uncapped while its PERMISSION_DENIED branch capped the
  echo at 8 KiB. All of its rejections now share one capped echo.
  parseDeclaredToolInput takes an echoContext option for this.
- Tests pin the comply parse-failure visibility check over invoke(),
  where no tools/call wrapper hides the controller first, and run both
  identity-order regressions over invoke() as well as MCP.
- The customTools inputSchema docs warn that it runs before identity
  authorization, so it must stay free of side effects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Approve

Approve — clean subsequent pass, no blocking or medium findings.

This is a follow-up pass on a refactor that hoists the inline safeEcho/8 KiB-cap from the comply_test_controller reject() branch into a module-scope cappedComplyEcho, threading it into parseDeclaredToolInput via a new options.echoContext. The parse-failure echo is now capped identically to the reject() path, while custom tools stay uncapped (default option undefined). New tests cover both echo paths at both sizes plus the invoke() transport. The changeset and migration doc carry an accurate A2A/invoke()/dispatchTestRequest behavior-change note.

Gate checks: no no-auto-approve team match; gated_paths is false; high_risk is false. Changeset present and consistent with wire impact. No witness-not-translator concern (behavior is echo-capping, not field fabrication). Reviewer surfaced no critical/high/medium findings, and the prior decision was also a clean approve. Falls through to decision-table row 9.

@bokelley
bokelley merged commit 57ffd9f into main Nov 9, 2026
46 checks passed
@bokelley
bokelley deleted the fix/3666-custom-tools-context-echo branch November 9, 2026 20:27
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.

customTools input-schema errors drop context (MCP -32602 before the AdCP layer)

1 participant