feat(sdk): per-instance adcp_version pin + wire emission (Stage 2 + 3a)#294
Merged
feat(sdk): per-instance adcp_version pin + wire emission (Stage 2 + 3a)#294
Conversation
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.
e967395 to
ebed151
Compare
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/clientPR #1044, extended one stage further. Built against adcontextprotocol/adcp#3493 — merged 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 legacyADCP_VERSIONfile shape.ConfigurationErroradded tosrc/adcp/exceptions.pyfor cross-major and unparseable pins.ADCPClient,ADCPMultiAgentClient,ADCPServerBuilder,adcp_server()factory. Each exposesget_adcp_version() -> str.Stage 3a — Wire emission (commit 2)
Client side:
ProtocolAdaptergainsenvelope_enricherhook +_enrich_outgoing_params()helper. Runs after idempotency injection, before schema validation.MCPAdapter._call_mcp_toolandA2AAdapter._call_a2a_toolboth apply the enricher to outbound params.ADCPClient.__init__installs an enricher that prependsadcp_version=self._adcp_versionto every outbound request. Caller-supplied values on the params dict win — the enricher is the default, not an override.Server side:
capabilities_response()acceptsadcp_version,supported_versions,build_version. Emitssupported_versions(release-precision list, authoritative for buyer pinning) andbuild_version(advisory full semver, for incident triage) on the adcp block, plus top-leveladcp_versionon the response envelope.major_versionsstill emitted for back-compat through 3.x.ADCPServerBuilder's auto-generatedget_adcp_capabilitieshandler threads the builder's pinnedadcp_versionintocapabilities_response().Spec-conformance fix — release-precision normalization (commit 3)
The merged spec (
core/version-envelope.json) requires:The packaged
ADCP_VERSIONfile ships full-semver (\"3.0.0\"today). Stage 2/3a were passing it through unchanged, which is non-compliant.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.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/— cleanmypy src/adcp/— no new errors (96 pre-existing, unchanged)pytest tests/test_adcp_version_*.py -v— 67/67 passtests/test_client.py,tests/test_capabilities.py,tests/test_helpers.py— no regressionsOut of scope (Stage 3b, separate PR)
latest.tgz— when run against the post-merge dev snapshot, codegen drift from the asset-union refactor (3.0.2-beta.0) eliminates*1discriminator artifacts thataliases.pyreferences. That sync requires its own PR for the alias-rewire work, separate from version-negotiation. Tracked.schemas/cache/<version>/layout,SchemaRegistryreplacing the module-level_statesingleton invalidation/schema_loader.py.adcp_versionand validate against that release's schema. Today the response field passes through onTaskResult.data[\"adcp_version\"].adcp_versionfrom the request envelope to handlers viatool_context.version_unsupportederror classification — Python-side wrapper around the new error data shape from the protocol PR.How to use it