Skip to content

fix(discovery): discover preview pins unpinned and reach probe-gated sellers anonymously - #3694

Merged
bokelley merged 4 commits into
mainfrom
fix/prerelease-discovery-unpinned
Nov 12, 2026
Merged

bokelley merged 4 commits into
mainfrom
fix/prerelease-discovery-unpinned

Conversation

@bokelley

@bokelley bokelley commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Reported in AdCP Slack. A registration check on the 3.3 beta SDK kept saying a 3.0-only seller "does not look like an AdCP agent". The check called get_adcp_capabilities pinned to adcp_version: "3.3-beta.5". The seller correctly answered VERSION_UNSUPPORTED with supported_versions: ["3.0"], and nothing retried. We reproduced it against 17.0.0-beta.22 with a local 3.0-only seller.

Rule implemented here: a buyer sends a preview pin only to a peer that advertises that exact release, and preview pins never negotiate down. Discovery goes out unpinned, and on VERSION_UNSUPPORTED the buyer retries unpinned.

Changes

1. Public discovery (SingleAgentClient)

  • One shared helper, capabilityDiscoveryEnvelope() in adapters/version/negotiate.ts. getAdcpCapabilities() and the internal probe both dispatch through executeCapabilityDiscovery().
  • A preview wire release (wireAdcpVersion ?? adcpVersion) goes out unpinned.
  • A refused stable pin is retried once unpinned. The retry runs with its own correlation id and the same deadline. Only a successful retry replaces the pinned refusal.
  • Nothing is overridden when the caller chose the pin: adcp_version / adcp_major_version in params, or an explicit versionEnvelope. The refusal comes back with adcpError.details.supported_versions.
  • A call with a caller operationId is not retried, so the returned task keeps that id.

2. Testing and storyboard discovery

  • applyAdcpVersionRunOptions() / applyStoryboardVersionOptions() leave versionEnvelope unset for AdCP 3 runs. That is the SDK default: ordinary calls still send auto. Before, they set an explicit auto, which pinned profile discovery.
  • Authored get_adcp_capabilities storyboard steps still send the configured pin (storyboardCapabilityStepEnvelope). The universal/version-negotiation storyboard grades the seller's answer to that pin.
  • A 3.0-only seller tested on a preview pin is now profiled. comply() then reaches its version check and fails with CapabilityResolutionError (unsupported_adcp_version, supportedVersions: ['3.0']), not "Agent unreachable — VERSION_UNSUPPORTED".
  • Harnesses that set versionEnvelope keep pinned discovery. VERSION_UNSUPPORTED: classification in comply.ts is unchanged.
  • Discovery diagnostics name a requested release only when one was sent (testClientDiscoveryWireAdcpVersion).

3. MCP server/discover 401 (mcp-modern.ts)

  • A client with no credential now gets one legacy initialize attempt when a seller 401s/403s only server/discover, as a static-credential client already did. AdCP capability discovery is anonymous.
  • If the legacy attempt fails for any reason other than a throttle or a cancellation (another 401, a 5xx, a 404), the probe's original 401 is rethrown. An uncredentialed caller sees exactly the error and challenge it saw before, not "endpoint not found". 429, a 503 with Retry-After, and aborts still reach the caller as themselves.
  • OAuth-provider clients are unchanged. A status-only debug log line records that the probe was refused.

4. Registry types sync (unrelated, needed for green CI)

Tests

  • test/lib/prerelease-capability-discovery.test.js (new). A 3.0-only seller with a client on the 3.3.0-beta.5 pin:
    • unpinned getAdcpCapabilities() / getCapabilities();
    • an explicit params or envelope pin is preserved, and the refusal carries supported_versions;
    • stable-pin retry, both public and internal;
    • a stable wire override is sent;
    • operationId skips the retry;
    • a failed retry keeps the pinned refusal;
    • discoverAgentProfile succeeds;
    • storyboard steps stay pinned;
    • comply reports unsupported_adcp_version.
  • test/lib/mcp-negotiation-401-legacy-fallback.test.js:
    • a seller that 401s only server/discover is discovered anonymously, with no credential sent and no OAuth walk;
    • a seller that 401s everything still yields AuthenticationRequiredError with the Basic challenge, after one legacy attempt per URL;
    • the probe challenge survives a non-auth legacy failure.
  • protocol-client-needs-auth (unchanged) caught that a bare legacy 401 replaced the probe's 401 for an uncredentialed caller. That is fixed by keeping the probe error.
  • Updated tests whose wire expectations change on purpose: capabilities-probe-wire-version, storyboard-version-negotiation, storyboard-capability-discovery-terminal.

Review

  • Security review (Claude security-reviewer, mcp-modern.ts): no High or Medium findings. Low items L1 (lost challenge on a non-auth legacy failure), L3 (debug log) and L4 (challenge-scheme assertion) are fixed here. L2 (an uncredentialed client against an everything-401 seller now sends one extra initialize per candidate URL) is accepted: it's bounded, and it's what makes anonymous discovery work.
  • Code review (Claude code-reviewer):
    • Fixed: the CI blocker; storyboard steps losing the pin; operationId on the retry; a failed retry hiding the refusal; a stable wire override on a preview client.
    • Out of scope: a seller that serves initialize anonymously but 401s tools/call gets a generic invalid_token error rather than AuthenticationRequiredError. The same seller without the server/discover gate already behaves that way on main: the call path rethrows the raw 401 when the RFC 9728 walk can't be validated. Worth a follow-up.
  • npm run review:codex -- --all, with the diff on stdin because codex's sandbox can't run shell commands on this host:
    • Security: no actionable findings.
    • Code and protocol: one shared item, that test-client reuse treats an unset envelope and auto as equal. Kept deliberately: both send the same envelope on ordinary calls and authored steps, and differ only in the reused client's own preview discovery probe, which follows that client's config. Making it strict would stop existing _client reuse in harnesses built with auto.

🤖 Generated with Claude Code

bokelley and others added 2 commits November 12, 2026 11:47
…sellers anonymously

A registration check on the 3.3 beta SDK reported a 3.0-only seller as
"not an AdCP agent": get_adcp_capabilities went out pinned to 3.3-beta.5,
the seller answered VERSION_UNSUPPORTED with supported_versions ["3.0"],
and nothing retried.

- getAdcpCapabilities() and the internal probe share one rule
  (capabilityDiscoveryEnvelope): a preview pin is discovered unpinned, and
  a refused stable pin is retried once unpinned. A caller-chosen pin
  (params or explicit versionEnvelope) is sent as-is; the refusal keeps
  its supported_versions.
- Testing discovery follows the same rule. The storyboard runner no longer
  sets an explicit 'auto' envelope for AdCP 3 runs, so comply reaches its
  version check (CapabilityResolutionError unsupported_adcp_version)
  instead of "Agent unreachable — VERSION_UNSUPPORTED".
- A client with no credential falls back to legacy MCP initialize when a
  seller 401/403s only server/discover. If that also fails, the seller's
  challenge still reaches the caller. OAuth-provider clients are unchanged.

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

- Authored get_adcp_capabilities storyboard steps send the configured pin
  again; only the SDK's profile discovery goes out unpinned.
- capabilityDiscoveryEnvelope evaluates the release actually sent
  (wireAdcpVersion ?? adcpVersion), so a stable wire override is sent.
- Skip the unpinned retry when the caller set an operationId, and keep the
  pinned refusal unless the retry succeeds.
- Without a credential, keep the server/discover challenge when the legacy
  initialize fails for a non-auth reason; log the probe refusal (status only).
- Document the discovery rule in WIRE-VERSION-COMPAT.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
aao-secretariat[bot]
aao-secretariat Bot previously approved these changes Nov 12, 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 — discovery-pinning fix verified clean, no blocking or medium findings.

What I checked:

  • Witness-not-translator: the failed unpinned retry returns the real pinned refusal (VERSION_UNSUPPORTED) with no fabrication — witness preserved. No mock/fallback data injected.
  • capabilityDiscoveryEnvelope behavior confirmed (preview→'none', stable→undefined, explicit wins); executeCapabilityDiscovery retry gating correctly skips for callerPinned/operationId/'none'.
  • mcp-modern.ts fallback is fail-closed: legacy auth/abort/throttle supersede, OAuth providers excluded, skipProbe bounds it to one legacy attempt. Uses official MCP client path.
  • Operational readiness: unpinned retry preserves deadline/signal via childTaskOptions; get_adcp_capabilities idempotent; cost is one extra round-trip.
  • Changeset present (patch) and defensible for an additive-helper bugfix — no removed/renamed exports or response-shape change that would understate impact.
  • Test coverage strong: prerelease-capability-discovery.test.js asserts discoveryCalls deepEqual, pinning retry ordering ['3.1', undefined] and no preview-pin leak.

Gate checks: high_risk is true only via src/lib/protocols/mcp-modern.ts (modified), but with no medium-or-higher finding the modification is presumed safe (row 5 does not fire). gated_paths is false. No no-auto-approve team match. review_decision REVIEW_REQUIRED does not block since gated_paths is false. No findings → rows 1–8 do not fire; falls through to row 9 = approve.

…nAPI

The published registry OpenAPI gained the brand logo asset endpoints
(getBrandLogoAsset, getBrandLogoLegacy) and logo background docs, so the
"Generated, Docs & Formatting" check failed on every open PR. Additive only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
aao-secretariat[bot]
aao-secretariat Bot previously approved these changes Nov 12, 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 — purely additive registry snapshot refresh, no blocking or medium findings.

What I checked:

  • Delta pass over 3 files: a patch changeset, a vendored registry OpenAPI snapshot (schemas/registry/registry.yaml) adding two brand-logo endpoints (getBrandLogoAsset, getBrandLogoLegacy) plus background-field documentation, and the regenerated types.generated.ts.
  • Changeset type (patch) and impact are correct — these are registry endpoint types, not agent-response reshaping, so no witness/wire-shape concerns.
  • high_risk: true is driven by (modified) entries on schemas/registry/registry.yaml and src/lib/protocols/mcp-modern.ts, but the reviewer found no medium-or-higher concerns, so the modifications are presumed safe (no row-5 trigger).
  • gated_paths: false; no no-auto-approve team match.
  • Prior decision was approve with no findings; this run also surfaces none — net clean.

No critical/high/medium findings. Rows 1–8 do not fire (review_decision REVIEW_REQUIRED does not block since gated_paths is false; high_risk alone with no medium finding does not escalate). Falls through to row 9.

… refused too

An uncredentialed client against a seller that 401s everything got the
legacy initialize's bare "Error POSTing to endpoint" instead of the probe's
401. Keep the probe error unless the legacy attempt was throttled or
cancelled, so the caller sees exactly what it saw before the retry existed.

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.

Subsequent review: the prior decision was a clean approve with no findings. The delta is a single non-generated file (src/lib/protocols/mcp-modern.ts, ~47 lines) consisting of comment rewrites plus narrowing legacyFailureSupersedesProbe to drop isNegotiationAuthFailure, so a second 401 on the legacy initialize attempt defers to the probe's original challenge rather than replacing it. The logic is consistent with the surrounding no-credential fallback and preserves witness behavior (surfaces the seller's real 401 + WWW-Authenticate chain, no fabrication). Covered by the updated 401-legacy-fallback tests, and a changeset is present.

Decision-table walk: no critical/high findings (row 1 n/a); gated_paths is false (row 2 n/a); high_risk is true but reasons are (modified), not (deleted) (row 3 n/a); no medium findings at all (rows 4, 5, 8 n/a — the high-risk (modified) paths carry no medium concern); prior decision was approve, not escalate (row 6 n/a); no no-auto-approve team match (row 7 n/a). Falls through to row 9 → approve.

@bokelley
bokelley merged commit 992716d into main Nov 12, 2026
46 checks passed
@bokelley
bokelley deleted the fix/prerelease-discovery-unpinned branch November 12, 2026 14:05
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