Skip to content

feat(sdk): per-instance adcp_version pin + wire emission (Stage 2 + 3a)#294

Merged
bokelley merged 7 commits intomainfrom
bokelley/port-pr-1044
Apr 30, 2026
Merged

feat(sdk): per-instance adcp_version pin + wire emission (Stage 2 + 3a)#294
bokelley merged 7 commits intomainfrom
bokelley/port-pr-1044

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

@bokelley bokelley commented Apr 29, 2026

Summary

Adds Stripe-style per-instance protocol-version pinning to the Python SDK and wires it onto the wire. Buyers pin once at construction and the SDK auto-injects adcp_version (release-precision string) on every outbound request and emits it from server capability responses.

This is the Python analog of @adcp/client PR #1044, extended one stage further. Built against adcontextprotocol/adcp#3493merged 2026-04-29, shipping in the next 3.0.x patch release.

Stages in this PR

Stage 2 — Constructor option + getter (commit 1)

  • src/adcp/_version.py — new module. COMPATIBLE_ADCP_VERSIONS = (\"3.0\", \"3.1\"), ADCP_MAJOR_VERSION = 3, parse_adcp_major_version(), resolve_adcp_version(). Release-precision canonical; patch-precision (\"3.0.1\") accepted for back-compat with the legacy ADCP_VERSION file shape.
  • ConfigurationError added to src/adcp/exceptions.py for cross-major and unparseable pins.
  • All four constructor surfaces wired: ADCPClient, ADCPMultiAgentClient, ADCPServerBuilder, adcp_server() factory. Each exposes get_adcp_version() -> str.

Stage 3a — Wire emission (commit 2)

Client side:

  • ProtocolAdapter gains envelope_enricher hook + _enrich_outgoing_params() helper. Runs after idempotency injection, before schema validation.
  • MCPAdapter._call_mcp_tool and A2AAdapter._call_a2a_tool both apply the enricher to outbound params.
  • ADCPClient.__init__ installs an enricher that prepends adcp_version=self._adcp_version to every outbound request. Caller-supplied values on the params dict win — the enricher is the default, not an override.

Server side:

  • capabilities_response() accepts adcp_version, supported_versions, build_version. Emits supported_versions (release-precision list, authoritative for buyer pinning) and build_version (advisory full semver, for incident triage) 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().

Spec-conformance fix — release-precision normalization (commit 3)

The merged spec (core/version-envelope.json) requires:

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). Stage 2/3a were passing it through unchanged, which is non-compliant.

  • New normalize_to_release_precision() helper in _version.py: \"3.0.0\"\"3.0\", \"3.0.1\"\"3.0\", \"3.1.0-rc.1\"\"3.1-rc.1\", release-precision unchanged.
  • Applied in resolve_adcp_version() so the stored pin and wire emission are always release-precision regardless of input shape.
  • get_adcp_version() returns the normalized form.

Tests

67 new tests across two files — all passing. Pre-existing test sweep clean.

  • tests/test_adcp_version_option.py — 57 tests: defaults, valid pins, cross-major rejection, unparseable strings, normalization (patch → release), pre-release tag preservation, all four constructor surfaces.
  • tests/test_adcp_version_wire.py — 10 tests: envelope injection, caller override, capability response shape, auto-capabilities handler.

Validation

  • ruff check src/ — clean
  • mypy src/adcp/ — no new errors (96 pre-existing, unchanged)
  • pytest tests/test_adcp_version_*.py -v — 67/67 pass
  • Sanity sweep on tests/test_client.py, tests/test_capabilities.py, tests/test_helpers.py — no regressions

Out of scope (Stage 3b, separate PR)

  • Schema sync from upstream latest.tgz — when run against the post-merge dev snapshot, codegen drift from the asset-union refactor (3.0.2-beta.0) eliminates *1 discriminator artifacts that aliases.py references. That sync requires its own PR for the alias-rewire work, separate from version-negotiation. Tracked.
  • Per-version schema cache — version-keyed schemas/cache/<version>/ layout, SchemaRegistry replacing the module-level _state singleton in validation/schema_loader.py.
  • Response-echo validation — read response's adcp_version and validate against that release's schema. Today the response field passes through on TaskResult.data[\"adcp_version\"].
  • Server handler-side dispatch — surface the buyer's pinned adcp_version from the request envelope to handlers via tool_context.
  • Per-version Pydantic generator — Stripe-style latest-superset rules so a single Pydantic surface accepts response shapes from any supported release.
  • version_unsupported error classification — Python-side wrapper around the new error data shape from the protocol PR.

How to use it

from adcp import ADCPClient
from adcp.types import AgentConfig, Protocol

client = ADCPClient(
    AgentConfig(id=\"seller\", agent_uri=\"https://...\", protocol=Protocol.MCP),
    adcp_version=\"3.1\",  # release-precision pin
)

client.get_adcp_version()  # \"3.1\"
# Every outbound request now carries adcp_version=\"3.1\" on the wire.
from adcp.server.builder import adcp_server

server = adcp_server(\"my-seller\", adcp_version=\"3.1\")

@server.get_products
async def get_products(params, context=None):
    return {\"products\": [...]}

# Auto-generated get_adcp_capabilities now emits:
#   { \"adcp_version\": \"3.1\",
#     \"adcp\": { \"major_versions\": [3], \"supported_versions\": [\"3.1\"], ... } }

@bokelley bokelley changed the title feat(sdk): per-instance adcp_version constructor option (Stage 2) feat(sdk): per-instance adcp_version pin + wire emission (Stage 2 + 3a) Apr 29, 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.
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.
…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.
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.
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.
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.
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 bokelley merged commit 4aa7a6d into main Apr 30, 2026
11 checks passed
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