Skip to content

spec(versioning): release-precision protocol version negotiation#3493

Merged
bokelley merged 9 commits intomainfrom
bokelley/version-negotiation-rfc
Apr 29, 2026
Merged

spec(versioning): release-precision protocol version negotiation#3493
bokelley merged 9 commits intomainfrom
bokelley/version-negotiation-rfc

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

@bokelley bokelley commented Apr 29, 2026

Summary

Adds release-precision version negotiation to AdCP via a new adcp_version top-level envelope field on every request and response. Buyers send their release pin (e.g. "3.0", "3.1"); sellers echo the release they actually served. Augments the existing major-precision adcp_major_version (integer) with finer precision and adds response-side echo, which the spec lacked.

Full proposal: specs/version-negotiation.md.

Why now

Major-precision is no longer the actual interop boundary in practice — clients and servers are matched at release boundaries. 3.0.1 is GA, 3.1 is heading to beta, and the additive-only forward-compat-within-major promise doesn't hold cleanly when the spec ships breaking fixes inside a major. Release-precision negotiation is what client SDKs need to do Stripe-style per-instance pinning, and what testing harnesses need for cross-release interop in one process.

What's in this PR

This is both the RFC and the implementation — fully additive on the wire, ready to ship as part of a 3.1 release.

  • Shared core/version-envelope.json carrying adcp_version (release-precision string) and adcp_major_version (deprecated integer). Composed via allOf $ref from every AdCP request and response — single source of truth, no per-schema duplication.
  • adcp.supported_versions and adcp.build_version on the capabilities response. Marks legacy adcp.major_versions as deprecated. build_version pattern is full semver with optional pre-release and build-metadata segments per semver §9–§10.
  • error-details/version-unsupported.json — standardized error.data shape for the existing VERSION_UNSUPPORTED code (no new code, just structured details). Linked from the VERSION_UNSUPPORTED enum description.
  • docs/reference/versioning.mdx updated with the bidirectional negotiation algorithm, the patch-not-negotiated rule, the deprecation timeline, the MCP protocolVersion layering note, and worked examples for pre-release pin matching.
  • CI lint at tests/lint-version-envelope.test.cjs enforces the composition invariant: schemas using the envelope allOf $ref MUST have permissive additionalProperties at root (draft-07 limitation; strict variant returns at draft 2019-09 — see Migrate JSON Schema source from draft-07 to 2019-09 (4.0) #3534).
  • Changeset: minor bump to adcontextprotocol.

Key decisions

  1. Top-level on the AdCP payload, not in extensions.* and not in transport headers. Version is meta-protocol, not a feature. Headers don't survive stdio-MCP. A versioned extension surface can't carry the signal that selects its own version.
  2. Symmetric field name (adcp_version) on both sides — semantics fall out of context (request body vs response body). Mirrors Stripe-Version.
  3. Release precision on the wire ("3.0", "3.1"); patches surface as advisory build_version on capabilities. Per the spec's three-tier model, patches by definition introduce no contract change — making them part of negotiation bakes a category mistake into the spec.
  4. Reuse VERSION_UNSUPPORTED with a richer error.data (no new error code).
  5. Deprecate adcp_major_version and adcp.major_versions through 3.x, remove in 4.0.
  6. Server response semantics: echoed adcp_version is the release served, never the seller's own latest release. A 3.1 seller serving a 3.0 buyer at 3.0 echoes "3.0".
  7. Field disagreement is malformed: when a request carries both adcp_version and adcp_major_version and the majors disagree, the server MUST return VERSION_UNSUPPORTED.
  8. Pre-release pins are matched exactly. No range resolution, no silent downshift onto a pre-release.

Backwards compatibility

Fully additive:

  • Servers that don't read adcp_version ignore it (additionalProperties: true) and fall back to the existing adcp_major_version or default. Existing clients that don't read response adcp_version keep working.
  • Capability responses keep emitting both adcp.major_versions (integer) and the new supported_versions (string) through 3.x.
  • Existing VERSION_UNSUPPORTED callers see a richer error.data payload but the code is unchanged.

One scoped behavior change: 17 request schemas (under collection/, governance/, property/, tmp/) previously declared additionalProperties: false at root. The allOf $ref composition pattern requires permissive parents in draft-07 (allOf does not bypass the parent's strict mode), so this PR flips them to true. Strict request validation across these schemas returns at draft 2019-09 via unevaluatedProperties: false — see #3534.

Migration

Spec stays at SHOULD on both sides through all of 3.x — consistent with the 3.x stability guarantee that fields don't graduate optional → required within a major. The AdCP compliance grader carries adoption pressure within 3.x.

Phase Spec — buyer Spec — seller Compliance grader
3.1 (additive ship) SHOULD emit adcp_version (with adcp_major_version mirror). SHOULD honor and echo adcp_version. SHOULD emit supported_versions on capabilities. Advisory: reports presence on requests and responses.
3.2 (unchanged from 3.1) (unchanged from 3.1) Blocking failure when sellers don't echo adcp_version or don't emit supported_versions on capabilities.
4.0 MUST emit adcp_version. adcp_major_version removed. MUST honor and echo adcp_version. adcp.major_versions and extensions.adcp.adcp_version removed. Blocking failure on absence; legacy fields rejected.

Out of scope (future RFCs)

Test plan

  • npm run build:schemas clean.
  • npm run build:compliance clean.
  • All schema-shape tests green: test:schemas (7), test:examples (34), test:json-schema (255 parsed JSON blocks), test:composed (32), test:extensions (20), test:version-envelope (new lint enforcing the allOf composition invariant).
  • Pre-commit run: 834 vitest unit tests, dynamic-import lint, typecheck — all green.
  • Verified with Ajv that previously-strict schemas (17 under collection/, governance/, property/, tmp/) now accept adcp_version and adcp_major_version after the additionalProperties: true flip.

Behavioral conformance — the resolution algorithm (cross-major rejection, in-major downshift, pre-release exact-match, dual-emit field-disagreement, response echo) is server-side behavior, not validatable from the JSON Schema alone. End-to-end coverage lives in the downstream SDK conformance suites:

The spec PR ships definitions; the contract is exercised end-to-end via SDK testing.

Reviewer pointers

Triage and reviewer feedback already addressed in commits on this branch:

  • Inline duplication → allOf $ref to core/version-envelope.json.
  • Pagination-response.json fragment-pollution reverted.
  • Resolution algorithm tightened (sub-min pin → VERSION_UNSUPPORTED; pre-release exact-match row added; field-disagreement → VERSION_UNSUPPORTED).
  • build_version pinned to semver §9–§10 format with build-metadata segment.
  • MCP protocolVersion vs AdCP adcp_version layering note in both RFC and doc.
  • Migration cadence pulled to SHOULD-only across 3.x with compliance-grader gating.
  • 17-schema strict-additionalProperties regression fixed; CI lint added to prevent regression.

Open question for protocol-WG eyes: anything in the SHOULD-with-grader-gating model that doesn't match how AAO actually runs certification.

Promote AdCP version to a top-level envelope field on every request
and response. Clients send their pin; servers echo what they served.
Minor precision on the wire; patches surface as build_version
operational metadata. Transport-uniform across MCP and A2A.
…lope field

Implements the RFC at specs/version-negotiation.md.

- Add `adcp_version` (release-precision semver string) to all 63 request
  schemas (alongside existing `adcp_major_version`, marked deprecated)
  and all 63 response schemas (new — server echoes what it served).
- Add `supported_versions` and `build_version` to capabilities response.
  Mark `adcp.major_versions` as deprecated.
- Add `error-details/version-unsupported.json` standardizing the
  `VERSION_UNSUPPORTED` error data payload.
- Update docs/reference/versioning.mdx to document bidirectional
  release-precision negotiation, deprecation of major-precision fields,
  and patch-not-negotiated rule.
- Changeset: minor bump.

Fully additive on the wire. Legacy fields removed in 4.0.
@bokelley bokelley changed the title proposal: bidirectional protocol version negotiation spec(versioning): release-precision protocol version negotiation Apr 29, 2026
bokelley added a commit to adcontextprotocol/adcp-client-python that referenced this pull request Apr 29, 2026
Lifts the per-instance adcp_version pin from plumbing-only (Stage 2)
to actual wire emission. Builds against the upstream RFC at
adcontextprotocol/adcp#3493 — assumes that lands.

Client side:
- ProtocolAdapter gains envelope_enricher hook + _enrich_outgoing_params
  helper. Hook runs after idempotency injection, before validation.
- MCPAdapter._call_mcp_tool and A2AAdapter._call_a2a_tool both apply
  the enricher to the outbound params dict.
- ADCPClient.__init__ installs an enricher that prepends
  adcp_version=self._adcp_version to every outbound request. Caller-
  supplied values on the params dict win over the pin (per-call
  override remains available once generated request types declare
  the field).

Server side:
- capabilities_response() accepts adcp_version, supported_versions,
  build_version. Emits supported_versions (release-precision list)
  and build_version (advisory full semver) on the adcp block, plus
  top-level adcp_version on the response envelope. Legacy
  major_versions still emitted for back-compat through 3.x.
- ADCPServerBuilder's auto-generated get_adcp_capabilities handler
  threads the builder's pinned adcp_version into capabilities_response().

10 new tests in tests/test_adcp_version_wire.py cover envelope
injection, default-value behavior, caller override, capability
response shape, and the auto-capabilities handler.
bokelley and others added 2 commits April 29, 2026 08:32
Post-triage fixes from external review + internal team feedback:

* Extract `adcp_version` and `adcp_major_version` to shared
  `core/version-envelope.json`; compose into all 127 task schemas via
  `allOf $ref`. Single source of truth replaces 127 inline copies of
  the same description, regex, and examples.

* Revert `core/pagination-response.json` field pollution. That schema
  is a fragment composed via `pagination: { $ref: ... }` and is
  `additionalProperties: false`, so adding `adcp_version` there created
  a meaningless validated location at `response.pagination.adcp_version`.

* Resolution algorithm: same-major-but-no-release ≤ buyer's pin now
  returns `VERSION_UNSUPPORTED` instead of asking the seller to
  downshift below its supported window. Sellers do not maintain
  validators they don't ship.

* `build_version` format pinned to a full semver string with optional
  pre-release and build-metadata segments (`3.1.2+vendor.deploy.42`)
  per semver §9–§10. Pattern updated on `error-details/version-unsupported.json`
  and `protocol/get-adcp-capabilities-response.json`.

* Pre-release pin semantics specified: `"3.1-beta"` matches exactly,
  no range resolution; servers MUST NOT downshift `"3.1"` onto a
  pre-release.

* Dual-emit MUST during 3.x: buyers that emit `adcp_version` MUST
  also emit `adcp_major_version` (mirror), and majors disagreeing
  between the two fields returns `VERSION_UNSUPPORTED`. Lets legacy
  3.x sellers keep negotiating off the integer.

* Server response semantics: echoed `adcp_version` is the **release
  served**, never the seller's own latest release. A 3.1 seller
  serving a 3.0 buyer at 3.0 echoes "3.0".

* MCP `protocolVersion` (initialize handshake) vs AdCP `adcp_version`
  (per-call payload) layering: documented as separate concerns in both
  the RFC and the user-facing doc. A2A has no equivalent to MCP
  initialize, which is the reason `adcp_version` rides on the payload.

* Migration cadence tightened to 3.1 SHOULD on both sides → 3.2 MUST
  with compliance grader gating non-echo. Buyer-side pinning is only
  useful once sellers actually echo; leaving response echo at
  RECOMMENDED for two minors stalls the migration.

* `VERSION_UNSUPPORTED` error code description updated to point at
  `error-details/version-unsupported.json` and reference release-precision.

* SDK guidance: `VERSION_UNSUPPORTED` SHOULD raise a typed error, not
  silently auto-retry. Auto-downshift is opt-in, not default.

Net diff vs origin/main is now -488 lines smaller than the prior
inline-duplication shape (3728/-514 → 3240/-1041).

All schema validation tests pass (test:schemas, test:examples,
test:json-schema, test:composed, test:extensions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ompliance

Pull back from MUST in 3.2 to SHOULD on both sides through all of 3.x.
Reasoning:

* The 3.x stability guarantee says fields don't graduate optional →
  required within a major. A spec MUST in 3.2 for a field that didn't
  exist in 3.0 dents that guarantee — a 3.0-built implementation
  shouldn't become non-conformant when 3.2 ships.

* The internal team's intuition — "buyers need echo to key off, leaving
  it RECOMMENDED stalls migration" — is correct. The right lever is
  the AdCP compliance grader (advisory in 3.1, blocking in 3.2 for
  sellers that don't echo). Sellers that want certification ship the
  echo; the spec stays stable.

* Dual-emit pulled back to SHOULD too. It's belt-and-suspenders for
  the transitional period, not a correctness requirement. Disagreement
  between adcp_version and adcp_major_version still MUST return
  VERSION_UNSUPPORTED (correctness — if you read both they have to
  agree).

What stays MUST:
* "Honor adcp_version when present" — semantic correctness for sellers
  that read the field. Sellers that don't read it are unaffected.
* "Don't drop legacy fields early" — backward-compat through 3.x.
* "Cross-major returns VERSION_UNSUPPORTED" — correctness.
* "Disagreeing fields return VERSION_UNSUPPORTED" — correctness.

Migration table updated in both specs/version-negotiation.md and
docs/reference/versioning.mdx; changeset reworded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley and others added 5 commits April 29, 2026 16:37
…legacy-3.0 echo fallback, in-house breadcrumb

Three doc-only fixes from the second expert pass:

* Resolution table gains a row covering server `["3.1-beta", "3.1"]` +
  buyer pin `"3.1"` → exact match wins, never silent downshift to
  pre-release. Worked example for the "release vs pre-release in the
  same MAJOR.MINOR" corner.

* `versioning.mdx` adds explicit guidance for legacy 3.0 sellers that
  don't echo `adcp_version`: buyers SHOULD validate against their pin
  and treat the seller as 3.0-only for capability inference. Closes
  the gap where the SDK fallback path was implicit.

* In-house client breadcrumb at the top of §Bidirectional negotiation
  so cold readers don't trip on "is this required for me?" before
  reaching the migration table 60 lines later.

* "Released-precision" → "Release-precision" typo fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…hemas; add CI lint

The allOf $ref envelope-composition pattern requires permissive
additionalProperties at root. Draft-07 allOf does NOT bypass the
parent schema's additionalProperties — a strict parent rejects the
envelope's fields outright, even though they're declared inside the
$ref'd schema.

17 request schemas under collection/, governance/, property/, and
tmp/ were declaring additionalProperties: false at root. The previous
allOf refactor silently broke those 17 — adcp_version AND legacy
adcp_major_version (which had been inlined before the refactor) were
both rejected. The schema-validation tests didn't catch it because
example payloads don't include the version fields.

Reproduced with Ajv:
  - get-property-list-request.json + payload {list_id, adcp_version: "3.1"}
    → "must NOT have additional properties" (adcp_version)

Fix:
- Flip additionalProperties: false → true on the 17 affected schemas.
- Add CI lint (tests/lint-version-envelope.test.cjs) that fails any
  schema with allOf $ref to version-envelope.json + strict root.
- Wire under npm run test:version-envelope; included in the umbrella
  `npm test` and the `precommit` step.
- Document the invariant in the RFC §1 ("Composition invariant" note).
- Note the behavior change in the changeset.

Strict request validation across these schemas returns at draft 2019-09
via unevaluatedProperties: false — see #3534 for the migration plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… dual-emit timeline

Two clarifications surfaced by the SDK-side implementation review:

* RFC §2 gains a "canonical wire shape" subsection with a valid/invalid
  table. Pattern allows MAJOR.MINOR or MAJOR.MINOR-PRERELEASE; no patch
  component is ever valid on the wire, even alongside pre-release tags.
  SDKs that internally key bundles with full-semver patch strings
  (e.g. "3.1.0-beta.1") MUST normalize to release-precision
  ("3.1-beta.1") before emitting. Internal keying can stay exact;
  the wire is release-only by construction.

* RFC §SDK consequences gains a "Dual-emit timeline" subsection:
  3.x SDKs always dual-emit; 4.0 SDKs emit only adcp_version.
  Decision is determined by which spec major the SDK was built
  against, not by detecting the seller. A 3.x SDK pointing at a 4.0
  server dual-emits and the 4.0 server ignores the integer (allowed
  by additionalProperties: true). A 4.0 SDK pointing at a 3.x server
  emits only the string and the 3.x server reads it directly.

* Doc mirror at versioning.mdx §"Patches are not negotiated" — same
  wire-shape rule with examples of invalid values.

Items 1 (conflict rule), 2 (capability symmetry), 3 (response echo
SHOULD), 5 (VERSION_UNSUPPORTED data shape), 6 (4.0 sunset) from the
review were already in the spec; this commit closes the two real
prose gaps the SDK review surfaced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… wire `adcp_version` (release-precision)

The schema registry, tarball manifests, compliance index, and the
/protocol/ HTTP discovery endpoint all carry a field named
`adcp_version` whose value has always been the full semver of the
published artifact (e.g. "3.1.0-beta.1"). After this PR introduced
the wire `adcp_version` field with release-precision constraint
(e.g. "3.1", "3.1-beta"), an SDK author reading the meta-field and
emitting it on the wire would now get rejected by the wire pattern.

Fix: introduce `published_version` as the preferred name on each
meta-object (full semver, unambiguous). Keep `adcp_version` as a
legacy alias through 3.x for back-compat with @adcp/client (whose
`ComplianceIndex.adcp_version` interface reads it). Both meta-aliases
sunset in 4.0.

Touched:
* static/schemas/source/index.json — emits both fields, note expanded.
* scripts/build-schemas.cjs, scripts/build-compliance.cjs,
  scripts/build-protocol-tarball.cjs — write both fields with comments.
* scripts/update-schema-versions.cjs, scripts/verify-version-sync.cjs
  — read/write `published_version`; treat `adcp_version` as alias.
* server/src/schemas-middleware.ts — /protocol/ discovery endpoint
  emits both fields.
* server/src/addie/mcp/member-tools.ts — comment notes the typed
  `@adcp/client` alias path; switch when upstream type bumps.
* docs/building/schemas-and-sdks.mdx — version-discovery curl example
  uses `published_version` and notes the wire-vs-meta distinction.
* specs/version-negotiation.md — adds "adcp_version is overloaded"
  table covering all three layers (wire / meta / legacy extensions).

The wire field, the meta field, and the legacy extension field all
share the name `adcp_version` for historical reasons. Documenting the
overlap is the safest path; mechanical rename would break the
@adcp/client TypeScript surface in a coordinated-release way that
isn't worth doing inside this PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ct, doc breadcrumbs

Final-pass expert review surfaced three real issues plus several
smaller fixes. All applied here.

Real bugs:

* Resolution table gap-case (RFC §3 + versioning.mdx). Server
  ["3.0", "3.2"] + buyer pin "3.1" fell between rows: row 3's predicate
  "server's max < pin" is false (3.2 < 3.1 false); row 4's "no server
  release ≤ pin" is also false (3.0 ≤ 3.1). No row fired. Predicate
  rewritten to "no exact match, at least one server release ≤ pin"
  which covers both "max < pin" and gap cases unambiguously. Mirrored
  in versioning.mdx.

* TMP privacy contract regression. tmp/identity-match-request.json
  and tmp/context-match-request.json describe a privacy boundary
  enforced by additionalProperties: false. The earlier sweep that
  fixed the strict-additionalProperties regression flipped these to
  permissive, breaking the contract — callers could now smuggle
  arbitrary fields including ext/context lookalikes. Fix: revert
  these two schemas to additionalProperties: false; remove the
  envelope allOf $ref; inline adcp_version and adcp_major_version
  in properties. Verified with Ajv that the schemas now reject
  rogue ext fields while accepting valid adcp_version.

* Governance docstring lie. governance/check-governance-request.json
  plan_id description claimed "Including `account` is rejected by
  `additionalProperties: false`" — but the regression-fix flipped
  root to true. Rewrote to "Governance agents MUST treat any sibling
  `account` field as a contract violation and reject the request"
  (agent-enforced rather than schema-enforced).

Smaller fixes:

* verify-version-sync.cjs now checks BOTH published_version and
  the legacy adcp_version alias against package.json. Hand-edits
  that update only one are caught.

* member-tools.ts:3690 reads published_version with a typed cast,
  falling back to the legacy adcp_version alias to keep typecheck
  green against @adcp/client.ComplianceIndex (which still types
  the legacy field). Eliminates a future migration TODO.

* tests/lint-version-envelope.test.cjs docstring narrowed to
  document its actual scope (root-level allOf only). Schemas
  that need strict-mode (e.g. TMP privacy endpoints) intentionally
  don't compose via allOf and are correctly outside the lint's scope.

* version-envelope.json description gains an explicit "NORMALIZATION"
  sentence: SDKs reading full-semver from bundle metadata MUST
  normalize to release-precision before emitting on the wire.
  Surfaces in generated SDK docstrings.

* versioning.mdx adds a meta-vs-wire breadcrumb after the wire-shape
  paragraph, with a link to schemas-and-sdks#version-discovery for
  the discovery URL.

* build-protocol-tarball.cjs comment phrasing aligned with the
  other build scripts (consistent "@adcp/client.ComplianceIndex"
  reference).

All schema validation tests green: test:schemas (7), test:examples
(34), test:composed (32), test:version-envelope (1), verify-version-sync.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bokelley bokelley merged commit 6eadf06 into main Apr 29, 2026
22 checks passed
@bokelley bokelley deleted the bokelley/version-negotiation-rfc branch April 29, 2026 23:38
bokelley added a commit to adcontextprotocol/adcp-client-python that referenced this pull request Apr 29, 2026
…ssion

Per the merged AdCP version-negotiation spec
(adcontextprotocol/adcp#3493, core/version-envelope.json):

  "SDKs that read full-semver values from bundle metadata
   (e.g. ComplianceIndex.published_version = '3.1.0-beta.1') MUST
   normalize to release-precision ('3.1-beta.1') before emitting on
   the wire — meta-field values are NOT valid wire values."

The packaged ADCP_VERSION file ships full-semver ('3.0.0' today). We
were passing it through unchanged to the wire, which is non-compliant.

Adds normalize_to_release_precision() to _version.py and applies it
in resolve_adcp_version() so:

- Patch-precision input ('3.0.0', '3.0.1') → stored/emitted as '3.0'
- Release-precision input ('3.0', '3.1-beta') → unchanged
- Pre-release tags preserved ('3.1.0-rc.1' → '3.1-rc.1')

get_adcp_version() returns the normalized form regardless of what
the caller passed — wire values are the canonical form.

13 new normalization tests; existing tests updated to assert
normalized output where the input was patch-precision.
bokelley added a commit to adcontextprotocol/adcp-client-python that referenced this pull request Apr 30, 2026
Adds Stripe-style per-instance protocol-version pinning. Each
ADCPClient / ADCPMultiAgentClient / ADCPServerBuilder accepts an
adcp_version constructor option (release-precision string, e.g.
"3.0", "3.1", "3.1-beta"). Default = the SDK's compile-time pin
(ADCP_VERSION packaged with the wheel). Each surface exposes
get_adcp_version().

Stage 2 is plumbing only — the value is stored per-instance and
exposed via the getter; no wire emission yet. Stage 3 lifts the
cross-major fence and threads the pin through schema/validator
selection and outbound wire emission. The protocol RFC for the
matching wire field is filed at adcontextprotocol/adcp#3493.

Cross-major pins raise ConfigurationError at construction —
within major 3 every accepted pin agrees with the wire's
ADCP_MAJOR_VERSION constant; no silent drift. Patch-precision
strings ("3.0.1") are accepted for backwards compatibility but
the spec defines negotiation at release precision only.

44 new tests in tests/test_adcp_version_option.py cover defaults,
valid pins, cross-major rejection, unparseable strings, and all
four constructor surfaces.
bokelley added a commit to adcontextprotocol/adcp-client-python that referenced this pull request Apr 30, 2026
Lifts the per-instance adcp_version pin from plumbing-only (Stage 2)
to actual wire emission. Builds against the upstream RFC at
adcontextprotocol/adcp#3493 — assumes that lands.

Client side:
- ProtocolAdapter gains envelope_enricher hook + _enrich_outgoing_params
  helper. Hook runs after idempotency injection, before validation.
- MCPAdapter._call_mcp_tool and A2AAdapter._call_a2a_tool both apply
  the enricher to the outbound params dict.
- ADCPClient.__init__ installs an enricher that prepends
  adcp_version=self._adcp_version to every outbound request. Caller-
  supplied values on the params dict win over the pin (per-call
  override remains available once generated request types declare
  the field).

Server side:
- capabilities_response() accepts adcp_version, supported_versions,
  build_version. Emits supported_versions (release-precision list)
  and build_version (advisory full semver) on the adcp block, plus
  top-level adcp_version on the response envelope. Legacy
  major_versions still emitted for back-compat through 3.x.
- ADCPServerBuilder's auto-generated get_adcp_capabilities handler
  threads the builder's pinned adcp_version into capabilities_response().

10 new tests in tests/test_adcp_version_wire.py cover envelope
injection, default-value behavior, caller override, capability
response shape, and the auto-capabilities handler.
bokelley added a commit to adcontextprotocol/adcp-client-python that referenced this pull request Apr 30, 2026
…ssion

Per the merged AdCP version-negotiation spec
(adcontextprotocol/adcp#3493, core/version-envelope.json):

  "SDKs that read full-semver values from bundle metadata
   (e.g. ComplianceIndex.published_version = '3.1.0-beta.1') MUST
   normalize to release-precision ('3.1-beta.1') before emitting on
   the wire — meta-field values are NOT valid wire values."

The packaged ADCP_VERSION file ships full-semver ('3.0.0' today). We
were passing it through unchanged to the wire, which is non-compliant.

Adds normalize_to_release_precision() to _version.py and applies it
in resolve_adcp_version() so:

- Patch-precision input ('3.0.0', '3.0.1') → stored/emitted as '3.0'
- Release-precision input ('3.0', '3.1-beta') → unchanged
- Pre-release tags preserved ('3.1.0-rc.1' → '3.1-rc.1')

get_adcp_version() returns the normalized form regardless of what
the caller passed — wire values are the canonical form.

13 new normalization tests; existing tests updated to assert
normalized output where the input was patch-precision.
bokelley added a commit to adcontextprotocol/adcp-client-python that referenced this pull request Apr 30, 2026
…a) (#294)

* feat(sdk): per-instance adcp_version constructor option (Stage 2)

Adds Stripe-style per-instance protocol-version pinning. Each
ADCPClient / ADCPMultiAgentClient / ADCPServerBuilder accepts an
adcp_version constructor option (release-precision string, e.g.
"3.0", "3.1", "3.1-beta"). Default = the SDK's compile-time pin
(ADCP_VERSION packaged with the wheel). Each surface exposes
get_adcp_version().

Stage 2 is plumbing only — the value is stored per-instance and
exposed via the getter; no wire emission yet. Stage 3 lifts the
cross-major fence and threads the pin through schema/validator
selection and outbound wire emission. The protocol RFC for the
matching wire field is filed at adcontextprotocol/adcp#3493.

Cross-major pins raise ConfigurationError at construction —
within major 3 every accepted pin agrees with the wire's
ADCP_MAJOR_VERSION constant; no silent drift. Patch-precision
strings ("3.0.1") are accepted for backwards compatibility but
the spec defines negotiation at release precision only.

44 new tests in tests/test_adcp_version_option.py cover defaults,
valid pins, cross-major rejection, unparseable strings, and all
four constructor surfaces.

* feat(sdk): wire emission for adcp_version pin (Stage 3a)

Lifts the per-instance adcp_version pin from plumbing-only (Stage 2)
to actual wire emission. Builds against the upstream RFC at
adcontextprotocol/adcp#3493 — assumes that lands.

Client side:
- ProtocolAdapter gains envelope_enricher hook + _enrich_outgoing_params
  helper. Hook runs after idempotency injection, before validation.
- MCPAdapter._call_mcp_tool and A2AAdapter._call_a2a_tool both apply
  the enricher to the outbound params dict.
- ADCPClient.__init__ installs an enricher that prepends
  adcp_version=self._adcp_version to every outbound request. Caller-
  supplied values on the params dict win over the pin (per-call
  override remains available once generated request types declare
  the field).

Server side:
- capabilities_response() accepts adcp_version, supported_versions,
  build_version. Emits supported_versions (release-precision list)
  and build_version (advisory full semver) on the adcp block, plus
  top-level adcp_version on the response envelope. Legacy
  major_versions still emitted for back-compat through 3.x.
- ADCPServerBuilder's auto-generated get_adcp_capabilities handler
  threads the builder's pinned adcp_version into capabilities_response().

10 new tests in tests/test_adcp_version_wire.py cover envelope
injection, default-value behavior, caller override, capability
response shape, and the auto-capabilities handler.

* fix(sdk): normalize adcp_version to release-precision before wire emission

Per the merged AdCP version-negotiation spec
(adcontextprotocol/adcp#3493, core/version-envelope.json):

  "SDKs that read full-semver values from bundle metadata
   (e.g. ComplianceIndex.published_version = '3.1.0-beta.1') MUST
   normalize to release-precision ('3.1-beta.1') before emitting on
   the wire — meta-field values are NOT valid wire values."

The packaged ADCP_VERSION file ships full-semver ('3.0.0' today). We
were passing it through unchanged to the wire, which is non-compliant.

Adds normalize_to_release_precision() to _version.py and applies it
in resolve_adcp_version() so:

- Patch-precision input ('3.0.0', '3.0.1') → stored/emitted as '3.0'
- Release-precision input ('3.0', '3.1-beta') → unchanged
- Pre-release tags preserved ('3.1.0-rc.1' → '3.1-rc.1')

get_adcp_version() returns the normalized form regardless of what
the caller passed — wire values are the canonical form.

13 new normalization tests; existing tests updated to assert
normalized output where the input was patch-precision.

* fix(sdk): code review polish on adcp_version pin

Addresses code-reviewer findings on PR #294:

- Re-export ConfigurationError from adcp.__init__ so callers can
  import it from the public package surface (matches every other
  ADCPError subclass).
- Accept SemVer build metadata (3.0.1+canary, 3.1.0-beta+sha.5) on
  the regex; strip it on wire emission alongside patch. Build
  metadata is purely a build identifier and never part of a contract.
- Document the caller-wins precedence on per-call params dict in
  ADCPClient.__init__'s adcp_version section.
- Drop dead `if TYPE_CHECKING: pass` block in _version.py.

Tests: 3 new build-metadata normalization cases. 70/70 passing.

* docs(sdk): dx-expert polish on adcp_version pin

Addresses dx-expert findings on PR #294:

- ConfigurationError docstring trimmed from past-tense narrative to
  the actionable rule: install the SDK major that targets the wire
  version you want. Staging history lives in _version.py module
  docstring (where it belongs).
- force_a2a_version docstring gains explicit cross-reference: "Not
  for AdCP protocol pinning — see adcp_version for that." The two
  string-shaped version kwargs sit side-by-side; agents skimming
  the signature will guess wrong without the disambiguation.
- adcp_version docstring documents the migration from the legacy
  adcp_major_version (integer) wire field — both coexist on the
  wire through 3.x, servers prefer the new field, generated request
  types still expose the legacy field until tracked schema sync
  (issue #306) lands.

* feat(sdk): per-agent adcp_version map on ADCPMultiAgentClient

Addresses adtech-product-expert finding: a holdco trade desk almost
always has one seller on a newer release than the others during
rollout. Forcing all sub-clients to share a uniform pin makes the
multi-client useless the moment one seller ships a new release.

ADCPMultiAgentClient now accepts adcp_version: str | dict[str, str] | None:

- None → every agent uses the SDK default (unchanged).
- str → every agent uses this pin (unchanged).
- dict[str, str] → per-agent override map. Agents missing from the
  map fall back to the SDK default. Each entry is independently
  validated; cross-major in any entry raises ConfigurationError.

multi.get_adcp_version() returns the uniform pin when all agents
agree (including the all-same dict case); raises ValueError with the
per-agent map in the message when pins are heterogeneous, pointing
callers at multi.agent(id).get_adcp_version() for per-agent reads.

Also addresses python-expert findings:

- responses.py: build_version emit guard tightened from `if x:` to
  `is not None` for consistency with the rest of the function.
- protocols/base.py: envelope_enricher docstring documents the
  contract that top-level Request models must declare extra="allow"
  (so the injected adcp_version key passes the post-enrichment
  schema validator).

6 new tests covering per-agent map, fall-back, heterogeneous
get_adcp_version() behavior, and cross-major rejection within a map.
75/75 passing.

* chore(types): regen public API snapshot for ConfigurationError

Snapshot test caught the new public symbol added in commit ebed151
(re-export ConfigurationError from adcp.__init__). Regenerated via
scripts/regenerate_public_api_snapshot.py — addition is intentional.
bokelley added a commit to adcontextprotocol/adcp-client that referenced this pull request Apr 30, 2026
)

* fix(protocols): caller args win over SDK version envelope (#1072)

`ProtocolClient.callTool` was spreading the wire version envelope after
caller `args`, silently rewriting any `adcp_major_version` /
`adcp_version` the caller put in `args` with the SDK's own pin. This
broke the bundled `error-compliance.yaml` `unsupported_major_version`
storyboard step (sends `adcp_major_version: 99` to elicit
`VERSION_UNSUPPORTED`) and any conformance harness using the SDK as
buyer transport to probe seller version negotiation.

- Reverse spread order at all four wire-injection sites (in-process
  MCP, HTTP MCP, A2A, both factory functions) so caller args win.
- Add `adcp_version` to `ADCP_ENVELOPE_FIELDS` so the same
  caller-supplied value survives `SingleAgentClient`'s per-tool
  schema-strip path (mirrors the existing `adcp_major_version`
  preservation).
- Replace the pseudo-test that asserted spread-order on inline
  literals with two real regression tests over the in-process MCP
  path: one for default injection, one for caller override.

Stale dual-field drift is still caught at the server boundary by
`createAdcpServer`'s field-disagreement check (spec PR
`adcontextprotocol/adcp#3493`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: sync LIBRARY_VERSION to 5.25.0

Release PR #1068 bumped package.json to 5.25.0 but the auto-generated
src/lib/version.ts still reported 5.24.0. The npm-published 5.25.0
SDK consequently mis-reports its own version through `LIBRARY_VERSION`
and `VERSION_INFO.library`. Re-running `sync-version.ts` against the
current package.json corrects both fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: factor applyVersionEnvelope helper for all 4 wire sites

Address expert-review asks on PR #1073:

- Single chokepoint (`applyVersionEnvelope`) so a future refactor can't
  silently flip the spread order on one branch and leave the other
  three intact (code-reviewer ask). Helper is exported.
- New regression tests cover: caller-wins, envelope-fills-gaps, the
  asymmetric case (caller integer 99 + SDK fills 3.1 string — exercises
  the server-side disagreement-check path the protocol-expert flagged),
  caller `adcp_version` override, and the v2 empty-envelope case.
- New strip-path test asserts `ADCP_ENVELOPE_FIELDS` contains both
  `adcp_major_version` and `adcp_version` so a caller-supplied 3.1+
  string survives `SingleAgentClient`'s schema-strip path.
- Changeset reframed to lead with the 5.24/5.25 → 5.26 behavior change
  and explicitly cite `createAdcpServer`'s field-disagreement check
  (5.25) as the reason caller-wins is now safe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(protocols): mark applyVersionEnvelope @internal

The helper is exported for testing but is not intended as a stable
public API — `ProtocolClient` and the two factory functions are the
only legitimate callers. @internal signals to TypeDoc / api-extractor
that consumers should not depend on this surface directly.

https://claude.ai/code/session_012cM32L5HpXygCB6qqvYGXd

* chore: keep applyVersionEnvelope off the public TypeScript surface

The @internal tag (commit 8fdd2b5) drops applyVersionEnvelope from
dist/lib/protocols/index.d.ts, but the explicit re-export in
src/lib/index.ts pulled it back into dist/lib/index.d.ts as part of
the public API. Drop the re-export and have the test import from
dist/lib/protocols/index.js so it never re-attaches.

Verified: applyVersionEnvelope is absent from both dist/lib/index.d.ts
and dist/lib/protocols/index.d.ts after this change; runtime export
remains for the regression tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley added a commit to adcontextprotocol/adcp-client that referenced this pull request Apr 30, 2026
* feat(server): single-field VERSION_UNSUPPORTED check (#1075)

Closes the spec-conformance gap surfaced during PR #1073 review.
`createAdcpServer`'s field-disagreement check (PR #1067) only fired
when both `adcp_version` and `adcp_major_version` were present and
the majors disagreed. A buyer sending only `adcp_major_version: 99`
(or only `adcp_version: "99.0"`) bypassed the cross-check and the
value rode into the handler unvalidated.

Server-side:

- New file-private helpers `getAdvertisedSupportedMajors` and
  `buildSupportedVersionsList`. Union the parsed majors from
  `capConfig.major_versions` (deprecated integer list) and
  `capConfig.supported_versions` (release-precision strings, AdCP 3.1+
  per spec PR adcontextprotocol/adcp#3493), with fallback to the
  server pin's major when both lists are absent.
- New single-field rejection runs after the existing dual-field
  check. Resolves the effective major from whichever envelope field
  the buyer set, returns VERSION_UNSUPPORTED with
  `details.supported_versions` populated when the major is outside
  the seller's advertised window.
- Dual-field check also gains the `details.supported_versions` echo
  so buyers can downgrade and retry after either kind of failure.
- New `AdcpCapabilitiesConfig.supported_versions?: string[]` for 3.1+
  sellers to declare release-precision strings.

Conformance runner (test isolation fix):

- `runToolFuzz` overwrites `adcp_major_version` / `adcp_version` on
  each sample before dispatch. The buyer SDK already auto-fills these
  via `applyVersionEnvelope` (PR #1073); without the runner pin, the
  schema-driven 1-99 integer arb would generate out-of-window claims
  on most samples and trigger the new server check before any
  handler bug could surface. Pinning at the runner (not the
  arbitrary) keeps `schemaToArbitrary` pure and existing
  schema-validity threshold tests undisturbed.

Tests:

- New `version-unsupported-server.test.js` (9 tests) covers
  single-field integer + string rejections, the 3.1-style
  `supported_versions: ['3.1.0']` seller path, multi-version sellers,
  fallback-to-server-pin, and regression coverage for the dual-field
  disagreement check.

Combined with #1073, fully unblocks the storyboard skip in
adcontextprotocol/adcp#3626 — the framework's own seller fixture
now passes the bundled `error_compliance/unsupported_major_version`
step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(server): address bokelley review comments on #1080

- Add JSDoc note to `buildSupportedVersionsList` explaining why strings
  are preferred over integers (asymmetric precedence vs
  `getAdvertisedSupportedMajors`).
- Replace hardcoded `adcp_major_version: 3, adcp_version: '3.0'` pin in
  `runToolFuzz` with `ADCP_MAJOR_VERSION` (single-field; no string drift).
- Document the additive `details.supported_versions` behavior change on
  the dual-field path in the changeset.

https://claude.ai/code/session_0184yyiQfCKErdpCxm4PjxnX

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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