Repository navigation
fix(server): declared-input tools echo context on validation errors (#3666) - #3670
Conversation
There was a problem hiding this comment.
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;wrapSdkRequestHandlerreturns boolean, tools/list closure readsstate.schemaslive so later swaps still advertise, missing tools/list handler returns false → early return leaves SDK validating andparseDeclaredToolInputpasses through. required/type/additionalProperties projection (L436-457) covered by tests.- Context echoed from raw input (L400), not parsed, so a strict schema rejecting
contextstill echoes it;injectContextIntoResponseidempotent (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>
bc71036 to
721270a
Compare
|
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. |
There was a problem hiding this comment.
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'); |
There was a problem hiding this comment.
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 +
finishFrameworkRejectiondoes not double-write; no fabricated or re-shaped wire fields introduced. - Account-identity routing:
contextis declared inTOOL_INPUT_SHAPE(test-controller.ts:1789), so comply-controller parsing preservesinput.context.accountrouting and thereject()context echo. - The pre-parse visibility gate on
rawInputplus 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>
There was a problem hiding this comment.
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.
…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>
There was a problem hiding this comment.
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.
Fixes #3666. Unblocks the known-gap marker in adcontextprotocol/adcp#8834. Follow-up on error codes: #3674.
Problem
customTools(and sotasks_getoncreateAdcpServerFromPlatform) andcomply_test_controllerregistered 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-32602text result: nostructuredContent, noadcp_error, nocontext. That breaks the context-sessions rule that failures echocontext. 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:customToolsincreateAdcpServer, which coverstasks_getregisterTestControllercreateComplyController().register()complyTestregistration increateAdcpServerFromPlatformHow it works:
validateToolInputAtAdcpLayerthen records the normalized schema and swaps the registered one to the framework'sPASSTHROUGH_INPUT_SCHEMA. That constant moved into this module and is reused, not duplicated.parseDeclaredToolInputuses the samenormalizeObjectSchema+safeParseAsyncas the SDK'svalidateToolInput. A valid call therefore produces exactly the value the handler got before: coerced, with defaults and transforms applied and undeclared keys stripped.identity33auxiliary 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 capturecontextfor echo.adcp_errorINVALID_REQUEST, built withbuildAdcpValidationErrorPayload(the framework's payload builder). It carriesfield(the first failing path),issuesanddetails.issues, and the finish step adds the caller'scontext,status: 'failed'and the servedadcp_version.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 emitVALIDATION_ERRORfor the same class of failure. Both codes are in the spec enum, and the spec reservesVALIDATION_ERRORfor 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 ranidentity33authorization 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:
adcp_version: z.string().default('3.1.31')selects 3.1.z.coerce.string()called with3.1selects 3.1.adcp_versionthe schema doesn't declare is stripped, so the default release applies.Every read of the arguments before the parse
tools/call(all declared tools)validateToolInputagainst the passthrough schemamaxToolInputElementssize cap. No decision on content.createAdcpServerwrappersauthInfo, request scope or response only. No arguments.customTools/tasks_gethandlercontextcaptured for echoINVALID_REQUESTis read from the raw arguments (no parsed value exists, and nothing runs).AdcpServer.invoke()(A2A, the 2026-07-28 adapter)authInfoonly. It then calls the same handler, which parses.skillname, the requirement thatinputis an object, signature-gate operation name, task-continuation operationinputisn't read.isRegisteredToolVisible(toolName, authInfo)dispatchTestRequest()registerTestControllercreateComplyController().register()sandboxGateand the controller receive the parsed value.createAdcpServerFromPlatform({ complyTest })tools/callvisibility wrapper, on raw argumentsINVALID_REQUESTis returned, so a hidden controller never reveals its schema.Comply echo cap
The platform
comply_test_controlleralways capped itsPERMISSION_DENIEDecho ofcontextandextat 8 KiB serialized. Its other rejections, including the new parse-failureINVALID_REQUEST, now share that cap:cappedComplyEchoinfrom-platform.ts, applied through the newechoContextoption onparseDeclaredToolInput. Custom tools echo uncapped, as framework tools do.A2A version selection
A2A has no AdCP version negotiation at the envelope or transport level.
A2A-Versiononly selects the A2A wire (0.x or 1.0), the agent card uses the server's default release, andextractInvocationreads onlyskillandinputfrom 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()):get_products, …)adcp_versionselects the releasetasks_get(declaresadcp_version/adcp_major_version)adcp_versionadcp_version3.1)Checked on this head through
invoke(), A2A's dispatch path: an undeclaredadcp_version: '3.1'now serves3.3-beta.5(the default), a declared one serves3.1, and frameworkget_productsserves3.1.This is deliberate. Reading an undeclared raw
adcp_versionafter 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 declaresadcp_version(andadcp_major_version) in its schema. The changeset and the migration note describe this under the parsed-value change.Keeping
tools/liston the real schemaThe MCP SDK reads one
inputSchemaboth 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.tools/listwrapper per server replaces each declared tool'sinputSchemawithtoJsonSchemaCompat(normalizeObjectSchema(declared), { strictUnions: true, pipeStrategy: 'input' }), the SDK's own conversion. Tests check that it deep-equals what a bareMcpServeradvertises.toolInputSchemaJsonon 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.tools/listhandler, nothing is swapped, and the SDK keeps validating.Behavior changes (why the changeset is
minor)adcp_errorINVALID_REQUESTwhere they used to get the-32602text. Anything that matched the text must readadcp_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 usez.looseObject(...). The changeset and the new "Custom tool input validation" section ofdocs/migration-16-to-17.mdspell this out.contexton returned failures. A handler's returned or thrown failure now gets the caller'scontextadded if it lacks one.Tests
test/lib/declared-input-validation.test.jshas 50 cases, over a real MCP Client and overinvoke():tools/listparity with a bareMcpServercomplyTest, andtasks_getinvoke()/dispatchTestRequest()invoke()invoke(), where notools/callwrapper runs firstMutation 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:
server-create-adcp-serverserver-test-controllercomply-controllerserver-decisioning-comply-testmcp-modern-negotiationserver-a2a-adapterserver-decisioning-from-platformidentity/web-bot-auth/wbasuites🤖 Generated with Claude Code