Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 50 additions & 5 deletions MIGRATION_ADCP_3.1_TO_3.2.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,12 @@ prevent a deployment from silently changing contracts when 3.2 stable ships.
|---|---|
| Product feed/read | `list_products` |
| Direct buy | `list_products`, `buy_products`, `control_media_buy` |
| Proposal buy | `request_proposals`, `refine_proposals`, `decline_proposals`, `accept_proposal` |
| Proposal buy | `list_products`, `request_proposals`, `refine_proposals`, `decline_proposals`, `accept_proposal`, `control_media_buy` |
| Full compact | all proposal tools plus `buy_products` |

Sellers can combine these subsets. Declare exactly what is implemented:
Proposal-only sellers do not need to advertise `buy_products`. Sellers can
combine the direct and proposal subsets when they support both paths. Declare
exactly what is implemented:

```python
from adcp.decisioning import DecisioningCapabilities, DecisioningPlatform
Expand Down Expand Up @@ -97,28 +100,70 @@ same tool name and idempotency key; never retry `buy_products` as
control requests do not accept inline creatives—use the dedicated creative
lifecycle.

## Use version-scoped public models

The unqualified `adcp.types` namespace tracks the SDK's current 3.2 beta
surface. Applications that keep 3.0, 3.1, and 3.2 peers in the same process
can import schema-backed Pydantic models from the release namespace:

```python
from adcp.types.v31 import ListCreativesRequest as ListCreativesRequest31
from adcp.types.v32 import ListCreativesRequest as ListCreativesRequest32

legacy = ListCreativesRequest31(include_assignments=True)
current = ListCreativesRequest32(
include_assignments=True,
assignment_projection="matching",
)
```

These dict-shaped Pydantic models accept keyword construction, top-level
attribute access, `model_dump()`, and `model_json_schema()`, materialize
top-level schema defaults, and validate against the complete bundled
versioned JSON Schema. Nested values remain plain dictionaries; use the
unqualified current-version models when deeply typed nested objects are more
important than multi-version isolation. Async variants are available as
`SubmittedResponse`, `WorkingResponse`, and `InputRequiredResponse` suffixes.
`adcp.types.v30` provides the same surface for 3.0. A server
created with `adcp_server(..., adcp_version=...)` also uses that bundle for
MCP `tools/list`; tools absent from the pinned release are not advertised.
Class-based servers can pass `adcp_version=` to `create_mcp_tools()`.

## Select the request-signing profile

AdCP 3.2 tightens RFC 9421 handling: `Signature` Structured Fields binary
values use standard padded Base64, and every signed body-bearing request covers
`content-digest`. The SDK signer defaults to the 3.2 wire format. Select a
legacy profile only when negotiating with a 3.0/3.1 peer:
`content-digest`. `ADCPClient` derives the signing profile from its trusted
`server_version` / `adcp_version` pin. An explicit profile is only needed to
override that negotiation, or when calling a low-level signer that has no
client pin:

```python
from adcp.signing import SigningConfig, VerifyOptions
from adcp.signing import SigningConfig, VerifyOptions, sign_request

legacy_buyer = SigningConfig(
private_key=key,
key_id="buyer-key",
signing_profile_version="3.1",
)

legacy_headers = sign_request(
...,
signing_profile_version="3.1",
)

strict_3_2_verifier = VerifyOptions(
...,
signing_profile_version="3.2",
)
```

For profile 3.2, low-level signers automatically cover `content-digest` on a
non-empty body when the coverage argument is omitted and reject an explicit
`cover_content_digest=False`. A 3.2 capability that advertises digest coverage
as forbidden is internally inconsistent and is rejected rather than producing
a non-conformant signature.

Choose the verifier profile from trusted endpoint configuration and negotiated
capabilities, never from an unsigned request-body field. The default verifier
profile remains 3.1-compatible so existing deployments do not begin rejecting
Expand Down
28 changes: 28 additions & 0 deletions MIGRATION_v7_to_v8.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,34 @@ AdCP 3.2.0-beta.0 and adds the compact product/media-buy lifecycle. The old
for lifecycle selection, capability declarations, and the compatibility test
matrix.

## Brand identity imports

The generated `adcp.types.generated_poc.brand.Brand` path was private and is
no longer stable under the 3.2 code-generation layout. Import the collision-
safe semantic model instead:

```python
from adcp.types import BrandIdentity

brand = BrandIdentity(id="acme", names=[{"en": "Acme"}])
```

`BrandIdentity` models a `brand.json` brand entry and preserves the 29-field
shape exposed by SDK 7. It is distinct from the unrelated `Brand` capability
model and from `GetBrandIdentitySuccessResponse`, which is a task response.

## Request signing profiles

`ADCPClient` now derives request-signature encoding from the effective trusted
wire pin (`server_version`, then `adcp_version`). Low-level `sign_request()`
and `async_sign_request()` calls must pass `signing_profile_version`
explicitly because they have no negotiation context. Profile 3.2 signs every
non-empty body with `content-digest` and rejects an explicit request to omit
that coverage. Request signing supports AdCP 3.0 through 3.2; constructing a
signed client pinned to an older protocol version fails immediately unless an
explicit supported signing profile is supplied. See
[the request-signing migration guide](docs/request-signing-migration.md).

SDK 8 makes the legacy `ADCPClient.handle_webhook()` convenience path fail
closed. Calls without a configured `webhook_secret` no longer accept unsigned
MCP callbacks.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1334,6 +1334,7 @@ signed = sign_request(
key_id="adcp-ed25519-20260418",
alg="ed25519",
cover_content_digest=True, # required by sellers that set covers_content_digest="required"
signing_profile_version="3.2", # required for the low-level signer
)
httpx.post(url, content=body, headers={**headers, **signed.as_dict()})
```
Expand Down Expand Up @@ -1368,6 +1369,7 @@ install_signing_event_hook(
client,
signing=signing,
seller_capability=seller_caps.request_signing,
adcp_version="3.2",
)

async with client:
Expand Down
10 changes: 10 additions & 0 deletions docs/request-signing-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ This guide covers the operator-facing mechanics. Spec reference: [Signed Request

The Python SDK ships parallel ergonomics to [adcp-go's MIGRATION guide](https://github.com/adcontextprotocol/adcp-go/blob/main/adcp/signing/MIGRATION.md) — same staged rollout, same key-rotation pattern, different language idioms.

`ADCPClient` derives the signing wire profile from its trusted
`server_version` / `adcp_version` pin. An explicit
`SigningConfig(signing_profile_version=...)` overrides that selection. The
low-level `sign_request()` and `async_sign_request()` primitives require an
explicit profile because they do not participate in version negotiation.
The supported signing profiles are 3.0, 3.1, and 3.2. A signed client pinned
to an older AdCP version therefore fails at construction unless
`signing_profile_version` explicitly selects a supported profile; unsigned
legacy clients are unaffected.

## 1. Bootstrap

One-time work to make an agent able to sign (as a buyer) or verify (as a seller).
Expand Down
3 changes: 3 additions & 0 deletions src/adcp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ def _resolve_version() -> str:
"VideoContent",
"WebhookContent",
"AuthorizationRequiredDetails",
"BrandIdentity",
"BrandReference",
"BrandSource",
"BriefAsset",
Expand Down Expand Up @@ -1124,6 +1125,7 @@ def get_adcp_version() -> str:
"AdvertiserIndustry",
"ArtifactWebhookPayload",
"AudienceSource",
"BrandIdentity",
"BrandReference",
"BrandSource",
"BuyingMode",
Expand Down Expand Up @@ -1549,6 +1551,7 @@ def get_adcp_version() -> str:
AudioContent,
AuthorizationRequiredDetails,
# Core domain types
BrandIdentity,
BrandReference,
BrandSource,
# Creative Operations
Expand Down
12 changes: 11 additions & 1 deletion src/adcp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
from adcp.signing.autosign import (
SigningConfig,
operation_needs_signing,
signing_profile_for_adcp_version,
)
from adcp.signing.autosign import (
current_operation as _signing_current_operation,
Expand Down Expand Up @@ -680,6 +681,12 @@ def __init__(
# override — so per-call overrides remain available once the
# generated request types declare the field.
_pinned_version = self._server_version or self._adcp_version
self._signing_profile_version = (
None
if signing is None
else signing.signing_profile_version
or signing_profile_for_adcp_version(_pinned_version)
)

def _inject_adcp_version(params: dict[str, Any]) -> dict[str, Any]:
return {"adcp_version": _pinned_version, **params}
Expand Down Expand Up @@ -1151,6 +1158,9 @@ async def _sign_outgoing_request(self, request: httpx.Request) -> None:
cover_digest = True

body = request.content
signing_profile_version = self._signing_profile_version
if signing_profile_version is None: # pragma: no cover - constructor invariant
raise RuntimeError("request signing profile was not initialized")
signed = sign_request(
method=request.method,
url=str(request.url),
Expand All @@ -1161,7 +1171,7 @@ async def _sign_outgoing_request(self, request: httpx.Request) -> None:
alg=self.signing.alg,
cover_content_digest=cover_digest,
tag=self.signing.tag,
signing_profile_version=self.signing.signing_profile_version,
signing_profile_version=signing_profile_version,
)
# pop-then-set ensures our signed values are authoritative even if
# another hook or earlier layer added a same-named header. httpx
Expand Down
5 changes: 4 additions & 1 deletion src/adcp/server/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,10 @@ async def auto_capabilities(params: Any, context: Any = None) -> dict[str, Any]:
# subclass — callers who want typed context go through the
# class-based ``ADCPHandler[MyContext]`` route instead.
class DynamicHandler(ADCPHandler[Any]):
pass
_adcp_version = self._adcp_version

def get_adcp_version(self) -> str:
return self._adcp_version

# The decorator framework's primary handler surface is canonical.
# Keep that server-owned fact separate from negotiated discovery
Expand Down
93 changes: 85 additions & 8 deletions src/adcp/server/mcp_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,11 @@
from adcp.types.error_narrowing import narrow_union_errors
from adcp.validation.client_hooks import UnknownFieldPolicy, ValidationHookConfig
from adcp.validation.envelope import DEFAULT_UNNEGOTIATED_ADCP_VERSION
from adcp.validation.schema_loader import get_validator
from adcp.validation.schema_loader import (
get_mcp_schema,
get_validator,
list_validator_keys,
)

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -2089,10 +2093,33 @@ def _is_method_overridden(handler_cls: type, method_name: str) -> bool:
return not getattr(subclass_handle, "__isabstractmethod__", False)


def _resolve_handler_adcp_version(
instance: ADCPHandler[Any] | None,
explicit_version: str | None,
) -> str | None:
"""Resolve one trusted server pin for both discovery and dispatch."""
if explicit_version is not None:
return explicit_version
if instance is None:
return None
getter = getattr(instance, "get_adcp_version", None)
if callable(getter):
try:
candidate = getter()
except Exception:
candidate = None
if isinstance(candidate, str):
return candidate
candidate = getattr(instance, "_adcp_version", None)
return candidate if isinstance(candidate, str) else None


def get_tools_for_handler(
handler: ADCPHandler[Any] | type[ADCPHandler[Any]],
*,
advertise_all: bool = False,
adcp_version: str | None = None,
_include_schemas: bool = True,
) -> list[dict[str, Any]]:
"""Return tool definitions the handler will actually answer.

Expand Down Expand Up @@ -2124,6 +2151,11 @@ def get_tools_for_handler(
handler: The handler instance or class.
advertise_all: When True, skip the override-based filter and
advertise every tool allowed for the handler type.
adcp_version: Trusted server protocol pin used to select request and
response schemas. When omitted for an instance, the handler's
``get_adcp_version()`` / ``_adcp_version`` pin is used when
available. Class-only introspection retains the current generated
model surface.

Returns:
Filtered list of tool definitions.
Expand Down Expand Up @@ -2177,12 +2209,43 @@ def get_tools_for_handler(
if tool["name"] in always_on or _is_method_overridden(cls, tool["name"])
]

# Pydantic schema generation is expensive for the full AdCP surface,
# especially with the 3.2 model graph. Compile only the definitions this
# handler will advertise; callers that explicitly request all schemas
# (tests and documentation generators) retain the eager default.
_ensure_pydantic_schemas_applied(tool["name"] for tool in selected)
return selected
resolved_version = _resolve_handler_adcp_version(instance, adcp_version)

if not _include_schemas:
return [{"name": tool["name"]} for tool in selected]

if resolved_version is None:
# Pydantic schema generation is expensive for the full AdCP surface,
# especially with the 3.2 model graph. Compile only the definitions
# this handler will advertise.
_ensure_pydantic_schemas_applied(tool["name"] for tool in selected)
return [copy.deepcopy(tool) for tool in selected]

if not list_validator_keys(version=resolved_version):
raise ValueError(
f"no bundled AdCP schemas are available for adcp_version={resolved_version!r}"
)

# A pinned server advertises the exact bundled wire contract. This also
# removes tools absent from that release (for example, the compact 3.2
# lifecycle on a 3.1 endpoint) instead of leaking the process-global
# current-model surface into tools/list.
versioned: list[dict[str, Any]] = []
for tool in selected:
name = tool["name"]
input_schema = get_mcp_schema(name, "request", version=resolved_version)
if input_schema is None:
continue
definition = copy.deepcopy(tool)
definition["inputSchema"] = input_schema
output_schema = get_mcp_schema(name, "sync", version=resolved_version)
if output_schema is not None:
output_schema.setdefault("type", "object")
definition["outputSchema"] = output_schema
else:
definition.pop("outputSchema", None)
versioned.append(definition)
return versioned


def _resolve_params_pydantic_model(method: Any) -> type[Any] | None:
Expand Down Expand Up @@ -2979,6 +3042,7 @@ def __init__(
validation: ValidationHookConfig | None = None,
pre_validation_hooks: PreValidationHooks | None = None,
response_enhancer: ResponseEnhancer | None = None,
adcp_version: str | None = None,
):
"""Create tool set from handler.

Expand All @@ -3000,7 +3064,12 @@ def __init__(
:func:`create_tool_caller`.
"""
self.handler = handler
self._filtered_definitions = get_tools_for_handler(handler, advertise_all=advertise_all)
resolved_adcp_version = _resolve_handler_adcp_version(handler, adcp_version)
self._filtered_definitions = get_tools_for_handler(
handler,
advertise_all=advertise_all,
adcp_version=resolved_adcp_version,
)
self._tools: dict[str, Callable[..., Any]] = {}

# Create tool callers only for filtered tools
Expand All @@ -3013,6 +3082,9 @@ def __init__(
validation=validation,
pre_validation_hook=hook,
response_enhancer=response_enhancer,
default_unnegotiated_adcp_version=(
resolved_adcp_version or DEFAULT_UNNEGOTIATED_ADCP_VERSION
),
)

@property
Expand Down Expand Up @@ -3049,6 +3121,7 @@ def create_mcp_tools(
validation: ValidationHookConfig | None = None,
pre_validation_hooks: PreValidationHooks | None = None,
response_enhancer: ResponseEnhancer | None = None,
adcp_version: str | None = None,
) -> MCPToolSet:
"""Create MCP tools from an ADCP handler.

Expand Down Expand Up @@ -3090,6 +3163,9 @@ async def call_tool(name: str, arguments: dict):
response_enhancer: Optional server-wide :data:`ResponseEnhancer`
applied to every successful response. See
:func:`create_tool_caller`.
adcp_version: Trusted server protocol pin for version-scoped
``tools/list`` schemas. Decorator-built handlers carry this pin
automatically; class-based handlers can pass it here.

Returns:
MCPToolSet with tool definitions and handlers.
Expand All @@ -3100,4 +3176,5 @@ async def call_tool(name: str, arguments: dict):
validation=validation,
pre_validation_hooks=pre_validation_hooks,
response_enhancer=response_enhancer,
adcp_version=adcp_version,
)
Loading
Loading