diff --git a/MIGRATION_ADCP_3.1_TO_3.2.md b/MIGRATION_ADCP_3.1_TO_3.2.md index 389dc38fc..aa5660950 100644 --- a/MIGRATION_ADCP_3.1_TO_3.2.md +++ b/MIGRATION_ADCP_3.1_TO_3.2.md @@ -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 @@ -97,15 +100,46 @@ 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, @@ -113,12 +147,23 @@ legacy_buyer = SigningConfig( 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 diff --git a/MIGRATION_v7_to_v8.md b/MIGRATION_v7_to_v8.md index e722eec0c..6d55a0d23 100644 --- a/MIGRATION_v7_to_v8.md +++ b/MIGRATION_v7_to_v8.md @@ -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. diff --git a/README.md b/README.md index 4daa6d398..f5ce40f19 100644 --- a/README.md +++ b/README.md @@ -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()}) ``` @@ -1368,6 +1369,7 @@ install_signing_event_hook( client, signing=signing, seller_capability=seller_caps.request_signing, + adcp_version="3.2", ) async with client: diff --git a/docs/request-signing-migration.md b/docs/request-signing-migration.md index 7109c6c22..ae80b7d13 100644 --- a/docs/request-signing-migration.md +++ b/docs/request-signing-migration.md @@ -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). diff --git a/src/adcp/__init__.py b/src/adcp/__init__.py index f6a57aa1f..be666a041 100644 --- a/src/adcp/__init__.py +++ b/src/adcp/__init__.py @@ -242,6 +242,7 @@ def _resolve_version() -> str: "VideoContent", "WebhookContent", "AuthorizationRequiredDetails", + "BrandIdentity", "BrandReference", "BrandSource", "BriefAsset", @@ -1124,6 +1125,7 @@ def get_adcp_version() -> str: "AdvertiserIndustry", "ArtifactWebhookPayload", "AudienceSource", + "BrandIdentity", "BrandReference", "BrandSource", "BuyingMode", @@ -1549,6 +1551,7 @@ def get_adcp_version() -> str: AudioContent, AuthorizationRequiredDetails, # Core domain types + BrandIdentity, BrandReference, BrandSource, # Creative Operations diff --git a/src/adcp/client.py b/src/adcp/client.py index 6494d8200..63c451b65 100644 --- a/src/adcp/client.py +++ b/src/adcp/client.py @@ -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, @@ -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} @@ -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), @@ -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 diff --git a/src/adcp/server/builder.py b/src/adcp/server/builder.py index be1c1765a..515c9fe80 100644 --- a/src/adcp/server/builder.py +++ b/src/adcp/server/builder.py @@ -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 diff --git a/src/adcp/server/mcp_tools.py b/src/adcp/server/mcp_tools.py index 79d7daa7c..efb6bad7c 100644 --- a/src/adcp/server/mcp_tools.py +++ b/src/adcp/server/mcp_tools.py @@ -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__) @@ -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. @@ -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. @@ -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: @@ -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. @@ -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 @@ -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 @@ -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. @@ -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. @@ -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, ) diff --git a/src/adcp/server/serve.py b/src/adcp/server/serve.py index 261e77bee..56523304a 100644 --- a/src/adcp/server/serve.py +++ b/src/adcp/server/serve.py @@ -37,6 +37,7 @@ async def get_adcp_capabilities(self, params, context=None): from adcp.server.mcp_sessions import ADCPStreamableHTTPSessionManager from adcp.server.mcp_tools import ( _HANDLER_TOOLS, + _resolve_handler_adcp_version, create_tool_caller, get_tools_for_handler, ) @@ -46,6 +47,7 @@ async def get_adcp_capabilities(self, params, context=None): from adcp.validation.client_hooks import ( ValidationHookConfig, ) +from adcp.validation.envelope import DEFAULT_UNNEGOTIATED_ADCP_VERSION # Re-exported as ``adcp.server.serve.DEFAULT_VALIDATION`` for adopters who # want a non-magic name when constructing their own @@ -407,7 +409,11 @@ class introduces a new specialism (a custom subclass that's not in declare a focused subset. """ registered_set = set(registered) - full_defs = get_tools_for_handler(handler, advertise_all=True) + full_defs = get_tools_for_handler( + handler, + advertise_all=True, + _include_schemas=False, + ) full_names = {t["name"] for t in full_defs} unadvertised = sorted(full_names - registered_set) @@ -2574,7 +2580,12 @@ def _register_handler_tools( # A2A executor's handling. middleware_tuple: tuple[SkillMiddleware, ...] = tuple(middleware or ()) - tool_defs = get_tools_for_handler(handler, advertise_all=advertise_all) + resolved_adcp_version = _resolve_handler_adcp_version(handler, None) + tool_defs = get_tools_for_handler( + handler, + advertise_all=advertise_all, + adcp_version=resolved_adcp_version, + ) registered: list[str] = [] for tool_def in tool_defs: tool_name = tool_def["name"] @@ -2593,6 +2604,9 @@ def _register_handler_tools( validation=validation, pre_validation_hook=hook, response_enhancer=response_enhancer, + default_unnegotiated_adcp_version=( + resolved_adcp_version or DEFAULT_UNNEGOTIATED_ADCP_VERSION + ), ) _register_tool( mcp, diff --git a/src/adcp/signing/__init__.py b/src/adcp/signing/__init__.py index a6482034f..eae4d419c 100644 --- a/src/adcp/signing/__init__.py +++ b/src/adcp/signing/__init__.py @@ -100,6 +100,7 @@ SigningConfig, SigningDecision, operation_needs_signing, + signing_profile_for_adcp_version, ) from adcp.signing.brand_authz import ( BrandAuthorizationReason, @@ -452,6 +453,7 @@ def __init__(self, *args: object, **kwargs: object) -> None: "same_registrable_domain", "sign_request", "sign_signature_base", + "signing_profile_for_adcp_version", "sign_standard_webhook", "signing_operation", "unauthorized_response_headers", diff --git a/src/adcp/signing/autosign.py b/src/adcp/signing/autosign.py index 764f03775..05b3accae 100644 --- a/src/adcp/signing/autosign.py +++ b/src/adcp/signing/autosign.py @@ -55,6 +55,25 @@ advertise signing support at all. Do not sign. """ +SigningProfileVersion = Literal["3.0", "3.1", "3.2"] + + +def signing_profile_for_adcp_version(adcp_version: str) -> SigningProfileVersion: + """Map a trusted AdCP release pin to its request-signing profile. + + Prerelease and patch precision do not change the signing wire profile. + Callers must derive this from trusted endpoint configuration, never an + unbound request-body field. + """ + release = adcp_version.split("+", 1)[0].split("-", 1)[0] + parts = release.split(".") + if len(parts) < 2: + raise ValueError(f"invalid AdCP version for request signing: {adcp_version!r}") + profile = f"{parts[0]}.{parts[1]}" + if profile not in {"3.0", "3.1", "3.2"}: + raise ValueError(f"AdCP {adcp_version!r} has no supported request-signing profile") + return profile # type: ignore[return-value] + @dataclass(frozen=True) class SigningConfig: @@ -80,21 +99,27 @@ class SigningConfig: tag: Signature tag. Defaults to the AdCP request-signing tag and should not need to be overridden. + signing_profile_version: + Explicit signing wire profile. When omitted, ``ADCPClient`` derives + it from the trusted effective wire pin (``server_version`` when set, + otherwise ``adcp_version``). Set this only to override negotiation or + when using the standalone event-hook helper, which has no client + protocol pin to consult. """ private_key: PrivateKey key_id: str alg: str = ALG_ED25519 tag: str = DEFAULT_TAG - signing_profile_version: Literal["3.0", "3.1", "3.2"] = "3.2" + signing_profile_version: SigningProfileVersion | None = None def __post_init__(self) -> None: if self.alg not in ALLOWED_ALGS: raise ValueError(f"alg must be one of {sorted(ALLOWED_ALGS)}, got {self.alg!r}") if not self.key_id: raise ValueError("key_id must be a non-empty string") - if self.signing_profile_version not in {"3.0", "3.1", "3.2"}: - raise ValueError("signing_profile_version must be one of '3.0', '3.1', or '3.2'") + if self.signing_profile_version not in {None, "3.0", "3.1", "3.2"}: + raise ValueError("signing_profile_version must be None, '3.0', '3.1', or '3.2'") def __repr__(self) -> str: # Redact the private key from string representations so accidental diff --git a/src/adcp/signing/client.py b/src/adcp/signing/client.py index 672da6461..0f5101e26 100644 --- a/src/adcp/signing/client.py +++ b/src/adcp/signing/client.py @@ -35,6 +35,7 @@ client, signing=signing, seller_capability=seller_capability, + adcp_version="3.2", ) async with client: @@ -60,6 +61,7 @@ SigningConfig, current_operation, operation_needs_signing, + signing_profile_for_adcp_version, ) from adcp.signing.signer import sign_request @@ -103,6 +105,7 @@ def install_signing_event_hook( seller_capability: RequestSigning | None = None, capability_provider: CapabilityProvider | None = None, expected_origin: str | None = None, + adcp_version: str | None = None, ) -> None: """Install an RFC 9421 request-signing event hook on ``client``. @@ -137,6 +140,9 @@ def install_signing_event_hook( first request inside :func:`signing_operation` binds the hook to its origin. Cross-origin redirects and later cross-origin requests fail before signing. + adcp_version: + Trusted negotiated AdCP version used to select the signing profile + when ``SigningConfig.signing_profile_version`` is not explicit. Notes ----- @@ -150,6 +156,16 @@ def install_signing_event_hook( "`seller_capability` or `capability_provider`." ) + if signing.signing_profile_version is not None: + signing_profile_version = signing.signing_profile_version + elif adcp_version is not None: + signing_profile_version = signing_profile_for_adcp_version(adcp_version) + else: + raise ValueError( + "install_signing_event_hook requires adcp_version when " + "SigningConfig.signing_profile_version is not explicit" + ) + bound_origin = ( _origin(expected_origin) if expected_origin is not None else _origin(client.base_url) ) @@ -222,7 +238,7 @@ async def _hook(request: httpx.Request) -> None: alg=signing.alg, cover_content_digest=cover_digest, tag=signing.tag, - signing_profile_version=signing.signing_profile_version, + signing_profile_version=signing_profile_version, ) # pop-then-set so our values are authoritative even if an # earlier layer set the same header in a different case. diff --git a/src/adcp/signing/signer.py b/src/adcp/signing/signer.py index ac9ad3a55..bdf59621f 100644 --- a/src/adcp/signing/signer.py +++ b/src/adcp/signing/signer.py @@ -228,6 +228,23 @@ def _assemble_headers( ) +def _resolve_content_digest_coverage( + *, + signing_profile_version: str, + body: bytes, + cover_content_digest: bool | None, +) -> bool: + if signing_profile_version not in {"3.0", "3.1", "3.2"}: + raise ValueError("signing_profile_version must be one of '3.0', '3.1', or '3.2'") + if cover_content_digest is None: + return signing_profile_version == "3.2" and bool(body) + if signing_profile_version == "3.2" and body and not cover_content_digest: + raise ValueError( + "AdCP 3.2 signatures over non-empty request bodies must cover content-digest" + ) + return cover_content_digest + + def sign_request( *, method: str, @@ -237,19 +254,26 @@ def sign_request( private_key: PrivateKey, key_id: str, alg: str, - cover_content_digest: bool = False, + cover_content_digest: bool | None = None, created: int | None = None, expires_in_seconds: int = DEFAULT_EXPIRES_IN_SECONDS, nonce: str | None = None, tag: str = DEFAULT_TAG, label: str = SIG_LABEL_DEFAULT, - signing_profile_version: Literal["3.0", "3.1", "3.2"] = "3.2", + signing_profile_version: Literal["3.0", "3.1", "3.2"], ) -> SignedHeaders: """Sign a request and return the headers to add to it. The caller is responsible for attaching `SignedHeaders.as_dict()` to the - outgoing HTTP request before sending. + outgoing HTTP request before sending. ``signing_profile_version`` is + intentionally required: this low-level primitive has no negotiated AdCP + version from which it could safely infer the wire encoding. """ + resolved_cover_content_digest = _resolve_content_digest_coverage( + signing_profile_version=signing_profile_version, + body=body, + cover_content_digest=cover_content_digest, + ) prepared = _prepare_signature( method=method, url=url, @@ -257,7 +281,7 @@ def sign_request( body=body, key_id=key_id, alg=alg, - cover_content_digest=cover_content_digest, + cover_content_digest=resolved_cover_content_digest, created=created, expires_in_seconds=expires_in_seconds, nonce=nonce, @@ -279,13 +303,13 @@ async def async_sign_request( headers: Mapping[str, str], body: bytes, provider: SigningProvider, - cover_content_digest: bool = False, + cover_content_digest: bool | None = None, created: int | None = None, expires_in_seconds: int = DEFAULT_EXPIRES_IN_SECONDS, nonce: str | None = None, tag: str = DEFAULT_TAG, label: str = SIG_LABEL_DEFAULT, - signing_profile_version: Literal["3.0", "3.1", "3.2"] = "3.2", + signing_profile_version: Literal["3.0", "3.1", "3.2"], ) -> SignedHeaders: """Sign a request via a :class:`SigningProvider` and return its headers. @@ -303,6 +327,11 @@ async def async_sign_request( raw RFC 9421 base — NOT a pre-hashed digest. See the :class:`SigningProvider` docstring for the ECDSA double-hash caveat. """ + resolved_cover_content_digest = _resolve_content_digest_coverage( + signing_profile_version=signing_profile_version, + body=body, + cover_content_digest=cover_content_digest, + ) prepared = _prepare_signature( method=method, url=url, @@ -310,7 +339,7 @@ async def async_sign_request( body=body, key_id=provider.key_id(), alg=provider.algorithm(), - cover_content_digest=cover_content_digest, + cover_content_digest=resolved_cover_content_digest, created=created, expires_in_seconds=expires_in_seconds, nonce=nonce, diff --git a/src/adcp/signing/webhook_signer.py b/src/adcp/signing/webhook_signer.py index 56426d4df..4170dca31 100644 --- a/src/adcp/signing/webhook_signer.py +++ b/src/adcp/signing/webhook_signer.py @@ -65,6 +65,7 @@ def sign_webhook( nonce=nonce, tag=WEBHOOK_TAG, label=label, + signing_profile_version="3.2", ) diff --git a/src/adcp/types/__init__.py b/src/adcp/types/__init__.py index bf5709b8f..d5da9e76b 100644 --- a/src/adcp/types/__init__.py +++ b/src/adcp/types/__init__.py @@ -310,6 +310,7 @@ "AssetType", # Deprecated "AdvertiserIndustry", "AudienceSource", + "BrandIdentity", "BrandReference", "BrandSource", "BusinessEntity", @@ -1096,6 +1097,7 @@ def __dir__() -> list[str]: AvailablePackage, AvailableReportingFrequency, BothPreviewRender, + BrandIdentity, BrandReference, BrandSource, BriefAsset, diff --git a/src/adcp/types/_eager.py b/src/adcp/types/_eager.py index 3b5e5f3ff..e302e35b5 100644 --- a/src/adcp/types/_eager.py +++ b/src/adcp/types/_eager.py @@ -477,6 +477,7 @@ AuthorizedAgentsBySignalId, AuthorizedAgentsBySignalTag, BothPreviewRender, + BrandIdentity, BriefAsset, BriefFormatAsset, # Cross-module name collision aliases (#911, Step 2) @@ -1077,6 +1078,7 @@ def __init__(self, *args: object, **kwargs: object) -> None: "CatalogFieldMapping", "CatalogFormatAsset", "CatalogGroupBinding", + "BrandIdentity", "CatalogItemStatus", "CatalogRequirements", "CatalogType", diff --git a/src/adcp/types/aliases.py b/src/adcp/types/aliases.py index 3b548fea3..298fe450f 100644 --- a/src/adcp/types/aliases.py +++ b/src/adcp/types/aliases.py @@ -144,6 +144,13 @@ from adcp.types.generated_poc.signals.get_signals_response import ( GetSignalsResponse as _GetSignalsSuccessResponse, ) +from adcp.types.generated_poc.brand_discovery import Brand as BrandIdentity + +"""Semantic public model for a brand entry in ``brand.json``. + +This replaces the removed private ``adcp.types.generated_poc.brand.Brand`` +import without colliding with the unrelated capabilities ``Brand`` model. +""" def _generated_alias(name: str, fallback_name: str) -> Any: @@ -2399,6 +2406,8 @@ class UnknownGroupAsset(_BaseGroupAsset): "MediaBuyDeliveryStatus", # Catalog field binding semantic alias "CatalogGroupBinding", + # brand.json identity model (collision-safe replacement for private Brand) + "BrandIdentity", # Field enum disambiguation aliases "GetProductsField", "GetBrandIdentityField", diff --git a/src/adcp/types/buyer.py b/src/adcp/types/buyer.py index a61f75ad8..251d47e48 100644 --- a/src/adcp/types/buyer.py +++ b/src/adcp/types/buyer.py @@ -73,6 +73,7 @@ "AcquireRightsRequest", "GetRightsRequest", "GetBrandIdentityRequest", + "BrandIdentity", "VerifyBrandClaimsRequest", "BrandReference", "BrandSource", @@ -98,6 +99,7 @@ AcceptProposalRequest, AcceptProposalResponse, AcquireRightsRequest, + BrandIdentity, BrandReference, BrandSource, BuyProductsRequest, diff --git a/src/adcp/types/v30.py b/src/adcp/types/v30.py new file mode 100644 index 000000000..95ae18c56 --- /dev/null +++ b/src/adcp/types/v30.py @@ -0,0 +1,5 @@ +"""Public AdCP 3.0 request/response models.""" + +from adcp.types.versioned import versioned_surface + +__getattr__, __dir__, __all__ = versioned_surface("3.0", __name__) diff --git a/src/adcp/types/v31.py b/src/adcp/types/v31.py new file mode 100644 index 000000000..e60e18ff7 --- /dev/null +++ b/src/adcp/types/v31.py @@ -0,0 +1,5 @@ +"""Public AdCP 3.1 request/response models.""" + +from adcp.types.versioned import versioned_surface + +__getattr__, __dir__, __all__ = versioned_surface("3.1", __name__) diff --git a/src/adcp/types/v32.py b/src/adcp/types/v32.py new file mode 100644 index 000000000..b8e6ce20c --- /dev/null +++ b/src/adcp/types/v32.py @@ -0,0 +1,5 @@ +"""Public AdCP 3.2 beta request/response models.""" + +from adcp.types.versioned import versioned_surface + +__getattr__, __dir__, __all__ = versioned_surface("3.2-beta.0", __name__) diff --git a/src/adcp/types/versioned.py b/src/adcp/types/versioned.py new file mode 100644 index 000000000..574d82629 --- /dev/null +++ b/src/adcp/types/versioned.py @@ -0,0 +1,256 @@ +"""Version-scoped Pydantic models backed by bundled AdCP JSON Schemas. + +The primary :mod:`adcp.types` surface represents the SDK's current generated +release. Use this module (or ``adcp.types.v30`` / ``v31`` / ``v32``) when an +application must construct and validate the exact public shape negotiated with +an older peer in the same SDK process. +""" + +from __future__ import annotations + +import copy +import re +from functools import cache +from typing import Any, ClassVar, Literal + +from pydantic import GetJsonSchemaHandler, RootModel, model_validator +from pydantic.json_schema import JsonSchemaValue +from pydantic_core import CoreSchema + +from adcp.validation.schema_loader import ( + get_portable_schema, + get_validator, + list_validator_keys, +) + +VersionedDirection = Literal[ + "request", + "sync", + "submitted", + "working", + "input-required", +] + + +def _inline_local_refs(schema: dict[str, Any]) -> dict[str, Any]: + """Inline bundled definitions for Pydantic's schema post-processor.""" + document = copy.deepcopy(schema) + + def resolve(pointer: str) -> Any: + value: Any = document + for raw_part in pointer.removeprefix("#/").split("/"): + part = raw_part.replace("~1", "/").replace("~0", "~") + value = value[part] + return value + + def expand(value: Any, stack: frozenset[str]) -> Any: + if isinstance(value, list): + return [expand(item, stack) for item in value] + if not isinstance(value, dict): + return value + reference = value.get("$ref") + if isinstance(reference, str) and reference.startswith("#/"): + if reference in stack: + return {} + target = expand(resolve(reference), stack | {reference}) + if isinstance(target, dict): + siblings = {key: item for key, item in value.items() if key != "$ref"} + return {**target, **expand(siblings, stack)} + return {key: expand(item, stack) for key, item in value.items() if key != "$defs"} + + expanded = expand(document, frozenset()) + assert isinstance(expanded, dict) + return expanded + + +class VersionedSchemaModel(RootModel[dict[str, Any]]): + """Dict-shaped Pydantic model that enforces one bundled schema version. + + Keyword construction and attribute access intentionally mirror ordinary + generated request models while retaining the exact JSON Schema as the + validation authority. + """ + + schema_version: ClassVar[str] + schema_tool_name: ClassVar[str] + schema_direction: ClassVar[VersionedDirection] + schema_document: ClassVar[dict[str, Any]] + + def __init__(self, root: dict[str, Any] | None = None, **data: Any) -> None: + if root is not None and data: + raise TypeError("pass either root or keyword fields, not both") + super().__init__(root=root if root is not None else data) + + @model_validator(mode="before") + @classmethod + def _apply_top_level_defaults(cls, value: Any) -> Any: + if not isinstance(value, dict): + return value + result = dict(value) + properties = cls.schema_document.get("properties", {}) + if isinstance(properties, dict): + for name, field_schema in properties.items(): + if ( + name not in result + and isinstance(field_schema, dict) + and "default" in field_schema + ): + result[name] = copy.deepcopy(field_schema["default"]) + return result + + @model_validator(mode="after") + def _validate_bundled_schema(self) -> VersionedSchemaModel: + validator = get_validator( + self.schema_tool_name, + self.schema_direction, + version=self.schema_version, + ) + if validator is None: + raise ValueError( + f"no {self.schema_version} schema for " + f"{self.schema_tool_name}::{self.schema_direction}" + ) + issues = sorted(validator.iter_errors(self.root), key=lambda error: list(error.path)) + if issues: + issue = issues[0] + path = ".".join(str(part) for part in issue.absolute_path) or "" + raise ValueError(f"{path}: {issue.message}") + return self + + def __getattr__(self, name: str) -> Any: + root = object.__getattribute__(self, "root") + if name in root: + return root[name] + raise AttributeError(f"{type(self).__name__!s} has no attribute {name!r}") + + def __getitem__(self, name: str) -> Any: + return self.root[name] + + @classmethod + def model_json_schema(cls, *args: Any, **kwargs: Any) -> dict[str, Any]: + del args, kwargs + return copy.deepcopy(cls.schema_document) + + @classmethod + def __get_pydantic_json_schema__( + cls, + core_schema: CoreSchema, + handler: GetJsonSchemaHandler, + ) -> JsonSchemaValue: + """Expose the negotiated contract to TypeAdapter/FastAPI consumers.""" + del core_schema, handler + return _inline_local_refs(cls.schema_document) + + +def _pascal_case(tool_name: str) -> str: + return "".join(part.capitalize() for part in tool_name.split("_")) + + +def _snake_case(model_stem: str) -> str: + step1 = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", model_stem) + return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", step1).lower() + + +@cache +def schema_model_for_version( + version: str, + tool_name: str, + direction: VersionedDirection = "request", + *, + model_name: str | None = None, +) -> type[VersionedSchemaModel]: + """Return a cached Pydantic model for one version/tool/direction.""" + schema = get_portable_schema(tool_name, direction, version=version) + if schema is None: + raise LookupError(f"no {version} schema for {tool_name}::{direction}") + suffix = { + "request": "Request", + "sync": "Response", + "submitted": "SubmittedResponse", + "working": "WorkingResponse", + "input-required": "InputRequiredResponse", + }[direction] + name = model_name or f"{_pascal_case(tool_name)}{suffix}" + return type( + name, + (VersionedSchemaModel,), + { + "__module__": __name__, + "schema_version": version, + "schema_tool_name": tool_name, + "schema_direction": direction, + "schema_document": schema, + }, + ) + + +def model_for_version(version: str, model_name: str) -> type[VersionedSchemaModel]: + """Resolve ``ListCreativesRequest``-style names for a protocol release.""" + direction: VersionedDirection + if model_name.endswith("SubmittedResponse"): + direction = "submitted" + stem = model_name[: -len("SubmittedResponse")] + elif model_name.endswith("WorkingResponse"): + direction = "working" + stem = model_name[: -len("WorkingResponse")] + elif model_name.endswith("InputRequiredResponse"): + direction = "input-required" + stem = model_name[: -len("InputRequiredResponse")] + elif model_name.endswith("Request"): + direction = "request" + stem = model_name[: -len("Request")] + elif model_name.endswith("Response"): + direction = "sync" + stem = model_name[: -len("Response")] + else: + raise AttributeError( + f"version-scoped model names must end in Request or Response: {model_name}" + ) + return schema_model_for_version( + version, + _snake_case(stem), + direction, + model_name=model_name, + ) + + +def versioned_surface( + version: str, + module_name: str, +) -> tuple[Any, Any, list[str]]: + """Build PEP 562 hooks for a version shorthand module.""" + names: list[str] = [] + for key in list_validator_keys(version=version): + tool_name, direction = key.split("::", 1) + if direction == "request": + names.append(f"{_pascal_case(tool_name)}Request") + elif direction == "sync": + names.append(f"{_pascal_case(tool_name)}Response") + elif direction == "submitted": + names.append(f"{_pascal_case(tool_name)}SubmittedResponse") + elif direction == "working": + names.append(f"{_pascal_case(tool_name)}WorkingResponse") + elif direction == "input-required": + names.append(f"{_pascal_case(tool_name)}InputRequiredResponse") + exported = sorted(set(names)) + + def resolve(name: str) -> Any: + if name not in exported: + raise AttributeError(f"module {module_name!r} has no attribute {name!r}") + model = model_for_version(version, name) + model.__module__ = module_name + return model + + def directory() -> list[str]: + return list(exported) + + return resolve, directory, exported + + +__all__ = [ + "VersionedDirection", + "VersionedSchemaModel", + "model_for_version", + "schema_model_for_version", + "versioned_surface", +] diff --git a/src/adcp/validation/__init__.py b/src/adcp/validation/__init__.py index a3c15c4d6..bfb5dc878 100644 --- a/src/adcp/validation/__init__.py +++ b/src/adcp/validation/__init__.py @@ -42,6 +42,9 @@ from adcp.validation.schema_loader import ( Direction, ResponseVariant, + get_mcp_schema, + get_portable_schema, + get_schema, get_validator, list_validator_keys, ) @@ -69,6 +72,9 @@ "ValidationIssue", "ValidationOutcome", "format_issues", + "get_mcp_schema", + "get_portable_schema", + "get_schema", "get_validator", "list_validator_keys", "validate_request", diff --git a/src/adcp/validation/schema_loader.py b/src/adcp/validation/schema_loader.py index 5d9943123..efb09834f 100644 --- a/src/adcp/validation/schema_loader.py +++ b/src/adcp/validation/schema_loader.py @@ -30,10 +30,12 @@ import re import threading import warnings +from copy import deepcopy from datetime import datetime from importlib.resources import as_file, files from pathlib import Path from typing import Any, Literal +from urllib.parse import unquote, urlparse from adcp.validation.version import resolve_bundle_key @@ -136,7 +138,10 @@ def __init__(self, root: _SchemaRoot, bundle_key: str) -> None: self.root = root self.bundle_key = bundle_key self.file_index: dict[tuple[str, Direction], Path] = {} + self.source_index: dict[tuple[str, Direction], Path] = {} + self.mcp_index: dict[tuple[str, Direction], Path] = {} self.compiled: dict[tuple[str, Direction], Any] = {} + self.portable: dict[tuple[str, Direction], dict[str, Any]] = {} self.registry: dict[str, dict[str, Any]] = {} self._core_loaded = False @@ -171,11 +176,17 @@ def _build_index(root: _SchemaRoot) -> dict[tuple[str, Direction], Path]: index[(tool, "sync")] = file for entry in sorted(root.root.iterdir()): - if not entry.is_dir() or entry.name in ("bundled", "core"): + if not entry.is_dir() or entry.name in ("bundled", "core", "mcp"): continue for file in _walk_json(entry): base = file.stem - if base.endswith("-async-response-submitted"): + if base.endswith("-request"): + tool = base[: -len("-request")].replace("-", "_") + index.setdefault((tool, "request"), file) + elif base.endswith("-response"): + tool = base[: -len("-response")].replace("-", "_") + index.setdefault((tool, "sync"), file) + elif base.endswith("-async-response-submitted"): tool = base[: -len("-async-response-submitted")].replace("-", "_") index[(tool, "submitted")] = file elif base.endswith("-async-response-working"): @@ -188,6 +199,42 @@ def _build_index(root: _SchemaRoot) -> dict[tuple[str, Direction], Path]: return index +def _build_source_index(root: _SchemaRoot) -> dict[tuple[str, Direction], Path]: + """Index modular request/response schemas before bundled duplication.""" + index: dict[tuple[str, Direction], Path] = {} + for entry in sorted(root.root.iterdir()): + if not entry.is_dir() or entry.name in ("bundled", "core", "mcp"): + continue + for file in _walk_json(entry): + base = file.stem + if base.endswith("-request"): + tool = base[: -len("-request")].replace("-", "_") + index.setdefault((tool, "request"), file) + elif base.endswith("-response"): + tool = base[: -len("-response")].replace("-", "_") + index.setdefault((tool, "sync"), file) + return index + + +def _build_mcp_index(root: _SchemaRoot) -> dict[tuple[str, Direction], Path]: + """Index compact, self-contained schemas generated for MCP discovery.""" + index: dict[tuple[str, Direction], Path] = {} + mcp_root = root.root / "mcp" + files = _walk_json(mcp_root) + for file in files: + relative_parts = file.relative_to(mcp_root).parts + if "profiles" not in relative_parts or "production" not in relative_parts: + continue + base = file.stem + if base.endswith("-request"): + tool = base[: -len("-request")].replace("-", "_") + index[(tool, "request")] = file + elif base.endswith("-response"): + tool = base[: -len("-response")].replace("-", "_") + index[(tool, "sync")] = file + return index + + def _resolve_bundle_key_for_version(version: str | None) -> str: """Resolve a caller-supplied version (or ``None``) to a bundle key.""" if version is None: @@ -229,6 +276,8 @@ def _ensure_state(version: str | None = None) -> _LoaderState | None: return None new_state = _LoaderState(root, bundle_key) new_state.file_index = _build_index(root) + new_state.source_index = _build_source_index(root) + new_state.mcp_index = _build_mcp_index(root) _states[bundle_key] = new_state return new_state @@ -350,6 +399,206 @@ def get_validator( return validator +def get_schema( + tool_name: str, + direction: Direction, + *, + version: str | None = None, +) -> dict[str, Any] | None: + """Return a defensive copy of a bundled version-specific JSON Schema. + + This is the non-compiled counterpart to :func:`get_validator`. It powers + version-scoped public models and MCP ``tools/list`` advertisement, both of + which need the schema document itself rather than only a validator. + """ + state = _ensure_state(version) + if state is None: + return None + file = state.file_index.get((tool_name, direction)) + if file is None: + return None + try: + schema = json.loads(file.read_text()) + except (OSError, json.JSONDecodeError) as exc: + logger.warning( + "Failed to load schema %s for %s::%s: %s", + file, + tool_name, + direction, + exc, + ) + return None + if not isinstance(schema, dict): + logger.warning("Schema %s is not a JSON object", file) + return None + return deepcopy(schema) + + +def _reference_file(state: _LoaderState, current_file: Path, reference: str) -> Path: + parsed = urlparse(reference) + if parsed.scheme: + marker = "/schemas/" + if marker not in parsed.path: + raise ValueError(f"unsupported external schema reference: {reference}") + version_and_path = parsed.path.split(marker, 1)[1] + _, separator, relative_path = version_and_path.partition("/") + if not separator: + raise ValueError(f"schema reference has no document path: {reference}") + return state.root.root / unquote(relative_path) + return (current_file.parent / unquote(parsed.path)).resolve() + + +def get_portable_schema( + tool_name: str, + direction: Direction, + *, + version: str | None = None, +) -> dict[str, Any] | None: + """Return a self-contained schema safe outside its source directory.""" + state = _ensure_state(version) + if state is None: + return None + key = (tool_name, direction) + cached = state.portable.get(key) + if cached is not None: + return deepcopy(cached) + file = state.source_index.get(key) or state.file_index.get(key) + if file is None: + return None + try: + schema = json.loads(file.read_text()) + if not isinstance(schema, dict): + raise ValueError("schema root is not an object") + portable = _self_contained_schema(state, file, schema) + except (OSError, json.JSONDecodeError, KeyError, ValueError) as exc: + logger.warning("Failed to make schema %s portable for %s: %s", file, key, exc) + return None + state.portable[key] = portable + return deepcopy(portable) + + +def _self_contained_schema( + state: _LoaderState, + file: Path, + schema: dict[str, Any], +) -> dict[str, Any]: + """Rebase file/URL refs into local ``$defs`` without weakening them.""" + result = deepcopy(schema) + root_definitions = result.pop("$defs", {}) + if not isinstance(root_definitions, dict): + raise ValueError("schema $defs must be an object") + definitions: dict[str, Any] = {} + loading: set[str] = set() + + def definition_key(path: Path) -> str: + try: + relative = path.resolve().relative_to(state.root.root.resolve()) + return f"external:{relative.as_posix()}" + except ValueError: + return f"external:{path.name}" + + def pointer_segment(value: str) -> str: + return value.replace("~", "~0").replace("/", "~1") + + def ensure_definition(target_file: Path, key: str) -> None: + if key in definitions or key in loading: + return + loading.add(key) + loaded = json.loads(target_file.read_text()) + if not isinstance(loaded, dict): + raise ValueError(f"referenced schema is not an object: {target_file}") + definitions[key] = rewrite(loaded, target_file, key) + loading.remove(key) + + def rewrite(value: Any, current_file: Path, current_key: str | None) -> Any: + if isinstance(value, list): + return [rewrite(item, current_file, current_key) for item in value] + if not isinstance(value, dict): + return value + rewritten = { + name: rewrite(item, current_file, current_key) + for name, item in value.items() + if name != "$ref" + } + reference = value.get("$ref") + if not isinstance(reference, str): + return rewritten + parsed = urlparse(reference) + if not parsed.scheme and not parsed.path: + if current_key is None: + rewritten["$ref"] = reference + else: + rewritten["$ref"] = f"#/$defs/{pointer_segment(current_key)}" f"{parsed.fragment}" + return rewritten + target_file = _reference_file(state, current_file, reference).resolve() + key = definition_key(target_file) + ensure_definition(target_file, key) + rewritten["$ref"] = f"#/$defs/{pointer_segment(key)}" f"{parsed.fragment}" + return rewritten + + rewritten_root = rewrite(result, file.resolve(), None) + if not isinstance(rewritten_root, dict): + raise ValueError("schema root is not an object") + for name, definition in root_definitions.items(): + definitions.setdefault(name, rewrite(definition, file.resolve(), None)) + if definitions: + rewritten_root["$defs"] = definitions + return rewritten_root + + +def _strip_schema_annotations(value: Any) -> Any: + if isinstance(value, list): + return [_strip_schema_annotations(item) for item in value] + if not isinstance(value, dict): + return value + omitted = {"description", "title", "examples", "$comment", "_bundled"} + return { + key: _strip_schema_annotations(item) for key, item in value.items() if key not in omitted + } + + +def get_mcp_schema( + tool_name: str, + direction: Literal["request", "sync"], + *, + version: str | None = None, +) -> dict[str, Any] | None: + """Return the compact transport schema used for MCP ``tools/list``. + + Newer bundles provide self-contained production-profile schemas that + remove duplicated descriptions and definitions. Releases without those + artifacts fall back to their canonical versioned schema. + """ + state = _ensure_state(version) + if state is None: + return None + key = (tool_name, direction) + file = state.mcp_index.get(key) or state.source_index.get(key) or state.file_index.get(key) + if file is None: + return None + try: + schema = json.loads(file.read_text()) + except (OSError, json.JSONDecodeError) as exc: + logger.warning( + "Failed to load MCP schema %s for %s::%s: %s", + file, + tool_name, + direction, + exc, + ) + return None + if not isinstance(schema, dict): + logger.warning("MCP schema %s is not a JSON object", file) + return None + try: + portable = _self_contained_schema(state, file, schema) + except (OSError, json.JSONDecodeError, KeyError, ValueError) as exc: + logger.warning("Failed to make MCP schema %s portable: %s", file, exc) + return None + compact = _strip_schema_annotations(portable) + return compact if isinstance(compact, dict) else None + + def list_validator_keys(*, version: str | None = None) -> list[str]: """Every ``tool::direction`` pair with a shipped schema. Used by tests.""" state = _ensure_state(version) diff --git a/tests/conformance/signing/test_autosign.py b/tests/conformance/signing/test_autosign.py index ef5b684af..ad1601de9 100644 --- a/tests/conformance/signing/test_autosign.py +++ b/tests/conformance/signing/test_autosign.py @@ -12,6 +12,7 @@ from cryptography.hazmat.primitives.asymmetric import ec, ed25519 from adcp.signing import SigningConfig, operation_needs_signing +from adcp.signing.autosign import signing_profile_for_adcp_version from adcp.signing.crypto import ALG_ED25519, ALG_ES256 from adcp.types.generated_poc.protocol.get_adcp_capabilities_response import ( CoversContentDigest, @@ -112,6 +113,20 @@ def test_signing_config_accepts_ed25519_key() -> None: assert cfg.alg == ALG_ED25519 assert cfg.key_id == "buyer-1" assert cfg.private_key is key + assert cfg.signing_profile_version is None + + +@pytest.mark.parametrize( + ("version", "expected"), + [("3.0", "3.0"), ("3.1.15", "3.1"), ("3.2-beta.0", "3.2")], +) +def test_signing_profile_follows_release_line(version: str, expected: str) -> None: + assert signing_profile_for_adcp_version(version) == expected + + +def test_signing_profile_rejects_unknown_release() -> None: + with pytest.raises(ValueError, match="no supported request-signing profile"): + signing_profile_for_adcp_version("4.0") def test_signing_config_accepts_es256_key() -> None: diff --git a/tests/conformance/signing/test_autosign_e2e.py b/tests/conformance/signing/test_autosign_e2e.py index ddd6658d3..149d63635 100644 --- a/tests/conformance/signing/test_autosign_e2e.py +++ b/tests/conformance/signing/test_autosign_e2e.py @@ -130,7 +130,6 @@ def signing_client(signing_config: SigningConfig) -> ADCPClient: [ (CoversContentDigest.either, ["create_media_buy"], "create_media_buy"), (CoversContentDigest.required, ["create_media_buy"], "create_media_buy"), - (CoversContentDigest.forbidden, ["create_media_buy"], "create_media_buy"), ], ) async def test_hook_on_real_httpx_round_trip_accepted_by_verifier( diff --git a/tests/conformance/signing/test_autosign_hook.py b/tests/conformance/signing/test_autosign_hook.py index 5643b2580..61ebae16d 100644 --- a/tests/conformance/signing/test_autosign_hook.py +++ b/tests/conformance/signing/test_autosign_hook.py @@ -48,13 +48,13 @@ # -- fixtures ------------------------------------------------------------ -def _make_client(signing: SigningConfig | None = None) -> ADCPClient: +def _make_client(signing: SigningConfig | None = None, **kwargs: Any) -> ADCPClient: agent = AgentConfig( id="test-seller", agent_uri="https://seller.example.com", protocol=Protocol.A2A, ) - return ADCPClient(agent, signing=signing) + return ADCPClient(agent, signing=signing, **kwargs) def _make_caps( @@ -143,6 +143,72 @@ def test_signing_kwarg_installs_adapter_hook(signing_config: SigningConfig) -> N assert client.adapter.signing_request_hook is not None +def test_signing_profile_derives_from_adcp_pin(signing_config: SigningConfig) -> None: + client = _make_client(signing=signing_config, adcp_version="3.1") + assert client._signing_profile_version == "3.1" + + +def test_server_pin_controls_effective_signing_profile( + signing_config: SigningConfig, +) -> None: + client = _make_client( + signing=signing_config, + adcp_version="3.1", + server_version="3.0", + ) + assert client._signing_profile_version == "3.0" + + +def test_legacy_server_pin_without_signing_needs_no_profile() -> None: + with pytest.warns(DeprecationWarning): + client = _make_client(server_version="2.5") + assert client._signing_profile_version is None + + +def test_legacy_server_pin_with_signing_requires_explicit_supported_profile( + signing_config: SigningConfig, +) -> None: + with ( + pytest.warns(DeprecationWarning), + pytest.raises(ValueError, match="has no supported request-signing profile"), + ): + _make_client(server_version="2.5", signing=signing_config) + + +def test_explicit_signing_profile_overrides_client_pin( + signing_config: SigningConfig, +) -> None: + explicit = SigningConfig( + private_key=signing_config.private_key, + key_id=signing_config.key_id, + signing_profile_version="3.2", + ) + client = _make_client(signing=explicit, adcp_version="3.1") + assert client._signing_profile_version == "3.2" + + +@pytest.mark.parametrize( + ("adcp_version", "uses_padded_base64"), + [("3.0", False), ("3.1", False), ("3.2-beta.0", True)], +) +async def test_client_pin_controls_signature_wire_encoding( + signing_config: SigningConfig, + adcp_version: str, + uses_padded_base64: bool, +) -> None: + client = _make_client(signing=signing_config, adcp_version=adcp_version) + client.fetch_capabilities = AsyncMock( # type: ignore[method-assign] + return_value=_make_caps(required=["create_media_buy"]) + ) + request = _build_request() + token = current_operation.set("create_media_buy") + try: + await client._sign_outgoing_request(request) + finally: + current_operation.reset(token) + assert request.headers["Signature"].endswith("==:") is uses_padded_base64 + + # -- hook: skip paths ---------------------------------------------------- @@ -360,7 +426,9 @@ async def test_hook_honors_covers_required(signing_config: SigningConfig) -> Non ) -async def test_hook_honors_covers_forbidden(signing_config: SigningConfig) -> None: +async def test_hook_rejects_covers_forbidden_under_32( + signing_config: SigningConfig, +) -> None: client = _make_client(signing=signing_config) client.fetch_capabilities = AsyncMock( # type: ignore[method-assign] return_value=_make_caps( @@ -372,21 +440,11 @@ async def test_hook_honors_covers_forbidden(signing_config: SigningConfig) -> No request = _build_request(body=body) token = current_operation.set("create_media_buy") try: - await client._sign_outgoing_request(request) + with pytest.raises(ValueError, match="must cover content-digest"): + await client._sign_outgoing_request(request) finally: current_operation.reset(token) - # covers_content_digest=forbidden → sign WITHOUT binding body. - assert "Signature" in request.headers - assert "Content-Digest" not in request.headers - _verify( - request, - body, - operation="create_media_buy", - covers_policy="forbidden", - required_for=frozenset({"create_media_buy"}), - ) - # -- invariants --------------------------------------------------------- diff --git a/tests/conformance/signing/test_e2e_fastapi.py b/tests/conformance/signing/test_e2e_fastapi.py index 719aa52dd..e86b75b71 100644 --- a/tests/conformance/signing/test_e2e_fastapi.py +++ b/tests/conformance/signing/test_e2e_fastapi.py @@ -82,6 +82,7 @@ async def test_signed_request_verifies_end_to_end(policy: str, cover_digest: boo key_id="test-ed25519-2026", alg="ed25519", cover_content_digest=cover_digest, + signing_profile_version="3.2", ) request_headers = {**headers, **signed.as_dict()} @@ -123,6 +124,7 @@ async def test_tampered_body_fails_digest_when_required() -> None: key_id="test-ed25519-2026", alg="ed25519", cover_content_digest=True, + signing_profile_version="3.2", ) tampered_body = b'{"plan_id":"plan_TAMPERED"}' request_headers = {"Content-Type": "application/json", **signed.as_dict()} diff --git a/tests/conformance/signing/test_install_signing_event_hook.py b/tests/conformance/signing/test_install_signing_event_hook.py index 0379ef173..aa9851e64 100644 --- a/tests/conformance/signing/test_install_signing_event_hook.py +++ b/tests/conformance/signing/test_install_signing_event_hook.py @@ -38,6 +38,15 @@ def _config() -> SigningConfig: + return SigningConfig( + private_key=private_key_from_jwk(ED25519_KEY, d_field="_private_d_for_test_only"), + key_id=ED25519_KEY["kid"], + alg="ed25519", + signing_profile_version="3.2", + ) + + +def _implicit_config() -> SigningConfig: return SigningConfig( private_key=private_key_from_jwk(ED25519_KEY, d_field="_private_d_for_test_only"), key_id=ED25519_KEY["kid"], @@ -117,6 +126,42 @@ async def test_signs_required_for_operation() -> None: ) +@pytest.mark.parametrize( + ("adcp_version", "uses_padded_base64"), + [("3.0", False), ("3.1", False), ("3.2-beta.0", True)], +) +@pytest.mark.asyncio +async def test_implicit_profile_uses_trusted_adcp_version( + adcp_version: str, + uses_padded_base64: bool, +) -> None: + request = httpx.Request( + method="POST", + url="https://seller.example.com/adcp/create_media_buy", + content=b"{}", + ) + client = httpx.AsyncClient() + install_signing_event_hook( + client, + signing=_implicit_config(), + seller_capability=_capability(required=["create_media_buy"]), + adcp_version=adcp_version, + ) + [hook] = client.event_hooks["request"] + with signing_operation("create_media_buy"): + await hook(request) + assert request.headers["Signature"].endswith("==:") is uses_padded_base64 + + +def test_implicit_profile_requires_trusted_adcp_version() -> None: + with pytest.raises(ValueError, match="requires adcp_version"): + install_signing_event_hook( + httpx.AsyncClient(), + signing=_implicit_config(), + seller_capability=_capability(required=["create_media_buy"]), + ) + + @pytest.mark.asyncio async def test_skips_unsigned_operation_not_in_any_list() -> None: request = httpx.Request( @@ -288,8 +333,8 @@ def provider() -> RequestSigning | None: @pytest.mark.asyncio -async def test_forbidden_covers_content_digest_omits_digest_coverage() -> None: - """A forbidden digest policy must not cover content-digest.""" +async def test_forbidden_covers_content_digest_is_rejected_under_32() -> None: + """A 3.2 peer cannot forbid its mandatory body digest coverage.""" body = b'{"plan_id":"p1"}' request = httpx.Request( method="POST", @@ -308,12 +353,8 @@ async def test_forbidden_covers_content_digest_omits_digest_coverage() -> None: [hook] = client.event_hooks["request"] with signing_operation("create_media_buy"): - await hook(request) - - assert "Signature" in request.headers - sig_input = request.headers["Signature-Input"] - # The covered-components list lives between parens before the `;` params block. - assert "content-digest" not in sig_input.lower(), sig_input + with pytest.raises(ValueError, match="must cover content-digest"): + await hook(request) def test_requires_exactly_one_of_capability_or_provider() -> None: diff --git a/tests/conformance/signing/test_keygen.py b/tests/conformance/signing/test_keygen.py index 646720f9f..f03e117d6 100644 --- a/tests/conformance/signing/test_keygen.py +++ b/tests/conformance/signing/test_keygen.py @@ -41,6 +41,7 @@ def test_generated_keypair_signs_and_verifies(generator, alg: str) -> None: private_key=private_key, # type: ignore[arg-type] key_id="test-kid", alg=alg, + signing_profile_version="3.2", ) headers = {"Content-Type": "application/json", **signed.as_dict()} diff --git a/tests/conformance/signing/test_keygen_programmatic.py b/tests/conformance/signing/test_keygen_programmatic.py index c42691c92..e7edddc21 100644 --- a/tests/conformance/signing/test_keygen_programmatic.py +++ b/tests/conformance/signing/test_keygen_programmatic.py @@ -295,6 +295,7 @@ def test_signature_produced_with_pem_verifies_against_jwk(alg: str) -> None: private_key=private_key, # type: ignore[arg-type] key_id=jwk["kid"], alg=alg_rfc, + signing_profile_version="3.2", ) headers = {"Content-Type": "application/json", **signed.as_dict()} diff --git a/tests/conformance/signing/test_pg_replay_store_e2e.py b/tests/conformance/signing/test_pg_replay_store_e2e.py index 2f078b19f..85590d870 100644 --- a/tests/conformance/signing/test_pg_replay_store_e2e.py +++ b/tests/conformance/signing/test_pg_replay_store_e2e.py @@ -158,6 +158,7 @@ async def test_signed_request_verifies_end_to_end( private_key=private_key, key_id="e2e-buyer", alg="ed25519", + signing_profile_version="3.2", ) headers = {"Content-Type": "application/json", **signed.as_dict()} @@ -192,6 +193,7 @@ async def test_replay_rejected_with_request_signature_replayed( private_key=private_key, key_id="e2e-buyer", alg="ed25519", + signing_profile_version="3.2", ) headers = {"Content-Type": "application/json", **signed.as_dict()} @@ -232,6 +234,7 @@ def _sign() -> dict[str, str]: private_key=private_key, key_id="e2e-buyer", alg="ed25519", + signing_profile_version="3.2", ) return {"Content-Type": "application/json", **signed.as_dict()} @@ -280,6 +283,7 @@ async def test_cross_instance_replay_rejection( private_key=private_key, key_id="e2e-buyer", alg="ed25519", + signing_profile_version="3.2", ) headers = {"Content-Type": "application/json", **signed.as_dict()} diff --git a/tests/conformance/signing/test_revocation_e2e.py b/tests/conformance/signing/test_revocation_e2e.py index 878bd7e48..82bdeae1e 100644 --- a/tests/conformance/signing/test_revocation_e2e.py +++ b/tests/conformance/signing/test_revocation_e2e.py @@ -105,9 +105,7 @@ async def handler(request: Any) -> Response: ) return Starlette( - routes=[ - Route("/.well-known/governance-revocations.json", handler, methods=["GET"]) - ] + routes=[Route("/.well-known/governance-revocations.json", handler, methods=["GET"])] ) @@ -136,9 +134,7 @@ def fetch( async def _do_fetch() -> httpx.Response: async with httpx.AsyncClient(transport=transport, base_url=ISSUER) as client: - return await client.get( - "/.well-known/governance-revocations.json", headers=headers - ) + return await client.get("/.well-known/governance-revocations.json", headers=headers) response = asyncio.run(_do_fetch()) @@ -286,6 +282,7 @@ def test_checker_plugs_into_verify_request_signature_pipeline() -> None: private_key=buyer_priv, key_id=buyer_jwk["kid"], alg=ALG_ED25519, + signing_profile_version="3.2", ) request_headers = {"Content-Type": "application/json", **signed.as_dict()} @@ -350,6 +347,7 @@ def test_checker_verifier_accepts_when_kid_not_in_revocation_list() -> None: private_key=buyer_priv, key_id=buyer_jwk["kid"], alg=ALG_ED25519, + signing_profile_version="3.2", ) request_headers = {"Content-Type": "application/json", **signed.as_dict()} diff --git a/tests/conformance/signing/test_signer.py b/tests/conformance/signing/test_signer.py index 1ea0820b5..ea93b6288 100644 --- a/tests/conformance/signing/test_signer.py +++ b/tests/conformance/signing/test_signer.py @@ -5,6 +5,8 @@ import json from pathlib import Path +import pytest + from adcp.signing import ( DEFAULT_TAG, StaticJwksResolver, @@ -45,6 +47,7 @@ def test_sign_then_verify_ed25519() -> None: private_key=private_key, key_id="test-ed25519-2026", alg="ed25519", + signing_profile_version="3.2", ) headers = {"Content-Type": "application/json", **signed.as_dict()} @@ -71,6 +74,7 @@ def test_sign_then_verify_es256() -> None: private_key=private_key, key_id="test-es256-2026", alg="ecdsa-p256-sha256", + signing_profile_version="3.2", ) headers = {"Content-Type": "application/json", **signed.as_dict()} @@ -97,6 +101,7 @@ def test_sign_with_content_digest_then_verify() -> None: key_id="test-ed25519-2026", alg="ed25519", cover_content_digest=True, + signing_profile_version="3.2", ) assert signed.content_digest is not None @@ -129,6 +134,68 @@ def test_signed_input_uses_adcp_tag() -> None: private_key=private_key, key_id="test-ed25519-2026", alg="ed25519", + signing_profile_version="3.2", ) assert f'tag="{DEFAULT_TAG}"' in signed.signature_input assert signed.signature.startswith("sig1=:") and signed.signature.endswith(":") + + +def test_low_level_signer_requires_profile() -> None: + private_key = private_key_from_jwk(ED25519_KEY, d_field="_private_d_for_test_only") + with pytest.raises(TypeError, match="signing_profile_version"): + sign_request( # type: ignore[call-arg] + method="POST", + url="https://seller.example.com/adcp/create_media_buy", + headers={}, + body=b"{}", + private_key=private_key, + key_id="test-ed25519-2026", + alg="ed25519", + ) + + +def test_low_level_signer_rejects_unknown_profile() -> None: + private_key = private_key_from_jwk(ED25519_KEY, d_field="_private_d_for_test_only") + with pytest.raises(ValueError, match="signing_profile_version"): + sign_request( + method="POST", + url="https://seller.example.com/adcp/create_media_buy", + headers={}, + body=b"{}", + private_key=private_key, + key_id="test-ed25519-2026", + alg="ed25519", + signing_profile_version="3.3", # type: ignore[arg-type] + ) + + +def test_32_nonempty_body_covers_digest_by_default() -> None: + private_key = private_key_from_jwk(ED25519_KEY, d_field="_private_d_for_test_only") + signed = sign_request( + method="POST", + url="https://seller.example.com/adcp/create_media_buy", + headers={}, + body=b"{}", + private_key=private_key, + key_id="test-ed25519-2026", + alg="ed25519", + signing_profile_version="3.2", + ) + assert signed.content_digest is not None + assert '"content-digest"' in signed.signature_input + + +def test_32_nonempty_body_rejects_disabled_digest() -> None: + private_key = private_key_from_jwk(ED25519_KEY, d_field="_private_d_for_test_only") + with pytest.raises(ValueError, match="must cover content-digest"): + sign_request( + method="POST", + url="https://seller.example.com/adcp/create_media_buy", + headers={}, + body=b"{}", + private_key=private_key, + key_id="test-ed25519-2026", + alg="ed25519", + cover_content_digest=False, + signing_profile_version="3.2", + ) diff --git a/tests/conformance/signing/test_signing_provider.py b/tests/conformance/signing/test_signing_provider.py index 72a7ab68d..63e9d057d 100644 --- a/tests/conformance/signing/test_signing_provider.py +++ b/tests/conformance/signing/test_signing_provider.py @@ -89,6 +89,7 @@ def test_async_sign_then_verify_ed25519() -> None: headers={"Content-Type": "application/json"}, body=body, provider=provider, + signing_profile_version="3.2", ) ) @@ -120,6 +121,7 @@ def test_async_sign_then_verify_es256() -> None: headers={"Content-Type": "application/json"}, body=body, provider=provider, + signing_profile_version="3.2", ) ) @@ -148,6 +150,7 @@ def test_async_sign_includes_content_digest_when_requested() -> None: body=body, provider=provider, cover_content_digest=True, + signing_profile_version="3.2", ) ) assert signed.content_digest is not None @@ -172,6 +175,7 @@ def test_sync_and_async_byte_identical_for_ed25519() -> None: "body": body, "created": 1714500000, "nonce": "AAAAAAAAAAAAAAAAAAAAAA", + "signing_profile_version": "3.2", } sync_signed = sign_request( **pinned, private_key=private_key, key_id="test-ed25519-2026", alg="ed25519" @@ -207,6 +211,7 @@ def test_sync_and_async_signature_input_identical_for_es256() -> None: "created": 1714500000, "nonce": "AAAAAAAAAAAAAAAAAAAAAA", "cover_content_digest": True, + "signing_profile_version": "3.2", } sync_signed = sign_request( **pinned, @@ -244,6 +249,7 @@ def test_key_id_with_quotes_and_backslash_round_trips() -> None: private_key=private_key, key_id=weird_kid, alg="ed25519", + signing_profile_version="3.2", ) parsed = parse_signature_input_header(signed.signature_input) @@ -371,6 +377,7 @@ def test_sign_request_rejects_key_id_with_control_characters() -> None: private_key=private_key, key_id="kid\r\nInjected: 1", alg="ed25519", + signing_profile_version="3.2", ) @@ -385,6 +392,7 @@ def test_sign_request_rejects_key_id_with_non_ascii() -> None: private_key=private_key, key_id="kid”", # right double quotation mark — sf-string parser-divergence risk alg="ed25519", + signing_profile_version="3.2", ) @@ -403,6 +411,7 @@ def test_sign_request_rejects_label_with_crlf() -> None: key_id="ok", alg="ed25519", label="sig1\r\nX-Injected: 1", + signing_profile_version="3.2", ) @@ -419,6 +428,7 @@ def test_sign_request_rejects_label_starting_with_uppercase() -> None: key_id="ok", alg="ed25519", label="Sig1", + signing_profile_version="3.2", ) @@ -434,6 +444,7 @@ def test_sign_request_rejects_empty_label() -> None: key_id="ok", alg="ed25519", label="", + signing_profile_version="3.2", ) @@ -461,4 +472,5 @@ def test_sign_request_rejects_tag_with_control_characters() -> None: key_id="ok", alg="ed25519", tag="adcp\x00bad", + signing_profile_version="3.2", ) diff --git a/tests/conformance/signing/test_target_uri_malformed_boundary.py b/tests/conformance/signing/test_target_uri_malformed_boundary.py index 5577c4b67..98770f784 100644 --- a/tests/conformance/signing/test_target_uri_malformed_boundary.py +++ b/tests/conformance/signing/test_target_uri_malformed_boundary.py @@ -87,6 +87,7 @@ def _signed_request_headers(*, url: str = SIGNED_URL) -> dict[str, str]: key_id=REQUEST_ED25519["kid"], alg="ed25519", created=CREATED, + signing_profile_version="3.2", ) return {**headers, **signed.as_dict()} diff --git a/tests/conformance/signing/test_verifier_behaviors.py b/tests/conformance/signing/test_verifier_behaviors.py index b75e19e5e..8c46fea9f 100644 --- a/tests/conformance/signing/test_verifier_behaviors.py +++ b/tests/conformance/signing/test_verifier_behaviors.py @@ -52,6 +52,7 @@ def _sign_basic( key_id="test-ed25519-2026", alg="ed25519", created=created, + signing_profile_version="3.2", ) return {**headers, **signed.as_dict()}, body diff --git a/tests/conformance/signing/test_verifier_key_origins.py b/tests/conformance/signing/test_verifier_key_origins.py index 298bb6c88..cccb135ea 100644 --- a/tests/conformance/signing/test_verifier_key_origins.py +++ b/tests/conformance/signing/test_verifier_key_origins.py @@ -100,6 +100,7 @@ def _sign_basic( key_id="test-ed25519-2026", alg="ed25519", created=created, + signing_profile_version="3.2", ) return {**headers, **signed.as_dict()}, body diff --git a/tests/conformance/signing/test_webhook_signer.py b/tests/conformance/signing/test_webhook_signer.py index d322bc59d..4f3688ea2 100644 --- a/tests/conformance/signing/test_webhook_signer.py +++ b/tests/conformance/signing/test_webhook_signer.py @@ -142,6 +142,7 @@ def test_accepts_request_signing_key_for_webhook_profile() -> None: alg="ed25519", cover_content_digest=True, tag="adcp/webhook-signing/v1", + signing_profile_version="3.2", ) headers = {"Content-Type": "application/json", **signed.as_dict()} @@ -186,6 +187,7 @@ def test_rejects_request_signing_tag() -> None: alg="ed25519", cover_content_digest=True, tag=DEFAULT_TAG, + signing_profile_version="3.2", ) headers = {"Content-Type": "application/json", **signed.as_dict()} diff --git a/tests/fixtures/public_api_snapshot.json b/tests/fixtures/public_api_snapshot.json index 9fb86abc5..69dff9c28 100644 --- a/tests/fixtures/public_api_snapshot.json +++ b/tests/fixtures/public_api_snapshot.json @@ -70,6 +70,7 @@ "AuthorizedAgentsBySignalTag", "BothPreviewRender", "BrandActivity", + "BrandIdentity", "BrandReference", "BrandRegistryItem", "BrandSource", @@ -634,6 +635,7 @@ "AvailablePackage", "AvailableReportingFrequency", "BothPreviewRender", + "BrandIdentity", "BrandReference", "BrandSource", "BriefAsset", diff --git a/tests/test_backward_compat.py b/tests/test_backward_compat.py index db9c151ee..f8fca3167 100644 --- a/tests/test_backward_compat.py +++ b/tests/test_backward_compat.py @@ -123,6 +123,65 @@ def test_catalog_group_binding_has_correct_kind(self): assert "kind" in CatalogGroupBinding.__annotations__ +class TestBrandIdentity: + """Private generated Brand imports have a collision-safe public target.""" + + def test_brand_identity_is_public_everywhere(self): + from adcp import BrandIdentity as RootBrandIdentity + from adcp.types import BrandIdentity + from adcp.types.buyer import BrandIdentity as BuyerBrandIdentity + from adcp.types.generated_poc.brand_discovery import Brand + + assert RootBrandIdentity is BrandIdentity is BuyerBrandIdentity is Brand + + def test_brand_identity_preserves_brand_json_shape(self): + from adcp.types import BrandIdentity + + brand = BrandIdentity(id="acme", names=[{"en": "Acme"}]) + assert brand.id.root == "acme" + assert brand.names[0].root == {"en": "Acme"} + assert len(BrandIdentity.model_fields) == 29 + + def test_brand_identity_field_contract_and_collision_separation(self): + from adcp.types import BrandIdentity + from adcp.types.generated_poc.protocol.get_adcp_capabilities_response import ( + Brand as CapabilitiesBrand, + ) + + assert BrandIdentity is not CapabilitiesBrand + assert tuple(BrandIdentity.model_fields) == ( + "id", + "url", + "identity_relying_parties", + "names", + "keller_type", + "parent_brand", + "description", + "industries", + "target_audience", + "logos", + "colors", + "fonts", + "tone", + "tagline", + "assets", + "properties", + "product_catalog", + "privacy_policy_url", + "data_subject_contestation", + "disclaimers", + "trademarks", + "voice_synthesis", + "avatar", + "visual_guidelines", + "agents", + "brand_agent", + "rights_agent", + "contact", + "collections", + ) + + class TestAllBackwardCompatInAll: """All backward-compat aliases must appear in __all__.""" diff --git a/tests/test_compact_lifecycle_matrix.py b/tests/test_compact_lifecycle_matrix.py index da4299e8d..26352f318 100644 --- a/tests/test_compact_lifecycle_matrix.py +++ b/tests/test_compact_lifecycle_matrix.py @@ -50,6 +50,7 @@ "accept_proposal", "control_media_buy", } +PROPOSAL_TASKS = COMPACT_TASKS - {"buy_products"} STATEFUL_COMPACT_TASKS = COMPACT_TASKS - {"list_products"} LEGACY_LIFECYCLE_TASKS = {"get_products", "create_media_buy", "update_media_buy"} @@ -99,7 +100,9 @@ def control_media_buy(self, req, ctx): class _ProposalLifecyclePlatform(_DirectLifecyclePlatform): capabilities = DecisioningCapabilities( specialisms=["sales-proposal-mode"], - media_buy=MediaBuy(lifecycle_tools=list(LifecycleTool)), + media_buy=MediaBuy( + lifecycle_tools=[LifecycleTool(tool) for tool in sorted(PROPOSAL_TASKS)] + ), ) def request_proposals(self, req, ctx): @@ -126,7 +129,7 @@ def accept_proposal(self, req, ctx): "direct", LEGACY_LIFECYCLE_TASKS | {"list_products", "buy_products", "control_media_buy"}, ), - ("3.2-beta.0", "proposal", LEGACY_LIFECYCLE_TASKS | COMPACT_TASKS), + ("3.2-beta.0", "proposal", LEGACY_LIFECYCLE_TASKS | PROPOSAL_TASKS), ], ) def test_protocol_lifecycle_matrix(version: str, variant: str, expected_tasks: set[str]) -> None: @@ -140,7 +143,8 @@ def test_protocol_lifecycle_matrix(version: str, variant: str, expected_tasks: s assert "request_proposals" not in expected_tasks assert {"list_products", "buy_products", "control_media_buy"} <= expected_tasks else: - assert COMPACT_TASKS <= expected_tasks + assert PROPOSAL_TASKS <= expected_tasks + assert "buy_products" not in expected_tasks def test_decisioning_advertises_only_declared_compact_variant() -> None: @@ -161,7 +165,7 @@ def test_decisioning_advertises_only_declared_compact_variant() -> None: "buy_products", "control_media_buy", } - assert proposal.get_advertised_tools() >= COMPACT_TASKS + assert proposal.get_advertised_tools() & COMPACT_TASKS == PROPOSAL_TASKS def test_decisioning_rejects_claimed_lifecycle_tool_without_method() -> None: diff --git a/tests/test_update_rights_roundtrip.py b/tests/test_update_rights_roundtrip.py index e2689ec70..14928565b 100644 --- a/tests/test_update_rights_roundtrip.py +++ b/tests/test_update_rights_roundtrip.py @@ -1,4 +1,4 @@ -"""Round-trip coverage for the new update_rights task (AdCP 3.0.0-rc.4). +"""Round-trip coverage for the update_rights task. Spec-coverage suite proves the method exists; this file proves it actually serializes the request correctly, reaches the adapter, and parses the @@ -53,6 +53,51 @@ def _full_terms(**overrides: Any) -> dict[str, Any]: return base +def _rights_constraint(rights_id: str) -> dict[str, Any]: + """A minimally complete AdCP 3.2 attested rights constraint.""" + agent_url = "https://rights.example/adcp" + holder = {"domain": "brand.example"} + digest = f"sha256:{'0' * 64}" + return { + "rights_id": rights_id, + "rights_agent": {"url": agent_url, "id": "rights-agent"}, + "rights_holder": holder, + "uses": ["endorsement"], + "grant_status": "active", + "content_digest": digest, + "attestation_refs": [ + { + "issuer": {"type": "brand", "brand": holder}, + "claim_type": "https://adcontextprotocol.org/claims/rights/grant", + "subject": { + "type": "resource", + "resource_type": ("https://adcontextprotocol.org/claims/subjects/rights-grant"), + "namespace": agent_url, + "id": rights_id, + "content_digest": digest, + }, + "locator": { + "type": "issuer_credential_id", + "credential_id": f"credential-{rights_id}", + "resolver_id": "primary", + }, + } + ], + } + + +def _success_response(rights_id: str, **overrides: Any) -> dict[str, Any]: + """A complete AdCP 3.2 update_rights success response.""" + response: dict[str, Any] = { + "rights_id": rights_id, + "terms": _full_terms(), + "generation_credentials": [], + "rights_constraint": _rights_constraint(rights_id), + } + response.update(overrides) + return response + + class TestUpdateRightsA2A: @pytest.mark.asyncio async def test_partial_update_reaches_wire(self) -> None: @@ -69,10 +114,7 @@ async def test_partial_update_reaches_wire(self) -> None: mock_client.send_message = AsyncMock( return_value=SendMessageSuccessResponse( result=_task_with_data( - { - "rights_id": "rts_live_01", - "terms": _full_terms(end_date="2026-12-31"), - } + _success_response("rts_live_01", terms=_full_terms(end_date="2026-12-31")) ) ) ) @@ -111,13 +153,7 @@ async def test_response_parses_as_union_type(self) -> None: mock_client = AsyncMock() mock_client.send_message = AsyncMock( return_value=SendMessageSuccessResponse( - result=_task_with_data( - { - "rights_id": "rts_live_02", - "terms": _full_terms(), - "paused": True, - } - ) + result=_task_with_data(_success_response("rts_live_02", paused=True)) ) ) with patch.object(client.adapter, "_get_a2a_client", return_value=mock_client): diff --git a/tests/test_version_scoped_models.py b/tests/test_version_scoped_models.py new file mode 100644 index 000000000..090c9856d --- /dev/null +++ b/tests/test_version_scoped_models.py @@ -0,0 +1,199 @@ +"""Version-scoped public models and MCP tool-schema advertisement.""" + +from __future__ import annotations + +import pytest +from jsonschema.validators import validator_for +from pydantic import TypeAdapter, ValidationError + +from adcp.server import ( + ADCPHandler, + ToolContext, + adcp_server, + create_mcp_server, + create_mcp_tools, +) +from adcp.server.mcp_tools import get_tools_for_handler +from adcp.types.v31 import BuildCreativeSubmittedResponse +from adcp.types.v31 import GetBrandIdentityRequest as GetBrandIdentityRequest31 +from adcp.types.v31 import ListCreativesRequest as ListCreativesRequest31 +from adcp.types.v31 import PackageRequest as PackageRequest31 +from adcp.types.v32 import ListCreativesRequest as ListCreativesRequest32 +from adcp.types.v32 import PackageRequest as PackageRequest32 +from adcp.validation import get_mcp_schema, get_validator + + +def test_list_creatives_schema_is_version_scoped() -> None: + schema31 = ListCreativesRequest31.model_json_schema() + schema32 = ListCreativesRequest32.model_json_schema() + + assert "assignment_projection" not in schema31["properties"] + assert "assignment_limit" not in schema31["properties"] + assert "assignment_projection" in schema32["properties"] + assert "assignment_limit" in schema32["properties"] + + +def test_package_budget_requirement_is_version_scoped() -> None: + with pytest.raises(ValidationError, match="budget.*required property"): + PackageRequest31(product_id="product-1", pricing_option_id="fixed") + + package32 = PackageRequest32(product_id="product-1", pricing_option_id="fixed") + assert package32.model_dump() == { + "product_id": "product-1", + "pricing_option_id": "fixed", + "paused": False, + } + assert "budget" in PackageRequest31.model_json_schema()["required"] + assert "budget" not in PackageRequest32.model_json_schema()["required"] + + +def test_versioned_models_keep_generated_model_ergonomics() -> None: + request = ListCreativesRequest31(include_assignments=True) + assert request.include_assignments is True + assert request["include_assignments"] is True + assert request.model_dump()["include_assignments"] is True + + +def test_versioned_models_apply_schema_defaults() -> None: + request = ListCreativesRequest31() + assert request.include_assignments is True + assert request.include_snapshot is False + + +def test_type_adapter_exposes_versioned_schema() -> None: + schema = TypeAdapter(ListCreativesRequest31).json_schema() + assert "assignment_projection" not in schema["properties"] + assert schema["properties"]["include_assignments"]["default"] is True + + +def test_non_bundled_and_async_models_are_public() -> None: + request = GetBrandIdentityRequest31(brand_id="brand-1") + submitted = BuildCreativeSubmittedResponse(task_id="task-1", status="submitted") + assert request.brand_id == "brand-1" + assert submitted.status == "submitted" + + +def _tool_map(version: str) -> dict[str, dict]: + builder = adcp_server("versioned-seller", adcp_version=version) + + @builder.list_creatives + async def list_creatives(params, context=None): # noqa: ANN001, ANN202 + return {"creatives": []} + + @builder.list_products + async def list_products(params, context=None): # noqa: ANN001, ANN202 + return {"products": []} + + return {tool["name"]: tool for tool in get_tools_for_handler(builder.build_handler())} + + +def test_mcp_tools_list_uses_pinned_31_schemas() -> None: + tools = _tool_map("3.1") + properties = tools["list_creatives"]["inputSchema"]["properties"] + + assert "assignment_projection" not in properties + assert "assignment_limit" not in properties + assert "list_products" not in tools + + +def test_mcp_tools_list_uses_pinned_32_schemas() -> None: + tools = _tool_map("3.2-beta.0") + properties = tools["list_creatives"]["inputSchema"]["properties"] + + assert "assignment_projection" in properties + assert "assignment_limit" in properties + assert "list_products" in tools + + +def test_mcp_tools_list_keeps_non_bundled_tools() -> None: + builder = adcp_server("brand-agent", adcp_version="3.1") + + @builder.get_brand_identity + async def get_brand_identity(params, context=None): # noqa: ANN001, ANN202 + return {"brand_id": params["brand_id"]} + + tools = {tool["name"]: tool for tool in get_tools_for_handler(builder.build_handler())} + assert "get_brand_identity" in tools + assert "brand_id" in tools["get_brand_identity"]["inputSchema"]["required"] + + +def test_mcp_32_uses_compact_transport_schemas() -> None: + import json + + tools = _tool_map("3.2-beta.0") + encoded = json.dumps(tools["list_creatives"]) + assert len(encoded) < 300_000 + + +def _contains_nonlocal_ref(value: object) -> bool: + if isinstance(value, dict): + reference = value.get("$ref") + return ( + isinstance(reference, str) + and not reference.startswith("#/") + or any(_contains_nonlocal_ref(item) for item in value.values()) + ) + if isinstance(value, list): + return any(_contains_nonlocal_ref(item) for item in value) + return False + + +@pytest.mark.parametrize("version", ["3.0", "3.1", "3.2-beta.0"]) +def test_pinned_mcp_inventory_is_portable_and_context_bounded(version: str) -> None: + tools = get_tools_for_handler(ADCPHandler, advertise_all=True, adcp_version=version) + assert not _contains_nonlocal_ref(tools) + + sample = next(tool for tool in tools if tool["name"] == "list_creatives") + schema = sample["inputSchema"] + validator_for(schema).check_schema(schema) + + +def test_mcp_compaction_preserves_deep_validation() -> None: + payload = { + "account": { + "brand": {"domain": 123}, + "operator": "agency.example", + } + } + canonical = get_validator("list_accounts", "request", version="3.1") + mcp_schema = get_mcp_schema("list_accounts", "request", version="3.1") + assert canonical is not None and mcp_schema is not None + mcp_validator = validator_for(mcp_schema)(mcp_schema) + assert list(canonical.iter_errors(payload)) + assert list(mcp_validator.iter_errors(payload)) + + +def test_mcp_tools_reject_missing_schema_bundle() -> None: + with pytest.raises(ValueError, match="no bundled AdCP schemas"): + get_tools_for_handler(ADCPHandler, advertise_all=True, adcp_version="3.3") + + +class _VersionCapturingHandler(ADCPHandler): + advertised_tools = {"list_creatives"} + + def __init__(self) -> None: + super().__init__() + self.resolved_version: str | None = None + + async def list_creatives(self, params, context=None): # noqa: ANN001, ANN202 + assert isinstance(context, ToolContext) + self.resolved_version = context.resolved_adcp_version + return {"creatives": []} + + +@pytest.mark.asyncio +async def test_mcp_dispatch_uses_same_pin_as_advertisement() -> None: + handler = _VersionCapturingHandler() + tools = create_mcp_tools(handler, adcp_version="3.2-beta.0") + await tools.call_tool("list_creatives", {}) + assert handler.resolved_version == "3.2-beta.0" + + +@pytest.mark.asyncio +async def test_create_mcp_server_dispatch_uses_advertised_pin() -> None: + handler = _VersionCapturingHandler() + handler._adcp_version = "3.2-beta.0" + mcp = create_mcp_server(handler, validation=None) + tool_fn = mcp._tool_manager._tools["list_creatives"].fn + await tool_fn() + assert handler.resolved_version == "3.2-beta.0" diff --git a/tests/type_checks/brand_identity_imports.py b/tests/type_checks/brand_identity_imports.py new file mode 100644 index 000000000..60c766257 --- /dev/null +++ b/tests/type_checks/brand_identity_imports.py @@ -0,0 +1,11 @@ +"""Static public-import contract for the collision-safe brand identity type.""" + +from adcp import BrandIdentity as RootBrandIdentity +from adcp.types import BrandIdentity +from adcp.types.buyer import BrandIdentity as BuyerBrandIdentity + +root: RootBrandIdentity = RootBrandIdentity.model_construct() +typed: BrandIdentity = root +buyer: BuyerBrandIdentity = typed + +assert isinstance(buyer, BrandIdentity)