From 382302a1a1559f3e81cbb176eebf25baa7b7dbc5 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 2 Nov 2026 00:50:19 +0000 Subject: [PATCH] feat(capabilities): self-report adcp.implementation.sdks (#1646) AdCP 3.3 lets an agent report the SDKs it runs and the server-side components each supplies. serve() (and adcp.decisioning.serve) now stamps {"name": "adcp", "version": , "components": [...]} on get_adcp_capabilities responses served at 3.3 or later, via an SDK response enhancer that runs ahead of the adopter's own. Components are claimed only when provably agent-wide: transport always; types for Decisioning platforms; schema_validation for strict request and response validation without the 2.5 legacy adapter; authentication for BearerTokenAuth with no widened discovery set; idempotency when every advertised mutating task is IdempotencyStore-wrapped; webhooks / signing only when the SDK owns and signs every outbound webhook; lifecycle never. A declared adcp.implementation always wins (DecisioningCapabilities.adcp .implementation is now projected; capabilities_response gains implementation=). serve(report_implementation=False) opts out; ImplementationReport adds components or further sdks[] entries. 3.0-3.2 callers and 3.2-pinned servers never see the block. Co-Authored-By: Claude Opus 5.5 --- MIGRATION_v9_to_v10.md | 1 + docs/adcp-3.3-sdk-inventory.md | 4 +- src/adcp/decisioning/capabilities.py | 6 + src/adcp/decisioning/handler.py | 3 + src/adcp/server/__init__.py | 3 + src/adcp/server/implementation.py | 518 +++++++++++++++++++++++ src/adcp/server/responses.py | 14 + src/adcp/server/serve.py | 37 ++ src/adcp/types/capabilities.py | 11 + tests/test_implementation_report.py | 591 +++++++++++++++++++++++++++ 10 files changed, 1186 insertions(+), 2 deletions(-) create mode 100644 src/adcp/server/implementation.py create mode 100644 tests/test_implementation_report.py diff --git a/MIGRATION_v9_to_v10.md b/MIGRATION_v9_to_v10.md index 2d617eddf..e0efe4047 100644 --- a/MIGRATION_v9_to_v10.md +++ b/MIGRATION_v9_to_v10.md @@ -50,3 +50,4 @@ the SDK-relevant inventory is [docs/adcp-3.3-sdk-inventory.md](docs/adcp-3.3-sdk | Permissive adagents.json resolution requires the fetched domain | `resolve_properties_for_agent(doc, agent_url, mode="permissive")` raises `ValueError` without `publisher_domain=`. Pass the domain the document was fetched for: a bare entry's top-level properties are then kept only when every identifier is a `type: "domain"` under that domain, so a file on another host can no longer claim a publisher's site, app bundle or DOOH venue through a bare entry. Properties identified by bundle, store or venue IDs, and properties whose `publisher_domain` names another domain, are dropped from the bare fallback. `allow_any_domain=True` restores the SDK 9 result. Typed selectors and strict mode are unchanged. See [docs/adagents-discovery.md](docs/adagents-discovery.md) for `properties_for_publisher_domain` and `verify_cross_domain_properties`. | | Generated enums name each value once | The `_1` alias members codegen emitted when an `anyOf` arm repeated an enum value (`System.zip_1`, `Field17.ad_request_url_1`, 19 enums on 10.0.0b4) are gone; each value has one member, the first-declared one. Code that referenced an alias name (`System.zip_1`) must use the real member (`System.zip`); `System("zip")` and `member.value` are unchanged. | | `list_account_changes` pages are checked and projected per caller | AdCP 3.3 requires non-empty `changed_paths` on every `adcp` / `seller_operator` change record (`[""]` for a whole-resource create, delete or purge) and lets the originating request's `idempotency_key` reach only the principal that sent it. A handler on 3.3 or later that returns such a record without `changed_paths` now fails the call with `VALIDATION_ERROR` (`field` `/changes/N/changed_paths`), whatever the validation mode; and every record loses `idempotency_key` unless it is an `adcp` record built with `adcp.account_change_feed.attribute_account_change(record, originator)` whose originator is the caller (`ToolContext.tenant_id` + `caller_identity`). Persist the originator when the mutation commits and attribute records when serving the page. `WebhookSender.prepare_account_change_recorded` / `send_account_change_recorded` also accept the change record itself (with `subscriber_id=`), refuse one without `changed_paths`, and never carry its `idempotency_key`. | +| `get_adcp_capabilities` reports `adcp.implementation` | `serve()` (and `adcp.decisioning.serve()`) now adds AdCP 3.3 `adcp.implementation.sdks[]` to capabilities served at 3.3 or later: one entry `{"name": "adcp", "version": , "components": [...]}`, listing only components the server provably wires agent-wide (`transport` always; `types` for Decisioning platforms; `schema_validation` with strict request and response validation and no `2.5` in `supported_versions`; `authentication` with `auth=BearerTokenAuth(...)` and no widened discovery set; `idempotency` when every advertised mutating task is `@IdempotencyStore.wrap`ped; `webhooks` / `signing` only when the SDK owns and signs every outbound webhook; `lifecycle` never automatically). A 3.0 to 3.2 caller never sees it. Tests that compare a whole 3.3 capabilities response need the new key. Opt out with `serve(report_implementation=False)` (or `ServeConfig(report_implementation=False)`); add components or other SDKs with `ImplementationReport(extra_components=[...], extra_sdks=[...])`; or declare the block yourself (`Adcp(implementation=...)` on `DecisioningCapabilities`, `capabilities_response(implementation=...)`, or in your own handler's response), which the SDK never overwrites. `adcp.server.adcp_sdk_entry()` builds this SDK's entry for a hand-written block. `DecisioningCapabilities.adcp.implementation` was previously dropped from the projection; it is now emitted. | diff --git a/docs/adcp-3.3-sdk-inventory.md b/docs/adcp-3.3-sdk-inventory.md index ec6f74b38..a0ac77530 100644 --- a/docs/adcp-3.3-sdk-inventory.md +++ b/docs/adcp-3.3-sdk-inventory.md @@ -111,7 +111,7 @@ it adopted the bundle, and what is left to the codegen/wire-fidelity (2) and | Discovery never provisions | `get_products`, `list_products`, `get_signals`, `request_proposals`, `refine_proposals`, `decline_proposals` MUST NOT create or activate an account; unresolved `account` → `ACCOUNT_NOT_FOUND`; `list_accounts` treats `account` as a filter | Storyboard `media_buy_seller/unprovisioned_account_reference` (advisory until runner 14.1.0). | done: discovery tasks never provision (`ResolveContext.is_provisioning`, #1288) and a supplied unresolved reference is `ACCOUNT_NOT_FOUND`; confirm with the storyboard: (3) | | `account-change.json` `idempotency_key`; `changed_paths` required for `adcp` / `seller_operator` origins | `AccountChange.idempotency_key` | Echoed to the sending principal only. | generated; `list_account_changes` dispatch refuses a page with an `adcp` / `seller_operator` record lacking non-empty `changed_paths` (`VALIDATION_ERROR` at `/changes/N/changed_paths`, any validation mode, handlers on 3.3 or later) and strips `idempotency_key` from every record except an `adcp` record attributed (`attribute_account_change`, off the wire) to the calling principal; `WebhookSender.prepare_account_change_recorded` accepts the record, checks it, and never carries its key; helpers in `adcp.account_change_feed`: done (3), #1647 | | `account.agent_onboarding`, `get_principal` `relationship`, `identity.trust_json`, `identity-relying-party` | `core/agent-onboarding.json`, `-tier.json`, `-requirement.json`, `core/buyer-onboarding-posture.json`, `core/principal-relationship.json`, `trust/v1/trust.json`, `trust/v1/trust-acknowledgements.json`; enums `agent-onboarding-mode`, `agent-onboarding-requirement-kind` (x-extensible), `principal-relationship-status`; `PrincipalChangedWebhook.Reason` `relationship_changed`, `relationship_expiring`; `get_principal` `Result.errors` | Experimental. The identity preview (`_identity_preview.py`, vendored `_identity_schemas/`, `generate_identity_preview.py`) is retired: `adcp.types.experimental_identity` binds the generated 3.3 types, `AgentOnboardingRequirement.kind` stays open (`enum | str`), and `adcp.signing.trust` validates against the bundle through `get_named_validator`. | done (1) | -| `adcp.implementation.sdks[]` | capabilities `Adcp.implementation` (`Implementation`, `Sdk`, `Component`) | Vendors move data out of `ext.adcp`. | generated; advertise this SDK: (3), #1646 | +| `adcp.implementation.sdks[]` | capabilities `Adcp.implementation` (`Implementation`, `Sdk`, `Component`) | Vendors move data out of `ext.adcp`. | generated; `serve()` self-reports `{"name": "adcp", "version": , "components": [...]}` on responses served at 3.3+, with components derived from what the server wires agent-wide (`adcp.server.implementation`); a declared block wins, `serve(report_implementation=False)` opts out: done (3), #1646 | | `security_profiles` | `GetAdcpCapabilitiesResponse.security_profiles` (`adcp-prod-security-3.3`, experimental) | | generated | | `request_signing.profiles`, `operation_sources`; `webhook_signing.profiles` | capabilities `RequestSigning.profiles` (`adcp/request-signing/v1`, `web-bot-auth`), `operation_sources` (`mcp_tools_call`, `a2a_invocation_skill`) | `required_for` enforced over A2A with `operation_sources` as the advertisement. | A2A hardening enforcement: done (3), #1659 (33 vectors pass); advertising `operation_sources`: (3), #1637 | | `ext.adcp` reserved | `extensions/adcp.json` `AdcpOwnedExtensions` | Reserved namespace for the working group. | generated | @@ -129,7 +129,7 @@ Every new declaration is optional; 3.2 behaviour stands when undeclared. | Path | Added | SDK status | | --- | --- | --- | -| `adcp.implementation` | `Implementation` (`sdks[]` of `Sdk` / `Component`) | generated | +| `adcp.implementation` | `Implementation` (`sdks[]` of `Sdk` / `Component`) | generated; exported as `Implementation`, `Sdk`, `ImplementationComponent` from `adcp.decisioning.capabilities`; projected from `DecisioningCapabilities.adcp.implementation` and `capabilities_response(implementation=...)`; otherwise `serve()` self-reports it: done (3), #1646 | | `security_profiles` | `SecurityProfile[]` | generated | | `account` | `agent_onboarding`, `stable_account_id` | generated | | `creative` | `stable_creative_ids`, `supports_assignment_landing_page`, `supports_assignment_merge`, `supports_assignment_trackers`, `broadcast_loudness` | generated | diff --git a/src/adcp/decisioning/capabilities.py b/src/adcp/decisioning/capabilities.py index 4bbee67d5..1fc8223eb 100644 --- a/src/adcp/decisioning/capabilities.py +++ b/src/adcp/decisioning/capabilities.py @@ -70,6 +70,8 @@ class HelloSeller(DecisioningPlatform): IdempotencySupported, IdempotencyUnsupported, Identity, + Implementation, + ImplementationComponent, KeyOrigins, KeywordTargets, LaterProposals, @@ -82,6 +84,7 @@ class HelloSeller(DecisioningPlatform): OpenOpportunities, Portfolio, RequestSigning, + Sdk, SiCapabilities, Signals, SignalsFeatures, @@ -147,6 +150,8 @@ class HelloSeller(DecisioningPlatform): "IdempotencySupported", "IdempotencyUnsupported", "Identity", + "Implementation", + "ImplementationComponent", "KeyOrigins", "KeywordTargets", "LaterProposals", @@ -163,6 +168,7 @@ class HelloSeller(DecisioningPlatform): "PreviewRenderingOrigin", "PreviewRoute", "RequestSigning", + "Sdk", "SiCapabilities", "Signals", "SignalsFeatures", diff --git a/src/adcp/decisioning/handler.py b/src/adcp/decisioning/handler.py index 6ca71b65b..722e19d3c 100644 --- a/src/adcp/decisioning/handler.py +++ b/src/adcp/decisioning/handler.py @@ -2263,6 +2263,9 @@ async def get_adcp_capabilities( supported_versions=adcp.get("supported_versions"), build_version=adcp.get("build_version"), idempotency=adcp.get("idempotency") or {"supported": False}, + # A declared AdCP 3.3 ``adcp.implementation`` wins over the + # self-report ``serve()`` adds (adcp.server.implementation). + implementation=adcp.get("implementation"), ) from adcp.server.version_policy import project_supported_versions diff --git a/src/adcp/server/__init__.py b/src/adcp/server/__init__.py index ed0508e3f..88c9295cd 100644 --- a/src/adcp/server/__init__.py +++ b/src/adcp/server/__init__.py @@ -134,6 +134,7 @@ async def get_products(params, context=None): valid_actions_for_status, ) from adcp.server.idempotency import IdempotencyStore, MemoryBackend +from adcp.server.implementation import ImplementationReport, adcp_sdk_entry from adcp.server.mcp_sessions import MCPSessionStats, get_mcp_session_stats from adcp.server.mcp_tools import ( DISCOVERY_METHODS, @@ -278,6 +279,8 @@ async def get_products(params, context=None): "ResponseEnhancer", "SchemaMode", "ServeConfig", + "ImplementationReport", + "adcp_sdk_entry", "create_mcp_tools", "create_mcp_server", "get_tools_for_handler", diff --git a/src/adcp/server/implementation.py b/src/adcp/server/implementation.py new file mode 100644 index 000000000..a246bdddf --- /dev/null +++ b/src/adcp/server/implementation.py @@ -0,0 +1,518 @@ +"""Self-reported ``adcp.implementation.sdks[]`` for ``get_adcp_capabilities``. + +AdCP 3.3 lets an agent report which AdCP SDKs it runs and which server-side +components each SDK supplies (``adcp.implementation``). :func:`adcp.server.serve` +(and :func:`adcp.decisioning.serve`, which delegates to it) fills the block in +automatically: one entry ``{"name": "adcp", "version": , "components": [...]}``, where ``components`` lists only +what this process can prove the SDK supplies across every task the agent +advertises. The spec is normative here: a component whose coverage varies +across task paths is omitted, and an empty ``components`` list is legal (SDK +use with no agent-wide claim). + +Derivation rules (each component is claimed only when all of its applicable +responsibilities are SDK-owned): + +``transport`` + Always, under :func:`adcp.server.serve`: the SDK's MCP / A2A stack frames, + dispatches and serializes every task. +``types`` + The handler is a Decisioning :class:`~adcp.decisioning.handler.PlatformHandler`: + the framework parses every request into the SDK's generated models and + projects capabilities from them. A plain ``ADCPHandler`` may return raw + dicts, so it is not claimed there. +``schema_validation`` + Request *and* response validation are both ``"strict"`` and the server + does not serve a legacy-adapter wire version (``2.5``), whose translated + responses are not re-validated. Warn-only or request-only validation never + qualifies. +``authentication`` + ``serve(auth=BearerTokenAuth(...))`` on an HTTP transport, with no + ``allow_unauthenticated`` and no unauthenticated tools beyond the spec's + discovery set. +``idempotency`` + The served capabilities declare ``adcp.idempotency.supported: true``, + deduplication is not declared external (``_adcp_idempotency_external``), + and every advertised task whose request schema requires + ``idempotency_key`` resolves to a method wrapped by + :meth:`adcp.server.idempotency.IdempotencyStore.wrap`. +``webhooks`` + Decisioning only: task webhooks are delivered by the SDK's atomic task + outbox (RFC 9421-signed, durable retries) and no advertised task accepts a + webhook the SDK does not deliver (``reporting_webhook``, + ``artifact_webhook``, ``revocation_webhook``, ``webhook_url``, + ``sync_agent_notification_configs``). +``signing`` + Request-signature verification is wired in ``serve()``, the served + capabilities declare ``request_signing.supported: true``, and outbound + webhooks are either SDK-signed (``webhooks`` claimed) or not offered at all. +``lifecycle`` + Never derived: domain state-transition enforcement + (``assert_media_buy_transition`` and friends) is called by adopter code, so + the SDK cannot prove it agent-wide. Adopters who enforce it everywhere add + it with ``ImplementationReport(extra_components=["lifecycle"])``. + +The block is only added to responses served at AdCP 3.3 or later (the field +was introduced in 3.3; earlier releases treated ``adcp.implementation`` as an +open extension point). A capabilities response that already carries +``adcp.implementation`` -- declared on ``DecisioningCapabilities.adcp``, passed +to :func:`~adcp.server.responses.capabilities_response`, or returned by a +custom handler -- is never touched. +""" + +from __future__ import annotations + +import logging +import re +from collections.abc import Collection, Iterable, Mapping, Sequence +from dataclasses import dataclass, field +from functools import lru_cache +from typing import TYPE_CHECKING, Any, Final + +if TYPE_CHECKING: + from pydantic import BaseModel + + from adcp.server.auth import BearerTokenAuth + from adcp.server.base import ToolContext + from adcp.server.helpers import ResponseEnhancer + from adcp.server.signed_requests import RequestSignatureVerification + from adcp.types.domains.protocol.get_adcp_capabilities_response import Component + from adcp.validation.client_hooks import ValidationHookConfig + +logger = logging.getLogger(__name__) + +#: PyPI distribution name of this SDK, as reported in ``sdks[].name``. +SDK_PACKAGE_NAME: Final[str] = "adcp" + +#: First AdCP release whose ``get_adcp_capabilities`` schema defines +#: ``adcp.implementation``. +IMPLEMENTATION_MIN_ADCP_VERSION: Final[str] = "3.3-beta.0" + +_MAX_SDKS: Final[int] = 16 +_VERSION_PATTERN: Final[re.Pattern[str]] = re.compile(r"\S{1,32}") +# Spec order of ``get-adcp-capabilities-response.json`` ``Component`` (pinned by +# a test against the generated enum so the import stays lazy). +_COMPONENT_ORDER: Final[tuple[str, ...]] = ( + "types", + "schema_validation", + "transport", + "authentication", + "signing", + "lifecycle", + "idempotency", + "webhooks", +) + +# Request fields carrying a webhook the SDK does not deliver itself. +_ADOPTER_WEBHOOK_FIELDS: Final[frozenset[str]] = frozenset( + {"reporting_webhook", "artifact_webhook", "revocation_webhook", "webhook_url"} +) +# Tasks whose existence implies adopter-delivered notifications. +_ADOPTER_NOTIFICATION_TASKS: Final[frozenset[str]] = frozenset({"sync_agent_notification_configs"}) + + +def sdk_package_version() -> str | None: + """Return the installed ``adcp`` distribution version, or ``None``. + + ``None`` when the distribution metadata is unavailable or the version does + not fit the schema (no whitespace, at most 32 characters) -- the SDK then + reports nothing rather than an invalid or approximate version. + """ + from importlib.metadata import PackageNotFoundError, version + + try: + resolved = version(SDK_PACKAGE_NAME) + except PackageNotFoundError: + return None + if not _VERSION_PATTERN.fullmatch(resolved): + return None + return resolved + + +def _normalize_components(components: Iterable[Component | str]) -> list[str]: + values = {str(getattr(item, "value", item)) for item in components} + unknown = sorted(values.difference(_COMPONENT_ORDER)) + if unknown: + raise ValueError( + f"unknown adcp.implementation component(s) {unknown}; " + f"choose from {list(_COMPONENT_ORDER)}" + ) + return [value for value in _COMPONENT_ORDER if value in values] + + +def adcp_sdk_entry(components: Iterable[Component | str] = ()) -> dict[str, Any] | None: + """Build this SDK's ``sdks[]`` entry for a hand-written ``adcp.implementation``. + + Use it when you declare ``adcp.implementation`` yourself (which turns the + automatic report off for that response) but still want this SDK listed + with its exact installed version:: + + from adcp.server.implementation import adcp_sdk_entry + + implementation = {"sdks": [adcp_sdk_entry(["transport", "types"]), my_other_sdk]} + + Returns ``None`` when the installed version cannot be resolved. + + :raises ValueError: a component is not a spec ``Component`` value. + """ + resolved = sdk_package_version() + if resolved is None: + return None + return { + "name": SDK_PACKAGE_NAME, + "version": resolved, + "components": _normalize_components(components), + } + + +@dataclass(frozen=True) +class ImplementationReport: + """Tune the automatic ``adcp.implementation`` report of :func:`adcp.server.serve`. + + Pass as ``serve(report_implementation=...)``. ``True`` (the default) is + ``ImplementationReport()``; ``False`` omits the block entirely, which the + spec reads as "SDK use unknown". + + :param extra_components: Components you assert for this SDK that the + framework cannot detect -- typically ``"lifecycle"`` when your code + enforces domain state transitions on every task with the SDK's + ``assert_*_transition`` helpers. Claim a component only when it holds + across every advertised task. + :param extra_sdks: Further ``sdks[]`` entries (dicts or + :class:`~adcp.types.domains.protocol.get_adcp_capabilities_response.Sdk`) + appended after this SDK's entry, for deployments that mix SDKs. An + entry named ``"adcp"`` is refused: the SDK reports itself. + + To replace the report entirely, declare ``adcp.implementation`` in your + capabilities instead; the SDK never overwrites a declared block. + """ + + extra_components: Collection[Component | str] = () + extra_sdks: Sequence[Mapping[str, Any] | BaseModel] = field(default=()) + + def __post_init__(self) -> None: + if isinstance(self.extra_components, str): + raise TypeError("extra_components must be a collection of component names") + object.__setattr__( + self, "extra_components", tuple(_normalize_components(self.extra_components)) + ) + from adcp.types.domains.protocol.get_adcp_capabilities_response import Sdk + + entries: list[dict[str, Any]] = [] + for entry in self.extra_sdks: + raw = ( + entry.model_dump(mode="json", exclude_none=True) + if hasattr(entry, "model_dump") + else entry + ) + dumped = Sdk.model_validate(raw).model_dump(mode="json", exclude_none=True) + if dumped["name"] == SDK_PACKAGE_NAME: + raise ValueError( + f"extra_sdks may not list {SDK_PACKAGE_NAME!r}; the SDK reports its own " + "entry. Use extra_components to add components to it." + ) + entries.append(dumped) + if len(entries) + 1 > _MAX_SDKS: + raise ValueError(f"adcp.implementation.sdks allows at most {_MAX_SDKS} entries") + object.__setattr__(self, "extra_sdks", tuple(entries)) + + +def resolve_implementation_report( + value: bool | ImplementationReport | None, +) -> ImplementationReport | None: + """Normalize ``serve(report_implementation=...)``; ``None`` means off.""" + if value is None or value is False: + return None + if value is True: + return ImplementationReport() + if isinstance(value, ImplementationReport): + return value + raise TypeError( + "report_implementation must be True, False or an ImplementationReport, " + f"got {type(value).__name__}" + ) + + +def _serves_implementation(version: str | None) -> bool: + if version is None: + return False + from adcp._version import ( + is_adcp_version_at_least, + normalize_to_release_precision, + resolve_adcp_version_alias, + ) + + try: + release = resolve_adcp_version_alias(normalize_to_release_precision(version)) + return is_adcp_version_at_least(release, IMPLEMENTATION_MIN_ADCP_VERSION) + except (TypeError, ValueError): + return False + + +@lru_cache(maxsize=512) +def _schema_shape(tool_name: str, version: str | None) -> tuple[frozenset[str], frozenset[str]]: + """Return (required, properties) of a task's request schema, arms merged.""" + from adcp.validation.schema_loader import get_schema + + try: + schema = get_schema(tool_name, "request", version=version) + except Exception: # noqa: BLE001 - a missing bundle only weakens the claim + schema = None + required: set[str] = set() + properties: set[str] = set() + + def walk(node: Any) -> None: + if not isinstance(node, dict): + return + required.update(node.get("required") or ()) + properties.update((node.get("properties") or {}).keys()) + for key in ("allOf", "oneOf", "anyOf"): + for arm in node.get(key) or (): + walk(arm) + + walk(schema) + return frozenset(required), frozenset(properties) + + +def _platform_handler_type() -> type | None: + try: + from adcp.decisioning.handler import PlatformHandler + except ImportError: # pragma: no cover - decisioning ships with the SDK + return None + return PlatformHandler + + +@dataclass(frozen=True) +class _ToolFacts: + mutating_covered: bool + has_adopter_webhooks: bool + has_any_webhooks: bool + + +class ImplementationReporter: + """Response enhancer that stamps ``adcp.implementation`` on capabilities. + + Built by :func:`adcp.server.serve`; runs before the adopter's own + ``response_enhancer`` (passed as ``then``) so that enhancer can still edit + or remove the block. Only touches successful ``get_adcp_capabilities`` + responses served at AdCP 3.3 or later that do not already declare + ``adcp.implementation``. + """ + + def __init__( + self, + handler: Any, + *, + report: ImplementationReport, + transport: str, + validation: ValidationHookConfig | None, + supported_versions: Sequence[str] | None, + auth: BearerTokenAuth | None, + request_signature_verification: RequestSignatureVerification | None, + advertise_all: bool, + then: ResponseEnhancer | None = None, + ) -> None: + self._handler = handler + self._report = report + self._then = then + self._advertise_all = advertise_all + platform_handler = _platform_handler_type() + self._is_platform = platform_handler is not None and isinstance(handler, platform_handler) + self._static = self._static_components( + transport=transport, + validation=validation, + supported_versions=supported_versions, + auth=auth, + ) + # ``serve`` drops the verifier on transports it cannot run on (stdio, + # legacy SSE) before building the reporter. + self._verifies_signatures = request_signature_verification is not None + self._tool_facts: dict[str | None, _ToolFacts] = {} + + # ----- static (boot-time) facts ----- + + def _static_components( + self, + *, + transport: str, + validation: ValidationHookConfig | None, + supported_versions: Sequence[str] | None, + auth: BearerTokenAuth | None, + ) -> set[str]: + components = {"transport"} + if self._is_platform: + components.add("types") + if ( + validation is not None + and validation.requests == "strict" + and (validation.responses == "strict") + ): + from adcp.compat.legacy import LEGACY_ADAPTER_VERSIONS + from adcp.validation.envelope import SUPPORTED_WIRE_VERSIONS + + served = SUPPORTED_WIRE_VERSIONS if supported_versions is None else supported_versions + if not any(version in LEGACY_ADAPTER_VERSIONS for version in served): + components.add("schema_validation") + if auth is not None and transport != "stdio" and _auth_covers_every_task(auth): + components.add("authentication") + return components + + # ----- tool-surface facts (computed once per application version) ----- + + def _facts(self, version: str | None) -> _ToolFacts: + cached = self._tool_facts.get(version) + if cached is not None: + return cached + from adcp.server.mcp_tools import get_tools_for_handler + + advertised = frozenset( + tool["name"] + for tool in get_tools_for_handler( + self._handler, advertise_all=self._advertise_all, _include_schemas=False + ) + ) + target = getattr(self._handler, "_platform", None) if self._is_platform else self._handler + mutating_covered = target is not None and not getattr( + target, "_adcp_idempotency_external", False + ) + has_mutating = False + has_adopter_webhooks = bool(advertised & _ADOPTER_NOTIFICATION_TASKS) + has_any_webhooks = has_adopter_webhooks + for name in advertised: + required, properties = _schema_shape(name, version) + if "idempotency_key" in required: + has_mutating = True + if mutating_covered and not _is_idempotency_wrapped(getattr(target, name, None)): + mutating_covered = False + if properties & _ADOPTER_WEBHOOK_FIELDS: + has_adopter_webhooks = True + has_any_webhooks = True + elif "push_notification_config" in properties: + has_any_webhooks = True + facts = _ToolFacts( + mutating_covered=mutating_covered and has_mutating, + has_adopter_webhooks=has_adopter_webhooks, + has_any_webhooks=has_any_webhooks, + ) + self._tool_facts[version] = facts + return facts + + def _sdk_owns_task_webhooks(self) -> bool: + if not self._is_platform: + return False + from adcp.decisioning.webhook_emit import ( + external_task_webhook_owner_ready, + task_webhook_owner_ready, + ) + + handler = self._handler + capabilities = handler._platform.capabilities + kwargs: dict[str, Any] = { + "capabilities": capabilities, + "sender": handler._webhook_sender, + "supervisor": handler._webhook_supervisor, + "auto_emit_task_webhooks": handler._auto_emit_task_webhooks, + } + return task_webhook_owner_ready( + **kwargs, registry=handler._registry + ) and not external_task_webhook_owner_ready(**kwargs) + + # ----- per-response derivation ----- + + def components(self, response: Mapping[str, Any], version: str | None) -> list[str]: + """Return the components this SDK supplies for ``response``'s agent.""" + components = set(self._static) + facts = self._facts(version) + adcp = response.get("adcp") + idempotency = adcp.get("idempotency") if isinstance(adcp, Mapping) else None + if ( + isinstance(idempotency, Mapping) + and idempotency.get("supported") is True + and facts.mutating_covered + ): + components.add("idempotency") + webhook_signing = response.get("webhook_signing") + webhooks = ( + not facts.has_adopter_webhooks + and isinstance(webhook_signing, Mapping) + and webhook_signing.get("supported") is True + and self._sdk_owns_task_webhooks() + ) + if webhooks: + components.add("webhooks") + request_signing = response.get("request_signing") + if ( + self._verifies_signatures + and isinstance(request_signing, Mapping) + and request_signing.get("supported") is True + and (webhooks or not facts.has_any_webhooks) + ): + components.add("signing") + components.update(self._report.extra_components) + return _normalize_components(components) + + def __call__( + self, method_name: str, result: dict[str, Any], context: ToolContext | None + ) -> None: + if method_name == "get_adcp_capabilities": + try: + self._stamp(result, context) + except Exception: # noqa: BLE001 - discovery must never fail on the report + logger.warning( + "adcp.implementation self-report failed; serving capabilities without it", + exc_info=True, + ) + if self._then is not None: + from adcp.server.helpers import _apply_response_enhancer + + _apply_response_enhancer(self._then, method_name, result, context) + + def _stamp(self, result: dict[str, Any], context: ToolContext | None) -> None: + if "adcp_error" in result or context is None: + return + adcp = result.get("adcp") + if not isinstance(adcp, dict) or "implementation" in adcp: + return + application_version = context.application_adcp_version + wire_version = context.resolved_adcp_version + if not _serves_implementation(application_version): + return + if wire_version is not None and not _serves_implementation(wire_version): + return + entry = adcp_sdk_entry(self.components(result, application_version)) + if entry is None: + return + adcp["implementation"] = {"sdks": [entry, *self._report.extra_sdks]} + + +def _auth_covers_every_task(auth: BearerTokenAuth) -> bool: + from adcp.server.mcp_tools import DISCOVERY_TOOLS + + if getattr(auth, "allow_unauthenticated", False): + return False + for widened in ( + getattr(auth, "mcp_discovery_tools", None), + getattr(auth, "a2a_discovery_skills", None), + ): + if widened is not None and not set(widened) <= DISCOVERY_TOOLS: + return False + return True + + +def _is_idempotency_wrapped(method: Any) -> bool: + if method is None or not callable(method): + return False + from adcp.server.idempotency import is_wrapped + + return is_wrapped(method) + + +__all__ = [ + "IMPLEMENTATION_MIN_ADCP_VERSION", + "SDK_PACKAGE_NAME", + "ImplementationReport", + "ImplementationReporter", + "adcp_sdk_entry", + "resolve_implementation_report", + "sdk_package_version", +] diff --git a/src/adcp/server/responses.py b/src/adcp/server/responses.py index 5c689bd81..84f53e7ad 100644 --- a/src/adcp/server/responses.py +++ b/src/adcp/server/responses.py @@ -287,6 +287,7 @@ def capabilities_response( features: dict[str, Any] | None = None, idempotency: dict[str, Any] | None = None, compliance_testing: dict[str, Any] | None = None, + implementation: Any = None, ) -> dict[str, Any]: """Build a get_adcp_capabilities response. @@ -322,6 +323,13 @@ def capabilities_response( compliance_testing: Optional top-level ``compliance_testing`` block to advertise compliance-testing capabilities. When provided, emitted as a sibling of ``adcp`` in the response. + implementation: Optional AdCP 3.3 ``adcp.implementation`` block + (a dict or the generated ``Implementation`` model), emitted + verbatim. Leave it unset to let :func:`adcp.server.serve` + self-report this SDK (see :mod:`adcp.server.implementation`); a + declared block replaces that report. + :func:`adcp.server.implementation.adcp_sdk_entry` builds this + SDK's entry for a hand-written block. Example:: @@ -354,6 +362,12 @@ def capabilities_response( adcp_info["build_version"] = build_version if idempotency: adcp_info["idempotency"] = idempotency + if implementation is not None: + adcp_info["implementation"] = ( + implementation.model_dump(mode="json", exclude_none=True) + if hasattr(implementation, "model_dump") + else _strip_none_values(dict(implementation)) + ) resp: dict[str, Any] = { "status": "completed", "adcp": adcp_info, diff --git a/src/adcp/server/serve.py b/src/adcp/server/serve.py index 3e5086c83..8cb5a7f83 100644 --- a/src/adcp/server/serve.py +++ b/src/adcp/server/serve.py @@ -41,6 +41,11 @@ async def get_adcp_capabilities(self, params, context=None): from adcp.server.base import ADCPHandler, ToolContext from adcp.server.helpers import ResponseEnhancer from adcp.server.http_policy import HostOriginMiddleware, transport_security_settings +from adcp.server.implementation import ( + ImplementationReport, + ImplementationReporter, + resolve_implementation_report, +) from adcp.server.mcp_sessions import ADCPStreamableHTTPSessionManager from adcp.server.mcp_tools import ( _HANDLER_TOOLS, @@ -346,6 +351,9 @@ class ServeConfig: on_startup: Sequence[LifespanHook] | None = None on_shutdown: Sequence[LifespanHook] | None = None + # --- AdCP 3.3 ``adcp.implementation`` self-report --- + report_implementation: bool | ImplementationReport = True + # --- Debug endpoints --- enable_debug_endpoints: bool = False debug_traffic_source: Callable[[], dict[str, int]] | None = None @@ -1041,6 +1049,7 @@ def serve( public_url: str | PublicUrlResolver | None = None, on_startup: Sequence[LifespanHook] | None = None, on_shutdown: Sequence[LifespanHook] | None = None, + report_implementation: bool | ImplementationReport = True, ) -> None: """Start an MCP or A2A server from an ADCP handler or server builder. @@ -1348,6 +1357,16 @@ def resolver(request): propagates. SDK cleanup diagnostics omit exception text and callable representations. Same ``transport="both"`` restriction as ``on_startup``. + report_implementation: AdCP 3.3 ``adcp.implementation`` self-report + on ``get_adcp_capabilities`` responses served at 3.3 or later. + ``True`` (default) adds ``{"name": "adcp", "version": , + "components": [...]}`` listing only the components this server + provably wires agent-wide (see :mod:`adcp.server.implementation`). + ``False`` omits the block. Pass an + :class:`~adcp.server.implementation.ImplementationReport` to add + components the SDK cannot detect or further ``sdks[]`` entries. + A capabilities response that already declares + ``adcp.implementation`` is never changed. Example (MCP): from adcp.server import ADCPHandler, serve @@ -1416,6 +1435,7 @@ async def force_account_status(self, account_id, status): public_url = config.public_url on_startup = config.on_startup on_shutdown = config.on_shutdown + report_implementation = config.report_implementation if unauthenticated_routes and transport == "stdio": raise ValueError("unauthenticated_routes requires an HTTP transport") @@ -1452,6 +1472,23 @@ async def force_account_status(self, account_id, status): adcp_version=_resolve_handler_adcp_version(handler, None), ) + # AdCP 3.3 ``adcp.implementation``: report this SDK and the components + # this server provably wires. Runs ahead of the adopter's enhancer so + # that enhancer can still amend or drop the block. + implementation_report = resolve_implementation_report(report_implementation) + if implementation_report is not None: + response_enhancer = ImplementationReporter( + handler, + report=implementation_report, + transport=transport, + validation=validation, + supported_versions=supported_versions, + auth=auth, + request_signature_verification=request_signature_verification, + advertise_all=advertise_all, + then=response_enhancer, + ) + # Compose debug endpoints as the outermost ASGI middleware on HTTP # transports. stdio has no HTTP layer, so debug endpoints are ignored # there instead of forcing HTTP-only validation knobs onto shared configs. diff --git a/src/adcp/types/capabilities.py b/src/adcp/types/capabilities.py index b02170213..9e97401ef 100644 --- a/src/adcp/types/capabilities.py +++ b/src/adcp/types/capabilities.py @@ -65,6 +65,7 @@ Governance, Idempotency, Identity, + Implementation, KeyOrigins, KeywordTargets, LaterProposals, @@ -77,6 +78,7 @@ OpenOpportunities, Portfolio, RequestSigning, + Sdk, Signals, Specialism, SponsoredIntelligence, @@ -115,6 +117,12 @@ Capabilities as SiCapabilities, ) +# AdCP 3.3 ``adcp.implementation.sdks[].components`` values. The generated +# name ``Component`` is too generic to export bare. +from adcp.types.domains.bundled.protocol.get_adcp_capabilities_response import ( + Component as ImplementationComponent, +) + # ``ContentStandards`` collides with ``adcp.types.ContentStandards`` (the # unrelated wire model on the content-standards protocol). Same # disambiguation pattern as ``Account`` / ``MediaBuy`` / ``Creative`` — @@ -268,6 +276,8 @@ class MediaBuy(_MediaBuy): "IdempotencySupported", "IdempotencyUnsupported", "Identity", + "Implementation", + "ImplementationComponent", "KeyOrigins", "KeywordTargets", "LaterProposals", @@ -281,6 +291,7 @@ class MediaBuy(_MediaBuy): "OpenOpportunities", "Portfolio", "RequestSigning", + "Sdk", "SiCapabilities", "Signals", "SignalsFeatures", diff --git a/tests/test_implementation_report.py b/tests/test_implementation_report.py new file mode 100644 index 000000000..5e3346872 --- /dev/null +++ b/tests/test_implementation_report.py @@ -0,0 +1,591 @@ +"""AdCP 3.3 ``adcp.implementation.sdks[]`` self-report (#1646). + +``serve()`` stamps ``{"name": "adcp", "version": , "components": +[...]}`` on ``get_adcp_capabilities`` responses served at 3.3 or later, listing +only components the server provably wires agent-wide. These tests drive the +reporter through ``create_tool_caller`` -- the dispatch path ``serve()`` uses on +both MCP and A2A -- so version negotiation, 3.3 -> 3.2 conversion and strict +response validation all run on the stamped response. +""" + +from __future__ import annotations + +import asyncio +import importlib +import importlib.metadata +from collections.abc import Iterator, Sequence +from concurrent.futures import ThreadPoolExecutor +from typing import Any +from unittest.mock import patch + +import pytest + +from adcp.decisioning import ( + DecisioningCapabilities, + DecisioningPlatform, + InMemoryTaskRegistry, + SingletonAccounts, +) +from adcp.decisioning.capabilities import ( + Account, + Adcp, + IdempotencySupported, + Implementation, + ImplementationComponent, + RequestSigning, + Sdk, + SupportedProtocol, +) +from adcp.decisioning.handler import PlatformHandler +from adcp.server import ADCPHandler, ServeConfig, ToolContext +from adcp.server.auth import BearerTokenAuth +from adcp.server.idempotency import IdempotencyStore, MemoryBackend +from adcp.server.implementation import ( + SDK_PACKAGE_NAME, + ImplementationReport, + ImplementationReporter, + adcp_sdk_entry, + resolve_implementation_report, + sdk_package_version, +) +from adcp.server.mcp_tools import create_tool_caller +from adcp.server.responses import capabilities_response +from adcp.types.domains.protocol.get_adcp_capabilities_response import ( + Component, + GetAdcpCapabilitiesResponse, +) +from adcp.validation.client_hooks import SERVER_DEFAULT_VALIDATION, ValidationHookConfig + +# A served set without the 2.5 legacy adapter, whose translated responses are +# not re-validated (so the default set never earns ``schema_validation``). +MODERN = ("3.2", "3.3-beta.3") +serve_module = importlib.import_module("adcp.server.serve") +INSTALLED = importlib.metadata.version("adcp") +_NO_IDEMPOTENCY = {"supported": False} + + +@pytest.fixture +def executor() -> Iterator[ThreadPoolExecutor]: + pool = ThreadPoolExecutor(max_workers=1, thread_name_prefix="test-implementation-") + yield pool + pool.shutdown(wait=True) + + +class _PlainAgent(ADCPHandler): + async def get_adcp_capabilities(self, params: Any, context: Any = None) -> dict[str, Any]: + return capabilities_response(["media_buy"], idempotency=_NO_IDEMPOTENCY) + + async def get_products(self, params: Any, context: Any = None) -> dict[str, Any]: + return {"products": []} + + +class _DeclaringAgent(_PlainAgent): + async def get_adcp_capabilities(self, params: Any, context: Any = None) -> dict[str, Any]: + return capabilities_response( + ["media_buy"], + idempotency=_NO_IDEMPOTENCY, + implementation={ + "sdks": [{"name": "my-fork", "version": "1.2.3", "components": ["types"]}] + }, + ) + + +class _ListsOnlyAgent(ADCPHandler): + """No task accepts a webhook, so webhook signing is inapplicable.""" + + async def get_adcp_capabilities(self, params: Any, context: Any = None) -> dict[str, Any]: + response = capabilities_response(["governance"], idempotency=_NO_IDEMPOTENCY) + response["request_signing"] = { + "supported": True, + "required_for": [], + "covers_content_digest": "required", + } + return response + + async def get_property_list(self, params: Any, context: Any = None) -> dict[str, Any]: + return {} + + +_store = IdempotencyStore(backend=MemoryBackend(), ttl_seconds=86400) + + +def _signals_capabilities(**overrides: Any) -> DecisioningCapabilities: + fields: dict[str, Any] = { + "specialisms": ["signal-marketplace"], + "supported_protocols": [SupportedProtocol.signals], + "account": Account(supported_billing=["agent"]), + "adcp": Adcp( + major_versions=[3], + idempotency=IdempotencySupported(supported=True, replay_ttl_seconds=86400), + ), + } + fields.update(overrides) + return DecisioningCapabilities(**fields) + + +class _WrappedSignals(DecisioningPlatform): + capabilities = _signals_capabilities() + accounts = SingletonAccounts(account_id="test") + + async def get_signals(self, req: Any, ctx: Any) -> dict[str, Any]: + return {"signals": []} + + @_store.wrap + async def activate_signal(self, req: Any, ctx: Any) -> dict[str, Any]: + return {} + + +class _UnwrappedSignals(_WrappedSignals): + async def activate_signal(self, req: Any, ctx: Any) -> dict[str, Any]: + return {} + + +class _ExternalIdempotencySignals(_WrappedSignals): + _adcp_idempotency_external = True + + +class _DeclaredSignals(_WrappedSignals): + capabilities = _signals_capabilities( + adcp=Adcp( + major_versions=[3], + idempotency=IdempotencySupported(supported=True, replay_ttl_seconds=86400), + implementation=Implementation( + sdks=[ + Sdk( + name="adcp", + version="9.9.9", + components=[ImplementationComponent.transport], + ) + ] + ), + ) + ) + + +def _platform_handler(platform: DecisioningPlatform, executor: ThreadPoolExecutor) -> Any: + return PlatformHandler(platform, executor=executor, registry=InMemoryTaskRegistry()) + + +def _reporter( + handler: Any, + *, + report: ImplementationReport | None = None, + transport: str = "streamable-http", + validation: ValidationHookConfig | None = SERVER_DEFAULT_VALIDATION, + supported_versions: Sequence[str] | None = MODERN, + auth: BearerTokenAuth | None = None, + request_signature_verification: Any = None, + then: Any = None, +) -> ImplementationReporter: + return ImplementationReporter( + handler, + report=report or ImplementationReport(), + transport=transport, + validation=validation, + supported_versions=supported_versions, + auth=auth, + request_signature_verification=request_signature_verification, + advertise_all=False, + then=then, + ) + + +def _capabilities( + handler: Any, + reporter: ImplementationReporter | None, + params: dict[str, Any] | None = None, + *, + supported_versions: Sequence[str] | None = MODERN, + validation: ValidationHookConfig | None = SERVER_DEFAULT_VALIDATION, +) -> dict[str, Any]: + call = create_tool_caller( + handler, + "get_adcp_capabilities", + validation=validation, + response_enhancer=reporter, + supported_versions=supported_versions, + ) + result: dict[str, Any] = asyncio.run(call(dict(params or {}))) + return result + + +def _sdks(response: dict[str, Any]) -> list[dict[str, Any]]: + sdks: list[dict[str, Any]] = response["adcp"]["implementation"]["sdks"] + return sdks + + +# --------------------------------------------------------------------------- +# Entry construction +# --------------------------------------------------------------------------- + + +def test_component_order_matches_generated_enum() -> None: + from adcp.server import implementation + + assert implementation._COMPONENT_ORDER == tuple(member.value for member in Component) + + +def test_entry_names_the_distribution_and_its_exact_version() -> None: + entry = adcp_sdk_entry(["transport", Component.types]) + + assert entry == { + "name": SDK_PACKAGE_NAME, + "version": INSTALLED, + "components": ["types", "transport"], + } + # Same name ``pyproject.toml`` publishes and ``importlib.metadata`` resolves. + assert importlib.metadata.metadata(SDK_PACKAGE_NAME)["Name"] == "adcp" + + +def test_entry_rejects_unknown_component() -> None: + with pytest.raises(ValueError, match="unknown adcp.implementation component"): + adcp_sdk_entry(["telemetry"]) + + +@pytest.mark.parametrize( + "resolved", + ["10.0.0 b5", "10.0.0b5.dev123+g0123456789abcdef.d20261101"], +) +def test_version_outside_schema_pattern_reports_nothing(resolved: str) -> None: + with patch("importlib.metadata.version", return_value=resolved): + assert sdk_package_version() is None + assert adcp_sdk_entry() is None + + +def test_missing_distribution_reports_nothing() -> None: + with patch( + "importlib.metadata.version", + side_effect=importlib.metadata.PackageNotFoundError("adcp"), + ): + assert sdk_package_version() is None + + +def test_report_option_normalization() -> None: + assert resolve_implementation_report(False) is None + assert resolve_implementation_report(None) is None + assert resolve_implementation_report(True) == ImplementationReport() + custom = ImplementationReport(extra_components=["lifecycle"]) + assert resolve_implementation_report(custom) is custom + with pytest.raises(TypeError): + resolve_implementation_report("yes") # type: ignore[arg-type] + + +def test_report_refuses_own_name_and_unknown_components() -> None: + with pytest.raises(ValueError, match="may not list 'adcp'"): + ImplementationReport(extra_sdks=[{"name": "adcp", "version": "1", "components": []}]) + with pytest.raises(ValueError, match="unknown adcp.implementation component"): + ImplementationReport(extra_components=["everything"]) + with pytest.raises(ValueError): + ImplementationReport(extra_sdks=[{"name": "has space", "version": "1", "components": []}]) + + +# --------------------------------------------------------------------------- +# Emission on the plain ADCPHandler path +# --------------------------------------------------------------------------- + + +def test_plain_handler_reports_transport_and_strict_validation() -> None: + handler = _PlainAgent() + response = _capabilities(handler, _reporter(handler)) + + assert _sdks(response) == [ + {"name": "adcp", "version": INSTALLED, "components": ["schema_validation", "transport"]} + ] + # Valid against the generated 3.3 model (strict validation also passed). + parsed = GetAdcpCapabilitiesResponse.model_validate(response) + assert parsed.adcp.implementation is not None + assert parsed.adcp.implementation.sdks[0].components == [ + Component.schema_validation, + Component.transport, + ] + + +@pytest.mark.parametrize( + "validation", + [ + None, + ValidationHookConfig(requests="strict", responses="warn"), + ValidationHookConfig(requests="warn", responses="strict"), + ValidationHookConfig(requests="strict", responses="off"), + ], +) +def test_partial_or_warn_only_validation_is_not_claimed( + validation: ValidationHookConfig | None, +) -> None: + handler = _PlainAgent() + response = _capabilities(handler, _reporter(handler, validation=validation)) + + assert _sdks(response)[0]["components"] == ["transport"] + + +def test_serving_the_legacy_adapter_forfeits_schema_validation() -> None: + handler = _PlainAgent() + response = _capabilities( + handler, _reporter(handler, supported_versions=None), supported_versions=None + ) + + assert _sdks(response)[0]["components"] == ["transport"] + + +def test_bearer_auth_covering_every_task_is_claimed() -> None: + handler = _PlainAgent() + auth = BearerTokenAuth(validate_token=lambda token: None) + response = _capabilities(handler, _reporter(handler, auth=auth)) + + assert "authentication" in _sdks(response)[0]["components"] + + +@pytest.mark.parametrize( + ("auth", "transport"), + [ + (BearerTokenAuth(validate_token=lambda token: None, allow_unauthenticated=True), "a2a"), + ( + BearerTokenAuth( + validate_token=lambda token: None, + mcp_discovery_tools={"get_adcp_capabilities", "get_products"}, + ), + "streamable-http", + ), + (BearerTokenAuth(validate_token=lambda token: None), "stdio"), + ], +) +def test_auth_with_gaps_is_not_claimed(auth: BearerTokenAuth, transport: str) -> None: + handler = _PlainAgent() + response = _capabilities(handler, _reporter(handler, auth=auth, transport=transport)) + + assert "authentication" not in _sdks(response)[0]["components"] + + +def test_signing_needs_verification_declaration_and_no_unsigned_webhooks() -> None: + lists_only = _ListsOnlyAgent() + verifier = object() + response = _capabilities( + lists_only, _reporter(lists_only, request_signature_verification=verifier) + ) + assert "signing" in _sdks(response)[0]["components"] + + # Without the verifier, the declaration alone does not earn it. + response = _capabilities(lists_only, _reporter(lists_only)) + assert "signing" not in _sdks(response)[0]["components"] + + # get_products accepts push_notification_config: a plain handler's + # webhooks are not SDK-delivered, so signing coverage is unproven. + plain = _PlainAgent() + response = _capabilities(plain, _reporter(plain, request_signature_verification=verifier)) + assert "signing" not in _sdks(response)[0]["components"] + + +def test_extra_components_and_sdks_are_appended() -> None: + handler = _PlainAgent() + other = {"name": "@acme/adcp-tools", "version": "2.0.0", "components": []} + report = ImplementationReport(extra_components=["lifecycle"], extra_sdks=[other]) + response = _capabilities(handler, _reporter(handler, report=report)) + + assert _sdks(response) == [ + { + "name": "adcp", + "version": INSTALLED, + "components": ["schema_validation", "transport", "lifecycle"], + }, + other, + ] + GetAdcpCapabilitiesResponse.model_validate(response) + + +# --------------------------------------------------------------------------- +# Override, opt-out and version projection +# --------------------------------------------------------------------------- + + +def test_declared_implementation_is_never_overwritten() -> None: + handler = _DeclaringAgent() + response = _capabilities(handler, _reporter(handler)) + + assert _sdks(response) == [{"name": "my-fork", "version": "1.2.3", "components": ["types"]}] + + +def test_no_reporter_means_no_block() -> None: + handler = _PlainAgent() + response = _capabilities(handler, None) + + assert "implementation" not in response["adcp"] + + +@pytest.mark.parametrize("claimed", ["3.2", "3.1"]) +def test_pre_3_3_callers_never_see_the_block(claimed: str) -> None: + handler = _PlainAgent() + versions = ("3.0", "3.1", "3.2", "3.3-beta.3") + response = _capabilities( + handler, + _reporter(handler, supported_versions=versions), + {"adcp_version": claimed}, + supported_versions=versions, + ) + + assert "implementation" not in response["adcp"] + + +def test_3_3_caller_sees_the_block() -> None: + handler = _PlainAgent() + response = _capabilities(handler, _reporter(handler), {"adcp_version": "3.3-beta.3"}) + + assert _sdks(response)[0]["name"] == "adcp" + + +def test_server_pinned_to_3_2_never_emits() -> None: + class _Pinned(_PlainAgent): + _adcp_version = "3.2" + + def get_adcp_version(self) -> str: + return "3.2" + + handler = _Pinned() + response = _capabilities(handler, _reporter(handler), supported_versions=("3.2",)) + + assert "implementation" not in response["adcp"] + + +def test_adopter_enhancer_runs_after_and_may_amend() -> None: + seen: list[Any] = [] + + def enhancer(method: str, result: dict[str, Any], context: ToolContext | None) -> None: + if method == "get_adcp_capabilities": + seen.append(result["adcp"].get("implementation")) + result["adcp"]["implementation"]["sdks"][0]["components"].append("lifecycle") + + handler = _PlainAgent() + response = _capabilities(handler, _reporter(handler, then=enhancer)) + + assert seen and seen[0]["sdks"][0]["name"] == "adcp" + assert _sdks(response)[0]["components"][-1] == "lifecycle" + + +def test_reporter_failure_still_runs_adopter_enhancer() -> None: + def enhancer(result: dict[str, Any]) -> None: + result["x_stamp"] = True + + handler = _PlainAgent() + reporter = _reporter(handler, then=enhancer) + with patch.object(ImplementationReporter, "components", side_effect=RuntimeError("boom")): + response = _capabilities(handler, reporter) + + assert "implementation" not in response["adcp"] + assert response["x_stamp"] is True + + +def test_reporter_ignores_other_tools_and_error_envelopes() -> None: + handler = _PlainAgent() + reporter = _reporter(handler) + context = ToolContext(application_adcp_version="3.3-beta.3") + other = {"adcp": {"major_versions": [3]}} + reporter("get_products", other, context) + error = {"adcp_error": {"code": "X"}, "adcp": {"major_versions": [3]}} + reporter("get_adcp_capabilities", error, context) + + assert "implementation" not in other["adcp"] + assert "implementation" not in error["adcp"] + + +# --------------------------------------------------------------------------- +# Decisioning platforms +# --------------------------------------------------------------------------- + + +def test_platform_with_every_mutating_task_wrapped_claims_idempotency( + executor: ThreadPoolExecutor, +) -> None: + handler = _platform_handler(_WrappedSignals(), executor) + response = _capabilities(handler, _reporter(handler)) + + assert _sdks(response)[0]["components"] == [ + "types", + "schema_validation", + "transport", + "idempotency", + ] + GetAdcpCapabilitiesResponse.model_validate(response) + + +@pytest.mark.parametrize("platform_type", [_UnwrappedSignals, _ExternalIdempotencySignals]) +def test_unproven_idempotency_is_not_claimed( + platform_type: type[DecisioningPlatform], executor: ThreadPoolExecutor +) -> None: + handler = _platform_handler(platform_type(), executor) + response = _capabilities(handler, _reporter(handler)) + + assert _sdks(response)[0]["components"] == ["types", "schema_validation", "transport"] + + +def test_platform_declared_implementation_wins(executor: ThreadPoolExecutor) -> None: + handler = _platform_handler(_DeclaredSignals(), executor) + response = _capabilities(handler, _reporter(handler)) + + assert _sdks(response) == [{"name": "adcp", "version": "9.9.9", "components": ["transport"]}] + + +def test_platform_without_sdk_task_outbox_does_not_claim_webhooks_or_signing( + executor: ThreadPoolExecutor, +) -> None: + class _SigningSignals(_WrappedSignals): + capabilities = _signals_capabilities( + request_signing=RequestSigning(supported=True, required_for=[]) + ) + + handler = _platform_handler(_SigningSignals(), executor) + response = _capabilities(handler, _reporter(handler, request_signature_verification=object())) + components = _sdks(response)[0]["components"] + + # activate_signal / get_signals accept push_notification_config, and no + # SDK outbox owns their delivery. + assert "webhooks" not in components + assert "signing" not in components + + +def test_direct_handler_call_is_unknown(executor: ThreadPoolExecutor) -> None: + """Outside ``serve()`` nothing proves transport ownership: no block.""" + handler = _platform_handler(_WrappedSignals(), executor) + response = asyncio.run(handler.get_adcp_capabilities()) + + assert "implementation" not in response["adcp"] + + +# --------------------------------------------------------------------------- +# serve() wiring +# --------------------------------------------------------------------------- + + +def _served_enhancer(**serve_kwargs: Any) -> Any: + with patch.object(serve_module, "_serve_mcp") as mock_mcp: + serve_module.serve(_PlainAgent(), **serve_kwargs) + mock_mcp.assert_called_once() + return mock_mcp.call_args.kwargs["response_enhancer"] + + +def test_serve_installs_the_reporter_by_default() -> None: + def adopter(result: dict[str, Any]) -> None: + return None + + enhancer = _served_enhancer(response_enhancer=adopter) + + assert isinstance(enhancer, ImplementationReporter) + assert enhancer._then is adopter + + +def test_serve_opt_out_leaves_the_adopter_enhancer_alone() -> None: + def adopter(result: dict[str, Any]) -> None: + return None + + assert _served_enhancer(report_implementation=False, response_enhancer=adopter) is adopter + assert _served_enhancer(report_implementation=False) is None + assert ( + _served_enhancer(config=ServeConfig(report_implementation=False, response_enhancer=adopter)) + is adopter + ) + + +def test_serve_config_carries_a_custom_report() -> None: + report = ImplementationReport(extra_components=["lifecycle"]) + enhancer = _served_enhancer(config=ServeConfig(report_implementation=report)) + + assert isinstance(enhancer, ImplementationReporter) + assert enhancer._report is report