Module adcp.server

ADCP Server Framework.

Build an AdCP agent in minutes. The framework handles the protocol plumbing so you focus on business logic.

Quickstart (class-based)::

from adcp.server import ADCPHandler, serve
from adcp.server.responses import capabilities_response, products_response

class MySeller(ADCPHandler):
    async def get_adcp_capabilities(self, params, context=None):
        return capabilities_response(["media_buy"])

    async def get_products(self, params, context=None):
        return products_response(MY_PRODUCTS)

serve(MySeller(), name="my-seller")

Quickstart (decorator-based)::

from adcp.server import adcp_server, serve
from adcp.server.responses import products_response

server = adcp_server("my-seller")

@server.get_products
async def get_products(params, context=None):
    return products_response(MY_PRODUCTS)

serve(server, name="my-seller")  # capabilities auto-generated

What the framework does automatically:

  • Error responses: adcp_error("BUDGET_TOO_LOW") auto-populates recovery classification (transient/correctable/terminal) from 20+ standard codes.
  • State transitions: media_buy_response(..., status="active") auto-populates valid_actions from the status. No manual mapping.
  • Account resolution: resolve_account()(params, my_resolver) auto-resolves AccountReference and returns ACCOUNT_NOT_FOUND errors.
  • Context passthrough: inject_context()(params, response) echoes the request context field back in the response (ADCP requirement).
  • Cancellation: cancel_media_buy_response(id, "buyer") auto-sets canceled_at, status, and valid_actions=[].
  • Capabilities: The decorator builder auto-generates get_adcp_capabilities from which handlers you register.
  • Validation: GovernanceHandler and ContentStandardsHandler auto-validate request dicts into Pydantic models before your handler code runs.

Sub-modules

adcp.server.a2a_server

A2A server support for ADCP handlers …

adcp.server.auth

Bearer-token HTTP authentication middleware for ADCP MCP servers …

adcp.server.base

Base classes for ADCP server implementations …

adcp.server.brand

Brand rights handler for ADCP server implementations.

adcp.server.builder

Decorator-based server builder for ADCP …

adcp.server.compliance

Compliance test controller handler for ADCP server implementations.

adcp.server.content_standards

Content Standards protocol handler …

adcp.server.debug_endpoints

ASGI middleware exposing opt-in debug endpoints …

adcp.server.discovery

Multi-agent topology manifest served at /.well-known/adcp-agents.json

adcp.server.governance

Governance protocol handler …

adcp.server.helpers

DX helpers for ADCP server builders …

adcp.server.idempotency

Server-side idempotency middleware for AdCP mutating tool handlers …

adcp.server.mcp_sessions

ADCP-managed Streamable HTTP session controls …

adcp.server.mcp_tools

MCP server integration helpers …

adcp.server.proposal

Proposal generation helpers …

adcp.server.responses

Response builder helpers for ADCP servers …

adcp.server.spec_compat

Built-in spec-compatibility hooks for pre-v3 / pre-4.4 buyers …

adcp.server.sponsored_intelligence

Sponsored Intelligence protocol handler …

adcp.server.tenant_registry

TenantRegistry — higher-level multi-tenant management primitive …

adcp.server.tenant_router

Subdomain-based tenant routing for multi-tenant deployments …

adcp.server.test_controller

Built-in comply_test_controller for ADCP servers …

adcp.server.tmp

TMP (Temporal Matching Protocol) handler for ADCP server implementations.

adcp.server.translate

Error translation and request normalization for proxy and custom-transport servers …

Functions

def activate_signal_response(deployments: list[dict[str, Any]], *, sandbox: bool = True) ‑> dict[str, typing.Any]
Expand source code
def activate_signal_response(
    deployments: list[dict[str, Any]],
    *,
    sandbox: bool = True,
) -> dict[str, Any]:
    """Build an activate_signal success response.

    Each deployment should include: type, is_live, activation_key.
    For platform: platform, account.
    For agent: agent_url.
    Matches ActivateSignalResponse1 (success) schema.
    """
    return {
        "deployments": deployments,
        "sandbox": sandbox,
    }

Build an activate_signal success response.

Each deployment should include: type, is_live, activation_key. For platform: platform, account. For agent: agent_url. Matches ActivateSignalResponse1 (success) schema.

def adcp_error(code: str,
message: str | None = None,
*,
field: str | None = None,
suggestion: str | None = None,
recovery: str | None = None,
retry_after: int | None = None,
details: dict[str, str | int | float | bool | None] | None = None) ‑> dict[str, typing.Any]
Expand source code
def adcp_error(
    code: str,
    message: str | None = None,
    *,
    field: str | None = None,
    suggestion: str | None = None,
    recovery: str | None = None,
    retry_after: int | None = None,
    details: dict[str, str | int | float | bool | None] | None = None,
) -> dict[str, Any]:
    """Build a structured ADCP error response with auto-recovery.

    Standard codes get recovery auto-populated from the code table.
    Custom codes default to "terminal".

    Args:
        code: Error code (e.g., "BUDGET_TOO_LOW").
        message: Human-readable message. Defaults to standard message.
        field: Which request field caused the error.
        suggestion: Actionable fix suggestion.
        recovery: Override ("transient", "correctable", "terminal").
        retry_after: Seconds to wait (for RATE_LIMITED).
        details: Server-generated debugging data (constraint names, limits,
            thresholds). Use only server-generated values here. NEVER pass
            request params or user-supplied strings -- they flow to the
            caller's LLM context and could enable prompt injection.
    """
    std = STANDARD_ERROR_CODES.get(code, {})
    err: dict[str, Any] = {
        "code": code,
        "message": message or std.get("message", code),
        "recovery": recovery or std.get("recovery", "terminal"),
    }
    if field is not None:
        err["field"] = field
    if suggestion is not None:
        err["suggestion"] = suggestion
    if retry_after is not None:
        err["retry_after"] = retry_after
    if details is not None:
        err["details"] = details
    return {"errors": [err]}

Build a structured ADCP error response with auto-recovery.

Standard codes get recovery auto-populated from the code table. Custom codes default to "terminal".

Args

code
Error code (e.g., "BUDGET_TOO_LOW").
message
Human-readable message. Defaults to standard message.
field
Which request field caused the error.
suggestion
Actionable fix suggestion.
recovery
Override ("transient", "correctable", "terminal").
retry_after
Seconds to wait (for RATE_LIMITED).
details
Server-generated debugging data (constraint names, limits, thresholds). Use only server-generated values here. NEVER pass request params or user-supplied strings – they flow to the caller's LLM context and could enable prompt injection.
def adcp_server(name: str, **kwargs: Any) ‑> ADCPServerBuilder
Expand source code
def adcp_server(name: str, **kwargs: Any) -> ADCPServerBuilder:
    """Create a decorator-based ADCP server builder.

    Args:
        name: Server name.
        **kwargs: Additional configuration (e.g., version="1.0.0").

    Returns:
        An ADCPServerBuilder instance.
    """
    return ADCPServerBuilder(name, **kwargs)

Create a decorator-based ADCP server builder.

Args

name
Server name.
**kwargs
Additional configuration (e.g., version="1.0.0").

Returns

An ADCPServerBuilder instance.

def auth_context_factory(meta: RequestMetadata) ‑> ToolContext
Expand source code
def auth_context_factory(meta: RequestMetadata) -> ToolContext:
    """Build a :class:`~adcp.server.ToolContext` from auth state the
    :class:`BearerTokenAuthMiddleware` populated for the in-flight
    request.

    Pass this to :func:`~adcp.server.create_mcp_server` (or
    :func:`~adcp.server.serve`) alongside the middleware so handlers
    receive a typed context carrying the authenticated principal.

    Resolution order:

    1. ``meta.request_context.state`` — the standard Starlette
       per-request scratchpad. Survives the stateful streamable-http
       session-task boundary (the dispatch sub-task gets the originating
       Starlette ``Request`` via the upstream MCP ``request_ctx``
       contextvar). Works on both stateless and stateful streamable-http.
    2. Module-level :data:`current_principal` etc. ContextVars — the
       legacy carrier. Works only when the dispatch runs in the same
       async task as the middleware (i.e., stateless streamable-http
       and A2A). In stateful streamable-http, these read ``None``
       because the session task is a separate task.

    Populates ``caller_identity``, ``tenant_id``, and a ``metadata``
    dict containing the transport + tool name plus anything the
    :class:`Principal` provided. SDK-owned keys (``tool_name``,
    ``transport``) take precedence over principal-supplied keys, so a
    validator returning ``Principal(metadata={"tool_name": "x"})``
    cannot shadow audit fields the SDK populates. Returns a bare
    :class:`ToolContext` — agents that want a typed subclass
    (e.g. :class:`~adcp.server.AccountAwareToolContext`) should copy
    the three-line body and return their own subclass instead.

    Also sets ``metadata["adcp.auth_info"]`` to a typed
    :class:`~adcp.decisioning.AuthInfo` when the request is
    authenticated, so :meth:`~adcp.decisioning.PlatformHandler._extract_auth_info`
    surfaces a non-``None`` :attr:`~adcp.decisioning.RequestContext.auth_info`
    for bearer flows — the same typed surface signed-request flows already
    populate.  ``credential`` is ``None`` for bearer flows because inbound
    bearer tokens are not for upstream propagation; adopters who need
    :class:`~adcp.decisioning.BuyerAgentRegistry` dispatch must supply a
    typed credential in a custom ``context_factory`` subclass.

    ``adcp.auth_info`` is server-internal and never wire-echoed by the
    framework. Do not pass ``ctx.metadata`` wholesale to a JSON serializer
    — the ``AuthInfo`` object is not JSON-serializable.
    """
    principal_identity: str | None = None
    tenant_id: str | None = None
    principal_metadata: dict[str, Any] | None = None
    if meta.request_context is not None:
        triple = _read_request_state_auth(meta.request_context)
        if triple is not None:
            principal_identity, tenant_id, principal_metadata = triple
    if principal_identity is None and tenant_id is None and principal_metadata is None:
        # Either no Request was threaded (stdio MCP, A2A pre-builder
        # path) or the middleware didn't write to state — fall back to
        # the ContextVars. Works on stateless streamable-http and A2A
        # where dispatch shares the middleware's task context.
        principal_identity = current_principal.get()
        tenant_id = current_tenant.get()
        principal_metadata = current_principal_metadata.get()
    principal_metadata = principal_metadata or {}
    combined_metadata: dict[str, Any] = {
        **principal_metadata,
        "tool_name": meta.tool_name,
        "transport": meta.transport,
    }
    if principal_identity is not None:
        # Lazy import to keep module-load order safe — decisioning.context
        # imports adcp.server.base but not adcp.server.auth, so there is no
        # circular dependency, but hoisting this to module level would create
        # one if the import graph ever changes. Call-time import matches
        # the pattern already used in dispatch._build_request_context.
        from adcp.decisioning.context import AuthInfo  # noqa: PLC0415

        combined_metadata["adcp.auth_info"] = AuthInfo(
            kind="bearer",
            principal=principal_identity,
            credential=None,  # explicit None: no synthesis, no DeprecationWarning
        )
    return ToolContext(
        request_id=meta.request_id,
        caller_identity=principal_identity,
        tenant_id=tenant_id,
        metadata=combined_metadata,
    )

Build a :class:~adcp.server.ToolContext from auth state the :class:BearerTokenAuthMiddleware populated for the in-flight request.

Pass this to :func:~adcp.server.create_mcp_server (or :func:~adcp.server.serve) alongside the middleware so handlers receive a typed context carrying the authenticated principal.

Resolution order:

  1. meta.request_context.state — the standard Starlette per-request scratchpad. Survives the stateful streamable-http session-task boundary (the dispatch sub-task gets the originating Starlette Request via the upstream MCP request_ctx contextvar). Works on both stateless and stateful streamable-http.
  2. Module-level :data:current_principal etc. ContextVars — the legacy carrier. Works only when the dispatch runs in the same async task as the middleware (i.e., stateless streamable-http and A2A). In stateful streamable-http, these read None because the session task is a separate task.

Populates caller_identity, tenant_id, and a metadata dict containing the transport + tool name plus anything the :class:Principal provided. SDK-owned keys (tool_name, transport) take precedence over principal-supplied keys, so a validator returning Principal(metadata={"tool_name": "x"}) cannot shadow audit fields the SDK populates. Returns a bare :class:ToolContext — agents that want a typed subclass (e.g. :class:~adcp.server.AccountAwareToolContext) should copy the three-line body and return their own subclass instead.

Also sets metadata["adcp.auth_info"] to a typed :class:~adcp.decisioning.AuthInfo when the request is authenticated, so :meth:~adcp.decisioning.PlatformHandler._extract_auth_info surfaces a non-None :attr:~adcp.decisioning.RequestContext.auth_info for bearer flows — the same typed surface signed-request flows already populate. credential is None for bearer flows because inbound bearer tokens are not for upstream propagation; adopters who need :class:~adcp.decisioning.BuyerAgentRegistry dispatch must supply a typed credential in a custom context_factory subclass.

adcp.auth_info is server-internal and never wire-echoed by the framework. Do not pass ctx.metadata wholesale to a JSON serializer — the AuthInfo object is not JSON-serializable.

def build_creative_response(creative_manifest: dict[str, Any] | list[dict[str, Any]],
*,
sandbox: bool = True) ‑> dict[str, typing.Any]
Expand source code
def build_creative_response(
    creative_manifest: dict[str, Any] | list[dict[str, Any]],
    *,
    sandbox: bool = True,
) -> dict[str, Any]:
    """Build a build_creative success response.

    Accepts either a single manifest dict or a list of manifests.
    Each manifest should include: format_id, name, assets.

    Single manifest matches BuildCreativeResponse1.
    List matches BuildCreativeResponse3 (multi-format).
    """
    if isinstance(creative_manifest, list):
        return {
            "creative_manifests": [_strip_none_values(m) for m in creative_manifest],
            "sandbox": sandbox,
        }
    return {
        "creative_manifest": _strip_none_values(creative_manifest),
        "sandbox": sandbox,
    }

Build a build_creative success response.

Accepts either a single manifest dict or a list of manifests. Each manifest should include: format_id, name, assets.

Single manifest matches BuildCreativeResponse1. List matches BuildCreativeResponse3 (multi-format).

def build_manifest(*,
name: str,
transports: list[Transport],
base_url: str,
description: str | None = None,
specialisms: list[str] | None = None) ‑> dict[str, typing.Any]
Expand source code
def build_manifest(
    *,
    name: str,
    transports: list[Transport],
    base_url: str,
    description: str | None = None,
    specialisms: list[str] | None = None,
) -> dict[str, Any]:
    """Build the AdCP multi-agent topology manifest document.

    Pure function — no I/O, no globals — so it's trivial to unit-test
    and reuse in adopter tooling that wants to publish a static
    manifest from CI.

    :param name: Operator-supplied agent / platform name. Becomes the
        ``agent_id`` (after normalization to the schema's character
        class) and informs the contact ``name`` field.
    :param transports: Transports the binary serves. ``["mcp"]``,
        ``["a2a"]``, or ``["mcp", "a2a"]`` for ``transport="both"``.
        One manifest entry is emitted per transport — buyers route by
        transport, so each gets its own row even when they share a
        process.
    :param base_url: Origin the binary is reachable at, e.g.
        ``"https://sales.example.com"``. The manifest URL is built as
        ``<base_url>/mcp`` for MCP and ``<base_url>`` for A2A.
    :param description: Optional human-readable description surfaced in
        operator UIs and conformance reports.
    :param specialisms: Optional AdCP specialisms (e.g.
        ``["sales-non-guaranteed"]``). The schema requires ``minItems:
        1`` so when nothing is supplied we fall back to a minimal
        ``["adcp"]`` placeholder. Adopters who know their specialism
        SHOULD pass it explicitly.
    """
    # TODO(#381): infer specialisms from the handler's advertised
    # tools (e.g. presence of ``get_products`` → sales-non-guaranteed).
    # For now adopters pass them explicitly or accept the placeholder.
    effective_specialisms = list(specialisms) if specialisms else ["adcp"]

    base_id = _normalize_agent_id(name)
    agents: list[dict[str, Any]] = []
    for transport in transports:
        # When emitting two rows from the same binary the schema requires
        # unique agent_ids — suffix with the transport so ``foo-mcp`` and
        # ``foo-a2a`` are both legal and self-describing.
        agent_id = f"{base_id}-{transport}" if len(transports) > 1 else base_id
        entry: dict[str, Any] = {
            "agent_id": agent_id,
            "url": _agent_url(transport, base_url),
            "transport": transport,
            "specialisms": effective_specialisms,
        }
        if description:
            entry["description"] = description
        agents.append(entry)

    # Truncate to whole-hour granularity so consecutive requests within
    # the same hour produce byte-identical manifests — lets HTTP caches
    # (CDNs, conformance runners polling on a loop) collapse repeated
    # fetches instead of seeing a fresh second-resolution timestamp on
    # every hit. Hour-resolution is well within the spec's "informational
    # only" semantics for ``last_updated``.
    last_updated = (
        datetime.now(timezone.utc)
        .replace(minute=0, second=0, microsecond=0)
        .strftime("%Y-%m-%dT%H:%M:%SZ")
    )
    manifest: dict[str, Any] = {
        "$schema": MANIFEST_SCHEMA_URI,
        "version": MANIFEST_VERSION,
        "agents": agents,
        "last_updated": last_updated,
    }
    if name:
        manifest["contact"] = {"name": name}
    return manifest

Build the AdCP multi-agent topology manifest document.

Pure function — no I/O, no globals — so it's trivial to unit-test and reuse in adopter tooling that wants to publish a static manifest from CI.

:param name: Operator-supplied agent / platform name. Becomes the agent_id (after normalization to the schema's character class) and informs the contact name field. :param transports: Transports the binary serves. ["mcp"], ["a2a"], or ["mcp", "a2a"] for transport="both". One manifest entry is emitted per transport — buyers route by transport, so each gets its own row even when they share a process. :param base_url: Origin the binary is reachable at, e.g. "https://sales.example.com". The manifest URL is built as <base_url>/mcp for MCP and <base_url> for A2A. :param description: Optional human-readable description surfaced in operator UIs and conformance reports. :param specialisms: Optional AdCP specialisms (e.g. ["sales-non-guaranteed"]). The schema requires minItems: 1 so when nothing is supplied we fall back to a minimal ["adcp"] placeholder. Adopters who know their specialism SHOULD pass it explicitly.

def cancel_media_buy_response(media_buy_id: str,
canceled_by: str,
*,
reason: str | None = None,
canceled_at: str | None = None,
affected_packages: list[Any] | None = None,
revision: int | None = None,
sandbox: bool = True) ‑> dict[str, typing.Any]
Expand source code
def cancel_media_buy_response(
    media_buy_id: str,
    canceled_by: str,
    *,
    reason: str | None = None,
    canceled_at: str | None = None,
    affected_packages: list[Any] | None = None,
    revision: int | None = None,
    sandbox: bool = True,
) -> dict[str, Any]:
    """Build a cancellation response with auto-defaults.

    Auto-sets canceled_at to now, status to "canceled", valid_actions to [].
    Requires canceled_by ("buyer" or "seller") - the field developers
    most commonly forget.
    """
    if canceled_by not in ("buyer", "seller"):
        raise ValueError(f"canceled_by must be 'buyer' or 'seller', got {canceled_by!r}")
    resp: dict[str, Any] = {
        "media_buy_id": media_buy_id,
        "status": "canceled",
        "canceled_by": canceled_by,
        "canceled_at": canceled_at or datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
        "valid_actions": [],
        "sandbox": sandbox,
    }
    if reason is not None:
        resp["reason"] = reason
    if affected_packages is not None:
        resp["affected_packages"] = affected_packages
    if revision is not None:
        resp["revision"] = revision
    return resp

Build a cancellation response with auto-defaults.

Auto-sets canceled_at to now, status to "canceled", valid_actions to []. Requires canceled_by ("buyer" or "seller") - the field developers most commonly forget.

def capabilities_response(supported_protocols: list[str],
*,
major_versions: list[int] | None = None,
adcp_version: str | None = None,
supported_versions: list[str] | None = None,
build_version: str | None = None,
sandbox: bool = True,
features: dict[str, Any] | None = None,
idempotency: dict[str, Any] | None = None,
compliance_testing: dict[str, Any] | None = None) ‑> dict[str, typing.Any]
Expand source code
def capabilities_response(
    supported_protocols: list[str],
    *,
    major_versions: list[int] | None = None,
    adcp_version: str | None = None,
    supported_versions: list[str] | None = None,
    build_version: str | None = None,
    sandbox: bool = True,
    features: dict[str, Any] | None = None,
    idempotency: dict[str, Any] | None = None,
    compliance_testing: dict[str, Any] | None = None,
) -> dict[str, Any]:
    """Build a get_adcp_capabilities response.

    Args:
        supported_protocols: e.g. ["media_buy"], ["media_buy", "signals"].
            Valid values: media_buy, signals, governance, creative, brand,
            sponsored_intelligence. ``compliance_testing`` is NOT a protocol —
            pass it via the ``compliance_testing`` kwarg.
        major_versions: AdCP major versions. Defaults to [3]. Deprecated in
            favor of ``supported_versions`` (release-precision); both are
            emitted through 3.x for backwards compatibility.
        adcp_version: Server's pinned release this response was built
            for. Must match one of ``supported_versions`` exactly. When
            set, included on the response envelope so buyers can read what
            release the server actually served. Typically passed by
            ``ADCPServerBuilder``'s auto-capabilities handler from its
            per-instance pin.
        supported_versions: Release-precision versions this server speaks
            (e.g. ``["3.0", "3.1"]``). Authoritative for buyer-side
            release pinning per the version-negotiation RFC. When omitted,
            defaults to the SDK's stable aliases plus the exact packaged
            prerelease line when applicable.
        build_version: Optional advisory metadata — full
            VERSION.RELEASE.PATCH of the server's build (e.g.
            ``"3.1.2"``). Useful for incident triage; not part of the
            wire negotiation contract.
        sandbox: Whether this is a sandbox agent. Defaults to True.
        features: Additional feature flags.
        idempotency: Optional idempotency declaration, nested under
            ``adcp.idempotency`` per AdCP #2315. Pass the output of
            :meth:`adcp.server.idempotency.IdempotencyStore.capability` here
            to declare the seller's ``replay_ttl_seconds``.
        compliance_testing: Optional top-level ``compliance_testing`` block
            to advertise compliance-testing capabilities. When provided,
            emitted as a sibling of ``adcp`` in the response.

    Example::

        from adcp.server.responses import capabilities_response
        from adcp.server.idempotency import IdempotencyStore, MemoryBackend

        store = IdempotencyStore(backend=MemoryBackend(), ttl_seconds=86400)
        return capabilities_response(
            ["media_buy"],
            idempotency=store.capability(),
        )
    """
    if compliance_testing is not None and not idempotency:
        _logger.warning(
            "capabilities_response: adcp.idempotency not declared. "
            "The AdCP 3.0.1 storyboard runner may downgrade to v2 mode and "
            "cascade failures across idempotency-dependent tracks. "
            "Pass idempotency={'supported': False} to declare non-support, "
            "or idempotency=store.capability() to declare support."
        )
    effective_major_versions = major_versions or [ADCP_MAJOR_VERSION]
    adcp_info: dict[str, Any] = {"major_versions": effective_major_versions}
    if supported_versions is None:
        majors = _major_version_values(effective_major_versions)
        if majors and all(major == ADCP_MAJOR_VERSION for major in majors):
            supported_versions = list(get_supported_adcp_versions())
    if supported_versions:
        adcp_info["supported_versions"] = supported_versions
    if build_version is not None:
        adcp_info["build_version"] = build_version
    if idempotency:
        adcp_info["idempotency"] = idempotency
    resp: dict[str, Any] = {
        "status": "completed",
        "adcp": adcp_info,
        "supported_protocols": supported_protocols,
        "sandbox": sandbox,
    }
    if adcp_version is not None:
        resp["adcp_version"] = _normalize_capabilities_adcp_version(
            adcp_version,
            supported_versions,
        )
    if features:
        resp["features"] = features
    if compliance_testing is not None:
        resp["compliance_testing"] = compliance_testing
    return resp

Build a get_adcp_capabilities response.

Args

supported_protocols
e.g. ["media_buy"], ["media_buy", "signals"]. Valid values: media_buy, signals, governance, creative, brand, sponsored_intelligence. compliance_testing is NOT a protocol — pass it via the compliance_testing kwarg.
major_versions
AdCP major versions. Defaults to [3]. Deprecated in favor of supported_versions (release-precision); both are emitted through 3.x for backwards compatibility.
adcp_version
Server's pinned release this response was built for. Must match one of supported_versions exactly. When set, included on the response envelope so buyers can read what release the server actually served. Typically passed by ADCPServerBuilder's auto-capabilities handler from its per-instance pin.
supported_versions
Release-precision versions this server speaks (e.g. ["3.0", "3.1"]). Authoritative for buyer-side release pinning per the version-negotiation RFC. When omitted, defaults to the SDK's stable aliases plus the exact packaged prerelease line when applicable.
build_version
Optional advisory metadata — full VERSION.RELEASE.PATCH of the server's build (e.g. "3.1.2"). Useful for incident triage; not part of the wire negotiation contract.
sandbox
Whether this is a sandbox agent. Defaults to True.
features
Additional feature flags.
idempotency
Optional idempotency declaration, nested under adcp.idempotency per AdCP #2315. Pass the output of :meth:IdempotencyStore.capability() here to declare the seller's replay_ttl_seconds.
compliance_testing
Optional top-level compliance_testing block to advertise compliance-testing capabilities. When provided, emitted as a sibling of adcp in the response.

Example::

from adcp.server.responses import capabilities_response
from adcp.server.idempotency import IdempotencyStore, MemoryBackend

store = IdempotencyStore(backend=MemoryBackend(), ttl_seconds=86400)
return capabilities_response(
    ["media_buy"],
    idempotency=store.capability(),
)
def compose_pre_validation_hooks(*hook_maps: Mapping[str, PreValidationHookChain] | None) ‑> dict[str, tuple[Callable[[str, dict[str, typing.Any]], dict[str, typing.Any]], ...]]
Expand source code
def compose_pre_validation_hooks(
    *hook_maps: Mapping[str, PreValidationHookChain] | None,
) -> dict[str, tuple[PreValidationHook, ...]]:
    """Compose ordered pre-validation hook maps.

    Later maps append to earlier maps for overlapping tool names. Each
    tool's hooks run left-to-right, feeding the returned args from one hook
    into the next.
    """
    composed: dict[str, list[PreValidationHook]] = {}
    for hook_map in hook_maps:
        if hook_map is None:
            continue
        for tool_name, chain in hook_map.items():
            composed.setdefault(tool_name, []).extend(_flatten_pre_validation_hooks(chain))
    return {tool_name: tuple(hooks) for tool_name, hooks in composed.items()}

Compose ordered pre-validation hook maps.

Later maps append to earlier maps for overlapping tool names. Each tool's hooks run left-to-right, feeding the returned args from one hook into the next.

def constant_time_token_match(token: str, stored_hashes: Mapping[str, _V]) ‑> ~_V | None
Expand source code
def constant_time_token_match(token: str, stored_hashes: Mapping[str, _V]) -> _V | None:
    """Look up a token in a dict of SHA-256 hashes using
    :func:`hmac.compare_digest` rather than dict-containment.

    Dict lookup + equality (``candidate_hash in stored_hashes``) leaks
    prefix-match timing because the hash comparison short-circuits on
    first byte mismatch. Iterating every stored hash with
    ``compare_digest`` makes the wall-clock runtime independent of
    how much of the candidate matches any entry.

    Use this when your token store is small enough to iterate linearly
    (hundreds to low-thousands). For larger stores, use a database
    column of hashed tokens with an equality index + one
    ``compare_digest`` check on the single returned row.

    :param token: Raw bearer token supplied by the client.
    :param stored_hashes: ``{sha256_hex: value}`` dictionary. Returns
        ``value`` on the matching entry, ``None`` on no match.
    """
    if not token:
        return None
    candidate = hashlib.sha256(token.encode()).hexdigest()
    for stored_hash, value in stored_hashes.items():
        if hmac.compare_digest(candidate, stored_hash):
            return value
    return None

Look up a token in a dict of SHA-256 hashes using :func:hmac.compare_digest rather than dict-containment.

Dict lookup + equality (candidate_hash in stored_hashes) leaks prefix-match timing because the hash comparison short-circuits on first byte mismatch. Iterating every stored hash with compare_digest makes the wall-clock runtime independent of how much of the candidate matches any entry.

Use this when your token store is small enough to iterate linearly (hundreds to low-thousands). For larger stores, use a database column of hashed tokens with an equality index + one compare_digest check on the single returned row.

:param token: Raw bearer token supplied by the client. :param stored_hashes: {sha256_hex: value} dictionary. Returns value on the matching entry, None on no match.

def create_a2a_server(handler: ADCPHandler[Any],
*,
name: str = 'adcp-agent',
port: int | None = None,
description: str | None = None,
version: str = '1.0.0',
test_controller: TestControllerStore | None = None,
test_controller_account_resolver: Any | None = None,
context_factory: ContextFactory | None = None,
task_store: TaskStore | None = None,
push_config_store: PushNotificationConfigStore | None = None,
middleware: Sequence[SkillMiddleware] | None = None,
message_parser: MessageParser | None = None,
advertise_all: bool = False,
validation: ValidationHookConfig | None = ValidationHookConfig(requests='strict', responses='strict', unknown_fields=None),
pre_validation_hooks: PreValidationHooks | None = None,
context_builder: Any | None = None,
auth: BearerTokenAuth | None = None,
public_url: str | PublicUrlResolver | None = None,
response_enhancer: ResponseEnhancer | None = None) ‑> Any
Expand source code
def create_a2a_server(
    handler: ADCPHandler[Any],
    *,
    name: str = "adcp-agent",
    port: int | None = None,
    description: str | None = None,
    version: str = "1.0.0",
    test_controller: TestControllerStore | None = None,
    test_controller_account_resolver: Any | None = None,
    context_factory: ContextFactory | None = None,
    task_store: TaskStore | None = None,
    push_config_store: PushNotificationConfigStore | None = None,
    middleware: Sequence[SkillMiddleware] | None = None,
    message_parser: MessageParser | None = None,
    advertise_all: bool = False,
    validation: ValidationHookConfig | None = SERVER_DEFAULT_VALIDATION,
    pre_validation_hooks: PreValidationHooks | None = None,
    context_builder: Any | None = None,
    auth: BearerTokenAuth | None = None,
    public_url: str | PublicUrlResolver | None = None,
    response_enhancer: ResponseEnhancer | None = None,
) -> Any:
    """Create an A2A Starlette application from an ADCP handler.

    The returned app dual-serves the a2a-sdk 0.3 and 1.0 wire formats via
    ``create_jsonrpc_routes(enable_v0_3_compat=True)``. Existing 0.3
    clients keep getting lowercase ``"state": "completed"`` and
    ``"kind": "task"`` discriminators; native 1.0 clients get the new
    shape. Do not disable the compat flag.

    Args:
        handler: An ADCPHandler subclass instance.
        name: Agent name shown in the A2A agent card.
        port: Port number (used in the agent card URL).
        description: Agent description for the agent card.
        version: Agent version string.
        test_controller: Optional TestControllerStore for storyboard testing.
        context_factory: Optional callable invoked per skill call to build
            a :class:`ToolContext` from :class:`RequestMetadata`. Mirrors
            the MCP-side ``context_factory=`` on
            :func:`~adcp.server.create_mcp_server` so a single factory
            populates tenant/adapter fields on both transports. When
            unset, the executor falls back to deriving ``caller_identity``
            from ``ServerCallContext.user`` — preserving pre-factory
            behavior. See :data:`~adcp.server.ContextFactory` for the
            recommended contextvars pattern.
        task_store: Optional a2a-sdk :class:`~a2a.server.tasks.task_store.TaskStore`
            instance for persisting A2A task state. Defaults to
            :class:`~a2a.server.tasks.inmemory_task_store.InMemoryTaskStore`,
            which is single-process and non-durable — fine for demos and
            local development, but tasks vanish on restart and don't share
            across workers. Production agents pass a durable subclass
            (Postgres, Redis, etc.). See ``examples/a2a_db_tasks.py`` for
            a reference SQLite-backed implementation and
            ``docs/handler-authoring.md`` for the persistence caveats on
            the default store.
        push_config_store: Optional a2a-sdk
            :class:`~a2a.server.tasks.push_notification_config_store.PushNotificationConfigStore`
            instance for persisting push-notification configs that clients
            register via ``tasks/pushNotificationConfig/set``. **When
            unset, a2a-sdk surfaces push-notif endpoints as
            ``UnsupportedOperationError``** — clients cannot register
            subscriptions at all. Set this only when your agent is ready
            to accept push-notif subscriptions. See
            ``examples/a2a_db_tasks.py`` for a reference SQLite-backed
            implementation that pairs with the ``SqliteTaskStore`` there.

            Security note: unlike ``TaskStore``, a2a-sdk's
            ``PushNotificationConfigStore`` ABC does not pass a
            ``ServerCallContext`` to ``set_info`` / ``get_info`` /
            ``delete_info``. Scoping by principal has to happen out-of-band
            (via a ``ContextVar`` your auth middleware populates) or by
            composition with a tenant-scoped ``TaskStore`` — the reference
            impl shows the ContextVar pattern.
        middleware: Optional sequence of :data:`~adcp.server.SkillMiddleware`
            callables wrapping every A2A skill dispatch. Composes
            outermost-first (first entry sees the call before later
            entries and before the handler). Use for audit logging,
            activity-feed hooks, rate limiting, per-skill tracing. See
            :data:`~adcp.server.SkillMiddleware` for the signature,
            composition semantics, and the exception-capture pattern
            audit hooks need.
        message_parser: Optional :data:`MessageParser` for alternative
            wire shapes. The default parser handles a DataPart carrying
            ``{"skill": ..., "parameters": ...}`` plus a TextPart JSON
            fallback. Supply this to accept JSON-RPC 2.0 message bodies,
            vendor-specific DataPart schemas, or other layouts. The
            callable returns ``(skill_name, params)`` or ``(None, {})``
            for "no parseable skill"; see :data:`MessageParser` and
            :meth:`ADCPAgentExecutor._default_parse_request` for the
            built-in fallback shape to delegate to for legacy clients.
        advertise_all: When True, advertise every tool the handler type
            supports — including ones whose method is still the SDK's
            ``not_supported`` default. Defaults to ``False``, which
            reflects only overridden methods in the agent card's
            ``skills`` list and in the executor's tool-caller registry.
            Turn on for spec-compliance storyboards or when the agent
            deliberately wants clients to see a ``not_supported`` tool.
        validation: :class:`ValidationHookConfig` enabling schema
            validation of every request and response against the
            bundled AdCP JSON schemas. Defaults to
            :data:`~adcp.validation.client_hooks.SERVER_DEFAULT_VALIDATION`
            (strict on both sides). Pass
            ``ValidationHookConfig(responses="warn")`` to log+continue
            on response drift, or ``validation=None`` to disable
            validation entirely.
        auth: Optional :class:`~adcp.server.auth.BearerTokenAuth`
            config. When supplied, the agent card publishes a matching
            ``bearerAuth`` security scheme + requirement so a2a-sdk's
            client auth interceptor attaches credentials automatically.
            Note that ``create_a2a_server`` does **not** install the
            request-time middleware itself — auth gating is wired by
            :func:`adcp.server.serve` via :class:`A2ABearerAuthMiddleware`
            at the ASGI layer. Adopters calling ``create_a2a_server``
            directly must wrap the returned app with
            :class:`A2ABearerAuthMiddleware` themselves.
        public_url: Public base URL for the A2A agent card
            (``/.well-known/agent-card.json``). Accepts either a static
            string or a :data:`PublicUrlResolver` callable for per-request
            resolution.

            *Static string* — replaces ``http://localhost:{port}/`` in
            every ``supported_interfaces`` URL.  Falls back to the
            ``PUBLIC_URL`` environment variable when ``public_url`` is
            ``None``.  Correct for single-host or fixed-URL deployments.

            *Callable* — receives the Starlette
            :class:`~starlette.requests.Request` on each card fetch and
            must return an absolute ``https://`` URL.  Use this for
            multi-tenant subdomain deployments where each tenant has its
            own public host::

                def agent_card_url(request: Request) -> str:
                    host = request.headers.get("host", "localhost")
                    return f"https://{host}/"

                serve(handler, transport="a2a", public_url=agent_card_url)

            When a callable is supplied the a2a-sdk's static
            ``create_agent_card_routes`` is bypassed in favour of an
            ASGI-layer intercept that builds the card per-request.  The
            ``DefaultRequestHandler``'s internal ``GetAgentCard`` RPC
            path retains a ``localhost`` fallback card — buyers probing
            the well-known endpoint always receive the per-request card.

            The ``PUBLIC_URL`` env-var fallback applies only when
            ``public_url`` is ``None``; a callable takes priority.
        response_enhancer: Optional server-wide
            :data:`~adcp.server.ResponseEnhancer` applied to every
            response — successes, ``adcp_error`` envelopes, and the
            ``comply_test_controller`` skill — after the context echo and
            (for successes) before schema validation. Mirrors the MCP-side
            ``create_mcp_server(response_enhancer=...)`` so a single
            callback stamps both transports. See
            :data:`~adcp.server.ResponseEnhancer` for the supported arities
            and failure semantics.

    Returns:
        A Starlette app ready to be run with uvicorn.
    """
    resolved_port = port or int(os.environ.get("PORT", "3001"))
    # A callable resolver takes priority; env-var fallback only applies
    # when public_url is None (not callable).
    resolved_public_url: str | PublicUrlResolver | None = (
        public_url if public_url is not None else os.environ.get("PUBLIC_URL")
    )

    executor = ADCPAgentExecutor(
        handler,
        test_controller=test_controller,
        context_factory=context_factory,
        middleware=middleware,
        message_parser=message_parser,
        advertise_all=advertise_all,
        validation=validation,
        pre_validation_hooks=pre_validation_hooks,
        test_controller_account_resolver=test_controller_account_resolver,
        response_enhancer=response_enhancer,
    )

    if task_store is None:
        task_store = InMemoryTaskStore()

    # ``enable_v0_3_compat=True`` is load-bearing: it makes the server
    # dual-serve 0.3 and 1.0 wire formats on the same endpoint so existing
    # 0.3 buyer clients keep working unchanged. Do not disable.
    #
    # ``context_builder`` is the a2a-sdk seam for customising the
    # :class:`ServerCallContext` each handler receives. We thread it
    # through verbatim when supplied — bearer-token auth is wired
    # separately via :class:`A2ABearerAuthMiddleware` at the ASGI
    # layer (see ``serve.py:_wrap_a2a_with_auth``) because the v0.3
    # compat adapter swallows builder-raised ``HTTPException``s. The
    # builder kwarg remains for adopters customising the
    # ``ServerCallContext`` shape (e.g. surfacing additional
    # ``state`` fields from the request).
    jsonrpc_kwargs: dict[str, Any] = {
        "rpc_url": "/",
        "enable_v0_3_compat": True,
    }
    if context_builder is not None:
        jsonrpc_kwargs["context_builder"] = context_builder

    _extra_skills = _test_controller_skills() if test_controller else None
    _push_supported = push_config_store is not None

    if callable(resolved_public_url):
        # Per-request path: build a localhost fallback card for
        # DefaultRequestHandler's internal GetAgentCard RPC (buyers probe
        # /.well-known/agent-card.json directly; the RPC fallback is rarely
        # used). The well-known endpoints are served by
        # _PerRequestCardMiddleware which builds a fresh card per GET.
        fallback_card = _build_agent_card(
            handler,
            name=name,
            port=resolved_port,
            description=description,
            version=version,
            extra_skills=_extra_skills,
            advertise_all=advertise_all,
            push_notifications_supported=_push_supported,
            auth=auth,
            public_url=None,
        )
        # DefaultRequestHandler stores push_config_store verbatim and
        # treats None as "push-notif unsupported". Passing None is the
        # correct default; sellers opt in by wiring a store.
        request_handler = DefaultRequestHandler(
            agent_executor=executor,
            task_store=task_store,
            agent_card=fallback_card,
            push_config_store=push_config_store,
        )
        jsonrpc_kwargs["request_handler"] = request_handler
        routes = list(create_jsonrpc_routes(**jsonrpc_kwargs))
        # Install the per-request card intercept via ``add_middleware``
        # so ``app`` stays a Starlette instance — the unified-transport
        # lifespan composer in ``serve._serve_mcp_and_a2a`` reaches
        # ``a2a_inner.router.lifespan_context`` on this object.
        app = Starlette(routes=routes)
        app.add_middleware(
            _PerRequestCardMiddleware,
            resolver=resolved_public_url,
            handler=handler,
            name=name,
            port=resolved_port,
            description=description,
            version=version,
            extra_skills=_extra_skills,
            advertise_all=advertise_all,
            push_notifications_supported=_push_supported,
            auth=auth,
        )
    else:
        # Static card path: existing behaviour — card built once at
        # server init and served unchanged on every card request.
        agent_card = _build_agent_card(
            handler,
            name=name,
            port=resolved_port,
            description=description,
            version=version,
            extra_skills=_extra_skills,
            advertise_all=advertise_all,
            push_notifications_supported=_push_supported,
            auth=auth,
            public_url=resolved_public_url,
        )
        # DefaultRequestHandler stores push_config_store verbatim and treats
        # None as "push-notif endpoints unsupported" (UnsupportedOperationError
        # on tasks/pushNotificationConfig/*). Passing None is the correct
        # default; sellers opt in by wiring a store.
        request_handler = DefaultRequestHandler(
            agent_executor=executor,
            task_store=task_store,
            agent_card=agent_card,
            push_config_store=push_config_store,
        )
        jsonrpc_kwargs["request_handler"] = request_handler
        routes = (
            list(create_agent_card_routes(agent_card=agent_card))
            # 0.3 alias: A2A 0.3 buyer SDKs probe /.well-known/agent.json
            # as a positive A2A signal. Same handler, no redirect round-trip.
            + list(
                create_agent_card_routes(agent_card=agent_card, card_url="/.well-known/agent.json")
            )
            + list(create_jsonrpc_routes(**jsonrpc_kwargs))
        )
        app = Starlette(routes=routes)

    # Startup log lives on the create_a2a_server path (symmetric with
    # MCP's _register_handler_tools). Moved out of
    # ADCPAgentExecutor.__init__ so per-test executor constructions
    # don't pollute caplog with repeated startup messages.
    from adcp.server.serve import _log_advertised_tools

    _log_advertised_tools(
        transport="a2a",
        handler=handler,
        advertise_all=advertise_all,
        registered=list(executor.supported_skills),
    )

    return app

Create an A2A Starlette application from an ADCP handler.

The returned app dual-serves the a2a-sdk 0.3 and 1.0 wire formats via create_jsonrpc_routes(enable_v0_3_compat=True). Existing 0.3 clients keep getting lowercase "state": "completed" and "kind": "task" discriminators; native 1.0 clients get the new shape. Do not disable the compat flag.

Args

handler
An ADCPHandler subclass instance.
name
Agent name shown in the A2A agent card.
port
Port number (used in the agent card URL).
description
Agent description for the agent card.
version
Agent version string.
test_controller
Optional TestControllerStore for storyboard testing.
context_factory
Optional callable invoked per skill call to build a :class:ToolContext from :class:RequestMetadata. Mirrors the MCP-side context_factory= on :func:~adcp.server.create_mcp_server so a single factory populates tenant/adapter fields on both transports. When unset, the executor falls back to deriving caller_identity from ServerCallContext.user — preserving pre-factory behavior. See :data:~adcp.server.ContextFactory for the recommended contextvars pattern.
task_store
Optional a2a-sdk :class:~a2a.server.tasks.task_store.TaskStore instance for persisting A2A task state. Defaults to :class:~a2a.server.tasks.inmemory_task_store.InMemoryTaskStore, which is single-process and non-durable — fine for demos and local development, but tasks vanish on restart and don't share across workers. Production agents pass a durable subclass (Postgres, Redis, etc.). See examples/a2a_db_tasks.py for a reference SQLite-backed implementation and docs/handler-authoring.md for the persistence caveats on the default store.
push_config_store

Optional a2a-sdk :class:~a2a.server.tasks.push_notification_config_store.PushNotificationConfigStore instance for persisting push-notification configs that clients register via tasks/pushNotificationConfig/set. When unset, a2a-sdk surfaces push-notif endpoints as UnsupportedOperationError — clients cannot register subscriptions at all. Set this only when your agent is ready to accept push-notif subscriptions. See examples/a2a_db_tasks.py for a reference SQLite-backed implementation that pairs with the SqliteTaskStore there.

Security note: unlike TaskStore, a2a-sdk's PushNotificationConfigStore ABC does not pass a ServerCallContext to set_info / get_info / delete_info. Scoping by principal has to happen out-of-band (via a ContextVar your auth middleware populates) or by composition with a tenant-scoped TaskStore — the reference impl shows the ContextVar pattern.

middleware
Optional sequence of :data:~adcp.server.SkillMiddleware callables wrapping every A2A skill dispatch. Composes outermost-first (first entry sees the call before later entries and before the handler). Use for audit logging, activity-feed hooks, rate limiting, per-skill tracing. See :data:~adcp.server.SkillMiddleware for the signature, composition semantics, and the exception-capture pattern audit hooks need.
message_parser
Optional :data:MessageParser for alternative wire shapes. The default parser handles a DataPart carrying {"skill": ..., "parameters": ...} plus a TextPart JSON fallback. Supply this to accept JSON-RPC 2.0 message bodies, vendor-specific DataPart schemas, or other layouts. The callable returns (skill_name, params) or (None, {}) for "no parseable skill"; see :data:MessageParser and :meth:ADCPAgentExecutor._default_parse_request for the built-in fallback shape to delegate to for legacy clients.
advertise_all
When True, advertise every tool the handler type supports — including ones whose method is still the SDK's not_supported() default. Defaults to False, which reflects only overridden methods in the agent card's skills list and in the executor's tool-caller registry. Turn on for spec-compliance storyboards or when the agent deliberately wants clients to see a not_supported() tool.
validation
:class:ValidationHookConfig enabling schema validation of every request and response against the bundled AdCP JSON schemas. Defaults to :data:~adcp.validation.client_hooks.SERVER_DEFAULT_VALIDATION (strict on both sides). Pass ValidationHookConfig(responses="warn") to log+continue on response drift, or validation=None to disable validation entirely.
auth
Optional :class:~adcp.server.auth.BearerTokenAuth config. When supplied, the agent card publishes a matching bearerAuth security scheme + requirement so a2a-sdk's client auth interceptor attaches credentials automatically. Note that create_a2a_server() does not install the request-time middleware itself — auth gating is wired by :func:serve() via :class:A2ABearerAuthMiddleware at the ASGI layer. Adopters calling create_a2a_server() directly must wrap the returned app with :class:A2ABearerAuthMiddleware themselves.
public_url

Public base URL for the A2A agent card (/.well-known/agent-card.json). Accepts either a static string or a :data:PublicUrlResolver callable for per-request resolution.

Static string — replaces http://localhost:{port}/ in every supported_interfaces URL. Falls back to the PUBLIC_URL environment variable when public_url is None. Correct for single-host or fixed-URL deployments.

Callable — receives the Starlette :class:~starlette.requests.Request on each card fetch and must return an absolute https:// URL. Use this for multi-tenant subdomain deployments where each tenant has its own public host::

def agent_card_url(request: Request) -> str:
    host = request.headers.get("host", "localhost")
    return f"https://{host}/"

serve(handler, transport="a2a", public_url=agent_card_url)

When a callable is supplied the a2a-sdk's static create_agent_card_routes is bypassed in favour of an ASGI-layer intercept that builds the card per-request. The DefaultRequestHandler's internal GetAgentCard RPC path retains a localhost fallback card — buyers probing the well-known endpoint always receive the per-request card.

The PUBLIC_URL env-var fallback applies only when public_url is None; a callable takes priority.

response_enhancer
Optional server-wide :data:~adcp.server.ResponseEnhancer applied to every response — successes, adcp_error() envelopes, and the comply_test_controller skill — after the context echo and (for successes) before schema validation. Mirrors the MCP-side create_mcp_server(response_enhancer=...) so a single callback stamps both transports. See :data:~adcp.server.ResponseEnhancer for the supported arities and failure semantics.

Returns

A Starlette app ready to be run with uvicorn.

def create_mcp_server(handler: ADCPHandler[Any],
*,
name: str = 'adcp-agent',
port: int | None = None,
host: str | None = None,
instructions: str | None = None,
include_test_controller: bool = False,
context_factory: ContextFactory | None = None,
middleware: Sequence[SkillMiddleware] | None = None,
advertise_all: bool = False,
streaming_responses: bool = False,
stateless_http: bool = False,
session_idle_timeout: float | None = 1800.0,
max_active_sessions: int | None = None,
validation: ValidationHookConfig | None = ValidationHookConfig(requests='strict', responses='strict', unknown_fields=None),
pre_validation_hooks: PreValidationHooks | None = None,
response_enhancer: ResponseEnhancer | None = None,
allowed_hosts: Sequence[str] | None = None,
allowed_origins: Sequence[str] | None = None,
enable_dns_rebinding_protection: bool | None = None) ‑> Any
Expand source code
def create_mcp_server(
    handler: ADCPHandler[Any],
    *,
    name: str = "adcp-agent",
    port: int | None = None,
    host: str | None = None,
    instructions: str | None = None,
    include_test_controller: bool = False,
    context_factory: ContextFactory | None = None,
    middleware: Sequence[SkillMiddleware] | None = None,
    advertise_all: bool = False,
    streaming_responses: bool = False,
    stateless_http: bool = False,
    session_idle_timeout: float | None = 1800.0,
    max_active_sessions: int | None = None,
    validation: ValidationHookConfig | None = DEFAULT_VALIDATION,
    pre_validation_hooks: PreValidationHooks | None = None,
    response_enhancer: ResponseEnhancer | None = None,
    allowed_hosts: Sequence[str] | None = None,
    allowed_origins: Sequence[str] | None = None,
    enable_dns_rebinding_protection: bool | None = None,
) -> Any:
    """Create a FastMCP server from an ADCP handler without starting it.

    Use this when you need to customize the server before running it,
    or when you need to add extra non-ADCP tools.

    Args:
        handler: An ADCPHandler subclass instance.
        name: Server name.
        port: Port to listen on.
        instructions: Optional system instructions.
        include_test_controller: When False (default), skip registering
            ``comply_test_controller`` as a handler tool. Sellers who want
            compliance-testing support should pass ``test_controller=`` to
            :func:`serve`, which registers a store-backed implementation
            via :func:`register_test_controller` and sets this flag
            implicitly. Registering the handler stub unconditionally would
            advertise a tool the seller didn't opt into.
        context_factory: Optional callable invoked per tool call to build
            a :class:`ToolContext` from the incoming :class:`RequestMetadata`.
            **Wiring this is how the server-side idempotency middleware
            gets the caller identity and tenant it needs for per-principal
            scoping** — a factory that returns ``caller_identity=None``
            effectively disables idempotency dedup. Sellers wiring their
            own HTTP auth middleware pass this to inject the authenticated
            principal into ``ToolContext.caller_identity``. See
            :data:`ContextFactory` for the recommended contextvars
            pattern. When ``None``, handlers receive a bare
            ``ToolContext()`` (no caller identity, no tenant).
        middleware: Optional sequence of :data:`SkillMiddleware` callables
            wrapping every tool dispatch. Symmetric with A2A's
            ``create_a2a_server(middleware=...)`` — the same list works
            on both transports. Use for audit logging, rate limiting,
            tracing, activity-feed hooks. See :data:`SkillMiddleware`
            for signature and composition semantics.
        advertise_all: When True, advertise every tool the handler type
            supports — even those whose method is still the SDK's
            ``not_supported`` default. Defaults to ``False``, which
            shrinks ``tools/list`` to only the tools the handler
            actually implements (subclass overrode the method). See
            :func:`~adcp.server.get_tools_for_handler` for semantics;
            use ``True`` for spec-compliance storyboards or when you
            deliberately want to expose a ``not_supported`` tool.
        host: Network interface to bind to. Defaults to the ``ADCP_HOST``
            environment variable, then ``"0.0.0.0"`` (all interfaces).
            Use ``"127.0.0.1"`` for local-only development.
        streaming_responses: When ``False`` (default), the streamable-http
            transport returns one ``application/json`` response per
            request — the right shape for AdCP tools today (none of which
            emit progress events). The FastMCP SSE-internal streaming
            default also has an upstream bug that drops the ASGI response
            without completing, blocking the storyboard runner. Set to
            ``True`` only if your tools genuinely emit progress
            notifications and your clients consume the SSE stream.
        stateless_http: When ``False`` (default), MCP keeps a
            per-client session task alive across requests so subsequent
            ``tools/call`` posts skip the per-request transport-
            construction tax — meaningfully faster for chatty clients,
            and the only mode where ``StreamableHTTPSessionManager``'s
            idle-reap path actually runs. (Stateless mode in upstream
            MCP holds GET-SSE streams open with no idle eviction —
            connections accumulate.) The SDK threads the originating
            Starlette ``Request`` into
            ``RequestMetadata.request_context``; the bundled
            :func:`~adcp.server.auth_context_factory` reads auth off
            ``request.state`` and works in both stateless and stateful.
            Custom factories using :mod:`contextvars` set in ASGI
            middleware should migrate — those vars do NOT propagate
            from the HTTP request task to the stateful session's
            dispatch task. Multi-replica stateful deployments need
            sticky load balancing on ``Mcp-Session-Id``; set
            ``stateless_http=True`` only when affinity isn't possible.
            Do not memoize per-call state on ``mcp.Context`` or
            session-manager-scoped objects in stateful mode — that
            smears identity across calls.
        session_idle_timeout: Idle reap deadline (seconds) for stateful
            sessions. Each request pushes the deadline forward; idle
            sessions are terminated and their per-session state freed.
            Defaults to 1800 (30 minutes); set to ``None`` to disable
            reaping. Ignored when ``stateless_http=True``. Required
            because without it
            ``StreamableHTTPSessionManager._server_instances`` grows
            without bound for clients that disconnect without DELETE.
        max_active_sessions: Optional cap for active stateful MCP
            sessions. When the cap is reached, new session-creating
            requests are rejected with HTTP 429; requests that carry an
            existing ``Mcp-Session-Id`` continue. Set this on public or
            service-to-service sellers that need a hard ceiling against
            clients opening one session per operation. Ignored when
            ``stateless_http=True``.
        response_enhancer: Optional server-wide
            :data:`~adcp.server.ResponseEnhancer` applied to successes and
            raised-error responses after the context echo and, for
            successes, before schema validation. See
            :data:`~adcp.server.ResponseEnhancer` for the exact coverage
            (including two non-enhanced paths), supported arities, and
            failure / idempotency semantics.

    Returns:
        A configured FastMCP server instance. Call ``mcp.run()`` to start,
        or ``mcp.streamable_http_app()`` to get the Starlette ASGI app for
        mounting behind a reverse proxy / adding HTTP middleware.

    Authentication:
        The SDK does not enforce authentication itself. Two integration
        patterns work:

        1. **Reverse-proxy auth** (simplest): the proxy (nginx, Caddy,
           Envoy) validates credentials and forwards only authenticated
           requests. The SDK trusts the proxy's decision.

        2. **In-process HTTP middleware**: call
           ``mcp.streamable_http_app()`` to get the Starlette app, then
           ``app.add_middleware(YourAuthMiddleware)``. The middleware
           extracts auth state per request (token, tenant, principal)
           into ContextVars; ``context_factory`` reads those to build a
           typed ``ToolContext``. Tools in
           :data:`adcp.server.DISCOVERY_TOOLS` (``get_adcp_capabilities``)
           should bypass auth per AdCP spec. See
           ``examples/mcp_with_auth_middleware.py`` and
           ``docs/handler-authoring.md``.

    Example (basic):
        >>> mcp = create_mcp_server(MyAgent(), name="my-agent")
        >>> mcp.run(transport="streamable-http")

    Example (custom auth + typed context via contextvars):
        >>> from contextvars import ContextVar
        >>> from adcp.server import RequestMetadata, ToolContext, create_mcp_server
        >>>
        >>> _principal: ContextVar[str | None] = ContextVar("p", default=None)
        >>> _tenant: ContextVar[str | None] = ContextVar("t", default=None)
        >>>
        >>> def build_context(meta: RequestMetadata) -> ToolContext:
        ...     return ToolContext(
        ...         caller_identity=_principal.get(),
        ...         tenant_id=_tenant.get(),
        ...     )
        >>>
        >>> mcp = create_mcp_server(
        ...     MyAgent(), name="my-agent", context_factory=build_context
        ... )
        >>> app = mcp.streamable_http_app()
        >>> app.add_middleware(MyAuthMiddleware)  # sets the ContextVars
        >>> # run via uvicorn
    """
    from mcp.server.fastmcp import FastMCP

    resolved_port = port or int(os.environ.get("PORT", "3001"))
    resolved_host = host if host is not None else (os.environ.get("ADCP_HOST") or "0.0.0.0")
    mcp = FastMCP(name, instructions=instructions, port=resolved_port)
    mcp.settings.host = resolved_host
    if not streaming_responses:
        # FastMCP's SSE-internal streaming default has an upstream bug
        # that drops the ASGI response without completing; AdCP tools
        # return one complete envelope per request anyway, so JSON
        # response mode is both safer and semantically correct.
        mcp.settings.json_response = True
    mcp.settings.stateless_http = stateless_http
    # FastMCP's TransportSecurityMiddleware enforces DNS-rebinding
    # protection: the default ``allowed_hosts`` accepts only loopback
    # patterns (``127.0.0.1:*``, ``localhost:*``, ``[::1]:*``). Adopters
    # serving multi-tenant subdomain hosts (``acme.example.com``,
    # ``acme.localhost``) extend the list or the transport returns
    # ``421 Misdirected Request`` and MCP discovery fails. Adopters
    # whose outer ASGI middleware already validates hosts against a
    # tenant table (e.g. :class:`SubdomainTenantMiddleware`) can set
    # ``enable_dns_rebinding_protection=False`` so the MCP-layer check
    # doesn't duplicate the upstream validation.
    #
    # ``_expand_allowed_hosts`` synthesizes the ``host:*`` sibling for
    # any bare host (no ``:``) so adopters who pass ``acme.localhost``
    # also cover requests on ``acme.localhost:3001``. Mirrors the port
    # stripping :class:`InMemorySubdomainTenantRouter` does at lookup
    # time so the two surfaces stay symmetric.
    if (
        enable_dns_rebinding_protection is not None
        or allowed_hosts is not None
        or allowed_origins is not None
    ):
        from mcp.server.transport_security import TransportSecuritySettings

        if mcp.settings.transport_security is None:
            mcp.settings.transport_security = TransportSecuritySettings()
        ts = mcp.settings.transport_security
        if enable_dns_rebinding_protection is not None:
            ts.enable_dns_rebinding_protection = enable_dns_rebinding_protection
        if allowed_hosts:
            ts.allowed_hosts = [
                *ts.allowed_hosts,
                *_expand_allowed_hosts(allowed_hosts),
            ]
        if allowed_origins:
            ts.allowed_origins = [*ts.allowed_origins, *allowed_origins]
    _register_handler_tools(
        mcp,
        handler,
        include_test_controller=include_test_controller,
        context_factory=context_factory,
        middleware=middleware,
        advertise_all=advertise_all,
        validation=validation,
        pre_validation_hooks=pre_validation_hooks,
        response_enhancer=response_enhancer,
    )
    # Pre-create the StreamableHTTPSessionManager so we can pass
    # ``session_idle_timeout`` and ADCP's session safety knobs —
    # FastMCP's settings don't expose these as of
    # mcp 1.27.x. ``streamable_http_app()`` lazy-creates the manager only
    # if ``_session_manager`` is ``None``, so populating it here is the
    # extension point. Reaches into FastMCP private attrs ``_mcp_server``,
    # ``_event_store``, ``_retry_interval`` to mirror upstream's own
    # constructor call — guarded by the ``mcp<2.0`` pin since v2 may
    # rename these.
    if session_idle_timeout is not None and session_idle_timeout <= 0:
        raise ValueError(
            f"session_idle_timeout must be positive (got {session_idle_timeout!r}); "
            "set None to disable reaping."
        )
    # Suppress the timeout in stateless mode — upstream raises
    # ``RuntimeError`` if both are set. Silent because ``stateless_http=True,
    # session_idle_timeout=1800.0`` is the default combination and would
    # warn on every server boot otherwise. Adopters who explicitly want a
    # timeout should set ``stateless_http=False``.
    idle_timeout = None if mcp.settings.stateless_http else session_idle_timeout
    mcp._session_manager = ADCPStreamableHTTPSessionManager(
        app=mcp._mcp_server,
        event_store=mcp._event_store,
        retry_interval=mcp._retry_interval,
        json_response=mcp.settings.json_response,
        stateless=mcp.settings.stateless_http,
        security_settings=mcp.settings.transport_security,
        session_idle_timeout=idle_timeout,
        max_active_sessions=max_active_sessions,
    )
    return mcp

Create a FastMCP server from an ADCP handler without starting it.

Use this when you need to customize the server before running it, or when you need to add extra non-ADCP tools.

Args

handler
An ADCPHandler subclass instance.
name
Server name.
port
Port to listen on.
instructions
Optional system instructions.
include_test_controller
When False (default), skip registering comply_test_controller as a handler tool. Sellers who want compliance-testing support should pass test_controller= to :func:serve(), which registers a store-backed implementation via :func:register_test_controller() and sets this flag implicitly. Registering the handler stub unconditionally would advertise a tool the seller didn't opt into.
context_factory
Optional callable invoked per tool call to build a :class:ToolContext from the incoming :class:RequestMetadata. Wiring this is how the server-side idempotency middleware gets the caller identity and tenant it needs for per-principal scoping — a factory that returns caller_identity=None effectively disables idempotency dedup. Sellers wiring their own HTTP auth middleware pass this to inject the authenticated principal into ToolContext.caller_identity. See :data:ContextFactory for the recommended contextvars pattern. When None, handlers receive a bare ToolContext (no caller identity, no tenant).
middleware
Optional sequence of :data:SkillMiddleware callables wrapping every tool dispatch. Symmetric with A2A's create_a2a_server(middleware=...) — the same list works on both transports. Use for audit logging, rate limiting, tracing, activity-feed hooks. See :data:SkillMiddleware for signature and composition semantics.
advertise_all
When True, advertise every tool the handler type supports — even those whose method is still the SDK's not_supported() default. Defaults to False, which shrinks tools/list to only the tools the handler actually implements (subclass overrode the method). See :func:~adcp.server.get_tools_for_handler for semantics; use True for spec-compliance storyboards or when you deliberately want to expose a not_supported() tool.
host
Network interface to bind to. Defaults to the ADCP_HOST environment variable, then "0.0.0.0" (all interfaces). Use "127.0.0.1" for local-only development.
streaming_responses
When False (default), the streamable-http transport returns one application/json response per request — the right shape for AdCP tools today (none of which emit progress events). The FastMCP SSE-internal streaming default also has an upstream bug that drops the ASGI response without completing, blocking the storyboard runner. Set to True only if your tools genuinely emit progress notifications and your clients consume the SSE stream.
stateless_http
When False (default), MCP keeps a per-client session task alive across requests so subsequent tools/call posts skip the per-request transport- construction tax — meaningfully faster for chatty clients, and the only mode where StreamableHTTPSessionManager's idle-reap path actually runs. (Stateless mode in upstream MCP holds GET-SSE streams open with no idle eviction — connections accumulate.) The SDK threads the originating Starlette Request into RequestMetadata.request_context; the bundled :func:~adcp.server.auth_context_factory reads auth off request.state and works in both stateless and stateful. Custom factories using :mod:contextvars set in ASGI middleware should migrate — those vars do NOT propagate from the HTTP request task to the stateful session's dispatch task. Multi-replica stateful deployments need sticky load balancing on Mcp-Session-Id; set stateless_http=True only when affinity isn't possible. Do not memoize per-call state on mcp.Context or session-manager-scoped objects in stateful mode — that smears identity across calls.
session_idle_timeout
Idle reap deadline (seconds) for stateful sessions. Each request pushes the deadline forward; idle sessions are terminated and their per-session state freed. Defaults to 1800 (30 minutes); set to None to disable reaping. Ignored when stateless_http=True. Required because without it StreamableHTTPSessionManager._server_instances grows without bound for clients that disconnect without DELETE.
max_active_sessions
Optional cap for active stateful MCP sessions. When the cap is reached, new session-creating requests are rejected with HTTP 429; requests that carry an existing Mcp-Session-Id continue. Set this on public or service-to-service sellers that need a hard ceiling against clients opening one session per operation. Ignored when stateless_http=True.
response_enhancer
Optional server-wide :data:~adcp.server.ResponseEnhancer applied to successes and raised-error responses after the context echo and, for successes, before schema validation. See :data:~adcp.server.ResponseEnhancer for the exact coverage (including two non-enhanced paths), supported arities, and failure / idempotency semantics.

Returns

A configured FastMCP server instance. Call mcp.run() to start, or mcp.streamable_http_app() to get the Starlette ASGI app for mounting behind a reverse proxy / adding HTTP middleware.

Authentication

The SDK does not enforce authentication itself. Two integration patterns work:

  1. Reverse-proxy auth (simplest): the proxy (nginx, Caddy, Envoy) validates credentials and forwards only authenticated requests. The SDK trusts the proxy's decision.

  2. In-process HTTP middleware: call mcp.streamable_http_app() to get the Starlette app, then app.add_middleware(YourAuthMiddleware). The middleware extracts auth state per request (token, tenant, principal) into ContextVars; context_factory reads those to build a typed ToolContext. Tools in :data:adcp.server.DISCOVERY_TOOLS (get_adcp_capabilities) should bypass auth per AdCP spec. See examples/mcp_with_auth_middleware.py and docs/handler-authoring.md.

Example (basic): >>> mcp = create_mcp_server(MyAgent(), name="my-agent") >>> mcp.run(transport="streamable-http")

Example (custom auth + typed context via contextvars): >>> from contextvars import ContextVar >>> from adcp.server import RequestMetadata, ToolContext, create_mcp_server >>> >>> _principal: ContextVar[str | None] = ContextVar("p", default=None) >>> _tenant: ContextVar[str | None] = ContextVar("t", default=None) >>> >>> def build_context(meta: RequestMetadata) -> ToolContext: … return ToolContext( … caller_identity=_principal.get(), … tenant_id=_tenant.get(), … ) >>> >>> mcp = create_mcp_server( … MyAgent(), name="my-agent", context_factory=build_context … ) >>> app = mcp.streamable_http_app() >>> app.add_middleware(MyAuthMiddleware) # sets the ContextVars >>> # run via uvicorn

def create_mcp_tools(handler: ADCPHandler[Any],
*,
advertise_all: bool = False,
validation: ValidationHookConfig | None = None,
pre_validation_hooks: PreValidationHooks | None = None,
response_enhancer: ResponseEnhancer | None = None) ‑> MCPToolSet
Expand source code
def create_mcp_tools(
    handler: ADCPHandler[Any],
    *,
    advertise_all: bool = False,
    validation: ValidationHookConfig | None = None,
    pre_validation_hooks: PreValidationHooks | None = None,
    response_enhancer: ResponseEnhancer | None = None,
) -> MCPToolSet:
    """Create MCP tools from an ADCP handler.

    This is the main entry point for MCP server integration.

    Example with mcp library:
        from mcp.server import Server
        from adcp.server import ContentStandardsHandler, create_mcp_tools

        class MyHandler(ContentStandardsHandler):
            # ... implement methods

        handler = MyHandler()
        tools = create_mcp_tools(handler)

        server = Server("my-content-agent")

        @server.list_tools()
        async def list_tools():
            return tools.tool_definitions

        @server.call_tool()
        async def call_tool(name: str, arguments: dict):
            return await tools.call_tool(name, arguments)

    Args:
        handler: ADCP handler instance.
        advertise_all: When True, advertise every tool the handler type
            supports — even those whose method is still the SDK's
            ``not_supported`` default. See :func:`get_tools_for_handler`.
        validation: Opt-in schema validation config. When supplied,
            every tool caller validates requests and responses against
            the bundled AdCP JSON schemas. See
            :func:`create_tool_caller` for mode semantics.
        pre_validation_hooks: Optional dict mapping tool name to a
            ``(tool_name, args) -> args`` callable or ordered sequence.
            Applied before schema + Pydantic validation. See
            :func:`create_tool_caller`.
        response_enhancer: Optional server-wide :data:`ResponseEnhancer`
            applied to every successful response. See
            :func:`create_tool_caller`.

    Returns:
        MCPToolSet with tool definitions and handlers.
    """
    return MCPToolSet(
        handler,
        advertise_all=advertise_all,
        validation=validation,
        pre_validation_hooks=pre_validation_hooks,
        response_enhancer=response_enhancer,
    )

Create MCP tools from an ADCP handler.

This is the main entry point for MCP server integration.

Example with mcp library: from mcp.server import Server from adcp.server import ContentStandardsHandler, create_mcp_tools

class MyHandler(ContentStandardsHandler):
    # ... implement methods

handler = MyHandler()
tools = create_mcp_tools(handler)

server = Server("my-content-agent")

@server.list_tools()
async def list_tools():
    return tools.tool_definitions

@server.call_tool()
async def call_tool(name: str, arguments: dict):
    return await tools.call_tool(name, arguments)

Args

handler
ADCP handler instance.
advertise_all
When True, advertise every tool the handler type supports — even those whose method is still the SDK's not_supported() default. See :func:get_tools_for_handler().
validation
Opt-in schema validation config. When supplied, every tool caller validates requests and responses against the bundled AdCP JSON schemas. See :func:create_tool_caller for mode semantics.
pre_validation_hooks
Optional dict mapping tool name to a (tool_name, args) -> args callable or ordered sequence. Applied before schema + Pydantic validation. See :func:create_tool_caller.
response_enhancer
Optional server-wide :data:ResponseEnhancer applied to every successful response. See :func:create_tool_caller.

Returns

MCPToolSet with tool definitions and handlers.

def creative_formats_response(formats: list[Any], *, sandbox: bool = True) ‑> dict[str, typing.Any]
Expand source code
def creative_formats_response(
    formats: list[Any],
    *,
    sandbox: bool = True,
) -> dict[str, Any]:
    """Build a list_creative_formats response.

    Each format should include: format_id ({agent_url, id}), name.
    Matches ListCreativeFormatsResponse schema.
    """
    return {
        "formats": _serialize(formats),
        "sandbox": sandbox,
    }

Build a list_creative_formats response.

Each format should include: format_id ({agent_url, id}), name. Matches ListCreativeFormatsResponse schema.

def current_tenant() ‑> Tenant | None
Expand source code
def current_tenant() -> Tenant | None:
    """Return the resolved :class:`Tenant` for the current request.

    Returns ``None`` outside the middleware's request scope, or
    when the request isn't tenant-routed (e.g., health-check paths
    excluded from the middleware).

    Adopter ``context_factory`` callbacks read this and write the
    tenant id onto :attr:`ToolContext.tenant_id` so downstream
    framework primitives (idempotency middleware, AccountStore,
    BuyerAgentRegistry) scope by tenant.
    """
    return _current_tenant.get()

Return the resolved :class:Tenant for the current request.

Returns None outside the middleware's request scope, or when the request isn't tenant-routed (e.g., health-check paths excluded from the middleware).

Adopter context_factory callbacks read this and write the tenant id onto :attr:ToolContext.tenant_id so downstream framework primitives (idempotency middleware, AccountStore, BuyerAgentRegistry) scope by tenant.

def delivery_response(media_buy_deliveries: list[dict[str, Any]],
*,
reporting_period: dict[str, str] | None = None,
currency: str = 'USD',
sandbox: bool = True) ‑> dict[str, typing.Any]
Expand source code
def delivery_response(
    media_buy_deliveries: list[dict[str, Any]],
    *,
    reporting_period: dict[str, str] | None = None,
    currency: str = "USD",
    sandbox: bool = True,
) -> dict[str, Any]:
    """Build a get_media_buy_delivery response.

    Each media_buy_delivery should include:
        media_buy_id, status, totals (impressions, spend, etc.), by_package.

    Matches GetMediaBuyDeliveryResponse schema.

    Args:
        media_buy_deliveries: Array of delivery data per media buy.
        reporting_period: {"start": ISO timestamp, "end": ISO timestamp}.
            Defaults to current timestamp for both.
        currency: ISO 4217 currency code.
        sandbox: Whether this is simulated data.
    """
    now = _rfc3339_now()
    return {
        "reporting_period": reporting_period or {"start": now, "end": now},
        "media_buy_deliveries": media_buy_deliveries,
        "currency": currency,
        "sandbox": sandbox,
    }

Build a get_media_buy_delivery response.

Each media_buy_delivery should include: media_buy_id, status, totals (impressions, spend, etc.), by_package.

Matches GetMediaBuyDeliveryResponse schema.

Args

media_buy_deliveries
Array of delivery data per media buy.
reporting_period
{"start": ISO timestamp, "end": ISO timestamp}. Defaults to current timestamp for both.
currency
ISO 4217 currency code.
sandbox
Whether this is simulated data.
def error_response(code: str, message: str) ‑> dict[str, typing.Any]
Expand source code
def error_response(code: str, message: str) -> dict[str, Any]:
    """Build a single AdCP error object (not a full error response).

    .. deprecated::
        Use ``adcp_error()`` from ``adcp.server.helpers`` instead.
        It returns a properly wrapped ``{"errors": [...]}`` response with
        auto-recovery classification. This function returns an unwrapped
        single error dict ``{"code": ..., "message": ...}`` which is not
        a valid ADCP error response on its own.
    """
    return {"code": code, "message": message}

Build a single AdCP error object (not a full error response).

Deprecated

Use adcp_error() from adcp.server.helpers instead. It returns a properly wrapped {"errors": [...]} response with auto-recovery classification. This function returns an unwrapped single error dict {"code": ..., "message": ...} which is not a valid ADCP error response on its own.

def get_mcp_session_stats(mcp_or_manager: Any) ‑> MCPSessionStats
Expand source code
def get_mcp_session_stats(mcp_or_manager: Any) -> MCPSessionStats:
    """Return session stats for a FastMCP server or session manager.

    The helper accepts either the object returned by
    :func:`adcp.server.create_mcp_server` or its ``_session_manager``.
    For non-ADCP managers, only the fields available from upstream MCP
    internals are populated.
    """
    manager = getattr(mcp_or_manager, "_session_manager", mcp_or_manager)
    if hasattr(manager, "session_stats"):
        stats = manager.session_stats()
        if isinstance(stats, MCPSessionStats):
            return stats

    server_instances = getattr(manager, "_server_instances", {}) or {}
    return MCPSessionStats(
        active_sessions=len(server_instances),
        max_active_sessions=getattr(manager, "max_active_sessions", None),
        total_sessions_created=0,
        sessions_created_last_60s=0,
        stateless=bool(getattr(manager, "stateless", False)),
        session_idle_timeout=getattr(manager, "session_idle_timeout", None),
        session_age_seconds=(),
        session_idle_seconds=(),
    )

Return session stats for a FastMCP server or session manager.

The helper accepts either the object returned by :func:create_mcp_server() or its _session_manager. For non-ADCP managers, only the fields available from upstream MCP internals are populated.

def get_tools_for_handler(handler: ADCPHandler[Any] | type[ADCPHandler[Any]],
*,
advertise_all: bool = False) ‑> list[dict[str, typing.Any]]
Expand source code
def get_tools_for_handler(
    handler: ADCPHandler[Any] | type[ADCPHandler[Any]],
    *,
    advertise_all: bool = False,
) -> list[dict[str, Any]]:
    """Return tool definitions the handler will actually answer.

    Walks the MRO to find the matching handler base class, so subclasses
    (e.g. ``MyGovernanceAgent(GovernanceHandler)``) get the correct tool
    set. ADCPHandler gets all tools. Unknown handlers get only protocol
    discovery (minimum privilege).

    By default, tools whose handler method is still the SDK's
    ``not_supported`` default (the subclass never overrode it) are
    filtered out — there's no point advertising a tool that answers
    every call with ``NOT_SUPPORTED``. This keeps ``tools/list`` small
    and protects agent clients from chasing non-functional tool surface.

    Always-advertised tools:
    - :data:`_PROTOCOL_TOOLS` (``get_adcp_capabilities``) — per-spec
      handshake requirement.
    - :data:`DISCOVERY_TOOLS` — auth-optional discovery tools the spec
      requires agents to expose.

    Escape hatch: pass ``advertise_all=True`` to restore the pre-#220
    behavior and advertise every tool in the handler-type's allowed
    set regardless of override state. Useful for spec-compliance
    storyboard tests and for agents that deliberately want to expose a
    ``not_supported`` tool (e.g. to signal "we know about X but don't
    implement it yet").

    Args:
        handler: The handler instance or class.
        advertise_all: When True, skip the override-based filter and
            advertise every tool allowed for the handler type.

    Returns:
        Filtered list of tool definitions.
    """
    _ensure_pydantic_schemas_applied()
    cls = handler if isinstance(handler, type) else type(handler)
    instance = handler if not isinstance(handler, type) else None

    candidates: list[dict[str, Any]] = []
    for base in cls.__mro__:
        if base.__name__ in _HANDLER_TOOLS:
            allowed = _HANDLER_TOOLS[base.__name__] | _PROTOCOL_TOOLS
            candidates = [tool for tool in ADCP_TOOL_DEFINITIONS if tool["name"] in allowed]
            break
    else:
        candidates = [tool for tool in ADCP_TOOL_DEFINITIONS if tool["name"] in _PROTOCOL_TOOLS]

    # Per-instance specialism filter (Emma cross-cutting P1). When the
    # handler instance exposes ``advertised_tools_for_instance``, intersect
    # the candidate universe with the per-instance set BEFORE the
    # override-detection filter. This trims tools whose Protocol family
    # the platform didn't claim (sales-only adopter no longer advertises
    # ``acquire_rights``, ``build_creative``, etc.). Falls back to the
    # class-level universe when:
    #
    # * The handler is being inspected by class (no instance) — class-level
    #   advertisement preserves backwards compat for static introspection.
    # * The hook returns an empty set (adopter piloting a novel specialism
    #   slug not in :data:`SPECIALISM_TO_ADVERTISED_TOOLS`); muting the
    #   handler would be a worse foot-gun than over-advertising.
    if instance is not None and hasattr(instance, "advertised_tools_for_instance"):
        try:
            per_instance_set = instance.advertised_tools_for_instance()
        except Exception:
            # Defensive: never let an instance hook crash tools/list.
            per_instance_set = None
        if per_instance_set:
            always_on = _PROTOCOL_TOOLS | DISCOVERY_TOOLS
            candidates = [
                tool
                for tool in candidates
                if tool["name"] in always_on or tool["name"] in per_instance_set
            ]

    if advertise_all:
        return candidates

    always_on = _PROTOCOL_TOOLS | DISCOVERY_TOOLS
    return [
        tool
        for tool in candidates
        if tool["name"] in always_on or _is_method_overridden(cls, tool["name"])
    ]

Return tool definitions the handler will actually answer.

Walks the MRO to find the matching handler base class, so subclasses (e.g. MyGovernanceAgent(GovernanceHandler)) get the correct tool set. ADCPHandler gets all tools. Unknown handlers get only protocol discovery (minimum privilege).

By default, tools whose handler method is still the SDK's not_supported() default (the subclass never overrode it) are filtered out — there's no point advertising a tool that answers every call with NOT_SUPPORTED. This keeps tools/list small and protects agent clients from chasing non-functional tool surface.

Always-advertised tools: - :data:_PROTOCOL_TOOLS (get_adcp_capabilities) — per-spec handshake requirement. - :data:DISCOVERY_TOOLS — auth-optional discovery tools the spec requires agents to expose.

Escape hatch: pass advertise_all=True to restore the pre-#220 behavior and advertise every tool in the handler-type's allowed set regardless of override state. Useful for spec-compliance storyboard tests and for agents that deliberately want to expose a not_supported() tool (e.g. to signal "we know about X but don't implement it yet").

Args

handler
The handler instance or class.
advertise_all
When True, skip the override-based filter and advertise every tool allowed for the handler type.

Returns

Filtered list of tool definitions.

def inject_context(params: dict[str, Any], response: dict[str, Any], *, max_size: int = 65536) ‑> dict[str, typing.Any]
Expand source code
def inject_context(
    params: dict[str, Any],
    response: dict[str, Any],
    *,
    max_size: int = _MAX_CONTEXT_SIZE,
) -> dict[str, Any]:
    """Auto-inject context passthrough from request into response.

    ADCP requires that if a request contains a ``context`` field,
    the response must echo it back unchanged. A size limit prevents
    resource amplification from oversized context payloads.

    The context field is opaque and may contain attacker-controlled
    data -- do not interpret or display its contents.
    """
    if "context" in params and "context" not in response:
        import json

        ctx = params["context"]
        if len(json.dumps(ctx, default=str)) <= max_size:
            response["context"] = ctx
    return response

Auto-inject context passthrough from request into response.

ADCP requires that if a request contains a context field, the response must echo it back unchanged. A size limit prevents resource amplification from oversized context payloads.

The context field is opaque and may contain attacker-controlled data – do not interpret or display its contents.

def is_terminal_status(status: str) ‑> bool
Expand source code
def is_terminal_status(status: str) -> bool:
    """Check if a media buy status is terminal (no further actions)."""
    return status in ("completed", "rejected", "canceled")

Check if a media buy status is terminal (no further actions).

def list_creatives_response(creatives: list[Any],
*,
pagination: dict[str, Any] | None = None,
sandbox: bool = True) ‑> dict[str, typing.Any]
Expand source code
def list_creatives_response(
    creatives: list[Any],
    *,
    pagination: dict[str, Any] | None = None,
    sandbox: bool = True,
) -> dict[str, Any]:
    """Build a list_creatives response.

    Each creative should include: creative_id, name, format_id, status.
    Matches ListCreativesResponse schema.

    Timestamp defaults: every Creative item in the spec requires
    ``created_date`` and ``updated_date`` (ISO 8601 UTC). For any dict
    item that omits either field, this helper fills it with the current
    UTC timestamp via :func:`_rfc3339_now` (Zulu suffix, RFC 3339).
    Both fields default to the same value when neither is provided,
    which matches the intuitive meaning for a freshly-listed item.
    Explicit caller-provided values are always preserved. Pydantic
    model items are passed through ``_serialize`` unchanged — callers
    using typed Creative models should set timestamps on the model.
    """
    now = _rfc3339_now()
    filled: list[Any] = []
    for item in creatives:
        if isinstance(item, dict):
            has_created = "created_date" in item and item["created_date"] is not None
            has_updated = "updated_date" in item and item["updated_date"] is not None
            if has_created and has_updated:
                filled.append(item)
                continue
            patched = dict(item)
            if not has_created:
                patched["created_date"] = now
            if not has_updated:
                patched["updated_date"] = now
            filled.append(patched)
        else:
            filled.append(item)

    count = len(filled)
    return {
        "creatives": _serialize(filled),
        "pagination": pagination or {"total_count": count, "has_more": False},
        "query_summary": {"total_results": count, "total_matching": count, "returned": count},
        "sandbox": sandbox,
    }

Build a list_creatives response.

Each creative should include: creative_id, name, format_id, status. Matches ListCreativesResponse schema.

Timestamp defaults: every Creative item in the spec requires created_date and updated_date (ISO 8601 UTC). For any dict item that omits either field, this helper fills it with the current UTC timestamp via :func:_rfc3339_now (Zulu suffix, RFC 3339). Both fields default to the same value when neither is provided, which matches the intuitive meaning for a freshly-listed item. Explicit caller-provided values are always preserved. Pydantic model items are passed through _serialize unchanged — callers using typed Creative models should set timestamps on the model.

def log_event_response(events_received: int, events_processed: int, *, sandbox: bool = True) ‑> dict[str, typing.Any]
Expand source code
def log_event_response(
    events_received: int,
    events_processed: int,
    *,
    sandbox: bool = True,
) -> dict[str, Any]:
    """Build a log_event success response.

    Matches LogEventResponse1 (success) schema.
    """
    return {
        "events_received": events_received,
        "events_processed": events_processed,
        "sandbox": sandbox,
    }

Build a log_event success response.

Matches LogEventResponse1 (success) schema.

def make_discovery_route(*,
name: str,
transports: list[Transport],
base_url: str,
description: str | None = None,
specialisms: list[str] | None = None) ‑> starlette.routing.Route
Expand source code
def make_discovery_route(
    *,
    name: str,
    transports: list[Transport],
    base_url: str,
    description: str | None = None,
    specialisms: list[str] | None = None,
) -> Route:
    """Build a Starlette :class:`Route` serving the discovery manifest.

    The route is GET-only — POST / PUT / etc. fall through to
    Starlette's default 405 handler, which is the correct behavior for
    a read-only, unauthenticated discovery document.

    The manifest is rebuilt per request so ``last_updated`` reflects
    the current time. The build is cheap (a few hundred bytes of JSON),
    well below the noise floor of any production traffic.
    """

    async def _handler(_request: Request) -> JSONResponse:
        manifest = build_manifest(
            name=name,
            transports=transports,
            base_url=base_url,
            description=description,
            specialisms=specialisms,
        )
        return JSONResponse(manifest)

    return Route(DISCOVERY_PATH, _handler, methods=["GET"])

Build a Starlette :class:Route serving the discovery manifest.

The route is GET-only — POST / PUT / etc. fall through to Starlette's default 405 handler, which is the correct behavior for a read-only, unauthenticated discovery document.

The manifest is rebuilt per request so last_updated reflects the current time. The build is cheap (a few hundred bytes of JSON), well below the noise floor of any production traffic.

def media_buy_error_response(errors: list[dict[str, str]]) ‑> dict[str, typing.Any]
Expand source code
def media_buy_error_response(errors: list[dict[str, str]]) -> dict[str, Any]:
    """Build a create_media_buy error response.

    Each error dict: {"code": "...", "message": "..."}.
    Matches CreateMediaBuyResponse2 (error) schema.
    """
    return {"errors": errors}

Build a create_media_buy error response.

Each error dict: {"code": "…", "message": "…"}. Matches CreateMediaBuyResponse2 (error) schema.

def media_buy_response(media_buy_id: str,
packages: list[Any],
*,
buyer_ref: str | None = None,
status: str | None = None,
valid_actions: list[str] | None = None,
revision: int | None = None,
confirmed_at: str | None | object = <object object>,
adcp_version: str | None = None,
sandbox: bool = True) ‑> dict[str, typing.Any]
Expand source code
def media_buy_response(
    media_buy_id: str,
    packages: list[Any],
    *,
    buyer_ref: str | None = None,
    status: str | None = None,
    valid_actions: list[str] | None = None,
    revision: int | None = None,
    confirmed_at: str | None | object = _UNSET,
    adcp_version: str | None = None,
    sandbox: bool = True,
) -> dict[str, Any]:
    """Build a create_media_buy success response.

    Each package should include: package_id, product_id, pricing_option_id, budget.
    Matches CreateMediaBuyResponse1 (success) schema.

    Auto-populates valid_actions from status if not provided.
    Auto-sets revision to 1 and confirmed_at to now if omitted. Pass
    ``confirmed_at=None`` explicitly only on AdCP 3.1+ shapes when the
    commitment timestamp is unavailable; pre-confirmation workflows such as
    IO signing or governance review should use the submitted task envelope
    rather than a synchronous media-buy success. Treat
    ``revision`` as the optimistic-concurrency token clients pass back on
    ``update_media_buy``. Treat ``confirmed_at`` as the seller commitment
    timestamp: once a buy is confirmed, pass the original value when rebuilding
    later create/get-style media-buy objects rather than stamping the current
    lifecycle transition time.
    ``confirmed_at=None`` is only schema-valid for AdCP 3.1+ response shapes;
    when ``adcp_version="3.0"`` is requested this helper raises ``ValueError``
    rather than emitting 3.0-invalid ``null``.
    Pass ``adcp_version="3.0"`` for the 3.0 top-level lifecycle status
    shape, or an exact 3.1+ supported version for the task-envelope shape
    (``status="completed"`` plus ``media_buy_status``). When omitted, the
    dispatcher projects by the buyer's requested version.
    """
    if adcp_version is not None and not _is_adcp_31_or_newer(adcp_version) and confirmed_at is None:
        raise ValueError("confirmed_at=None is not valid for AdCP 3.0 media_buy_response")

    resp: dict[str, Any] = {
        "media_buy_id": media_buy_id,
        "packages": _serialize(packages),
        "revision": revision if revision is not None else 1,
        "sandbox": sandbox,
    }
    if confirmed_at is _UNSET:
        resp["confirmed_at"] = _rfc3339_now()
    else:
        resp["confirmed_at"] = confirmed_at
    if buyer_ref is not None:
        resp["buyer_ref"] = buyer_ref
    if status is not None:
        if adcp_version is None or _is_adcp_31_or_newer(adcp_version):
            resp["media_buy_status"] = status
        else:
            resp["status"] = status
        if valid_actions is None:
            resp["valid_actions"] = valid_actions_for_status(status)
        else:
            resp["valid_actions"] = valid_actions
    elif valid_actions is not None:
        resp["valid_actions"] = valid_actions
    if adcp_version is not None and _is_adcp_31_or_newer(adcp_version):
        resp["status"] = "completed"
    return resp

Build a create_media_buy success response.

Each package should include: package_id, product_id, pricing_option_id, budget. Matches CreateMediaBuyResponse1 (success) schema.

Auto-populates valid_actions from status if not provided. Auto-sets revision to 1 and confirmed_at to now if omitted. Pass confirmed_at=None explicitly only on AdCP 3.1+ shapes when the commitment timestamp is unavailable; pre-confirmation workflows such as IO signing or governance review should use the submitted task envelope rather than a synchronous media-buy success. Treat revision as the optimistic-concurrency token clients pass back on update_media_buy. Treat confirmed_at as the seller commitment timestamp: once a buy is confirmed, pass the original value when rebuilding later create/get-style media-buy objects rather than stamping the current lifecycle transition time. confirmed_at=None is only schema-valid for AdCP 3.1+ response shapes; when adcp_version="3.0" is requested this helper raises ValueError rather than emitting 3.0-invalid null. Pass adcp_version="3.0" for the 3.0 top-level lifecycle status shape, or an exact 3.1+ supported version for the task-envelope shape (status="completed" plus media_buy_status). When omitted, the dispatcher projects by the buyer's requested version.

def media_buys_response(media_buys: list[Any], *, sandbox: bool = True) ‑> dict[str, typing.Any]
Expand source code
def media_buys_response(
    media_buys: list[Any],
    *,
    sandbox: bool = True,
) -> dict[str, Any]:
    """Build a get_media_buys response.

    Each media buy should include: media_buy_id, status, currency, packages.
    Matches GetMediaBuysResponse schema.
    """
    return {
        "media_buys": _serialize(media_buys),
        "sandbox": sandbox,
    }

Build a get_media_buys response.

Each media buy should include: media_buy_id, status, currency, packages. Matches GetMediaBuysResponse schema.

def not_supported(reason: str = 'This operation is not supported by this agent') ‑> NotImplementedResponse
Expand source code
def not_supported(
    reason: str = "This operation is not supported by this agent",
) -> NotImplementedResponse:
    """Create a standard 'not supported' response.

    Use this to return from operations that your agent does not implement.

    Args:
        reason: Human-readable explanation of why the operation is not supported

    Returns:
        NotImplementedResponse with supported=False
    """
    return NotImplementedResponse(
        supported=False,
        reason=reason,
        error=Error(
            code="NOT_SUPPORTED",
            message=reason,
        ),
    )

Create a standard 'not supported' response.

Use this to return from operations that your agent does not implement.

Args

reason
Human-readable explanation of why the operation is not supported

Returns

NotImplementedResponse with supported=False

def preview_creative_response(previews: list[dict[str, Any]],
*,
expires_at: str | None = None,
sandbox: bool = True) ‑> dict[str, typing.Any]
Expand source code
def preview_creative_response(
    previews: list[dict[str, Any]],
    *,
    expires_at: str | None = None,
    sandbox: bool = True,
) -> dict[str, Any]:
    """Build a preview_creative single response.

    Each preview should include:
        preview_id, input ({format_id, name, assets}),
        renders ([{render_id, output_format, preview_url, role, dimensions}]).

    Matches PreviewCreativeResponse1 (single) schema.
    """
    return {
        "response_type": "single",
        "previews": _serialize(previews),
        "expires_at": expires_at or "2099-12-31T23:59:59Z",
        "sandbox": sandbox,
    }

Build a preview_creative single response.

Each preview should include: preview_id, input ({format_id, name, assets}), renders ([{render_id, output_format, preview_url, role, dimensions}]).

Matches PreviewCreativeResponse1 (single) schema.

def products_response(products: list[Any] | None = None,
*,
item_count: int | None = None,
proposals: list[Any] | None = None,
incomplete: list[Any] | None = None,
pagination: dict[str, Any] | None = None,
wholesale_feed_version: str | None = None,
pricing_version: str | None = None,
cache_scope: str | None = None,
unchanged: bool | None = None,
status: str = 'completed',
sandbox: bool = True) ‑> dict[str, typing.Any]
Expand source code
def products_response(
    products: list[Any] | None = None,
    *,
    item_count: int | None = None,
    proposals: list[Any] | None = None,
    incomplete: list[Any] | None = None,
    pagination: dict[str, Any] | None = None,
    wholesale_feed_version: str | None = None,
    pricing_version: str | None = None,
    cache_scope: str | None = None,
    unchanged: bool | None = None,
    status: str = "completed",
    sandbox: bool = True,
) -> dict[str, Any]:
    """Build a get_products response.

    Matches GetProductsResponse schema, including beta 3 wholesale feed
    metadata for cache/version-aware enumeration. Pass ``cache_scope``
    explicitly for spec-valid wholesale responses; the dispatcher only infers
    ``public`` for request paths without an account.
    """
    serialized = _serialize(products) if products is not None else None
    resp: dict[str, Any] = {
        "status": status,
        "sandbox": sandbox,
    }
    if serialized is not None:
        resp["products"] = serialized
    if item_count is not None:
        resp["item_count"] = item_count
    elif serialized is not None:
        resp["item_count"] = len(serialized)
    if proposals is not None:
        resp["proposals"] = _serialize(proposals)
    if incomplete is not None:
        resp["incomplete"] = _serialize(incomplete)
    if pagination is not None:
        resp["pagination"] = pagination
    if wholesale_feed_version is not None:
        resp["wholesale_feed_version"] = wholesale_feed_version
    if pricing_version is not None:
        resp["pricing_version"] = pricing_version
    if cache_scope is not None:
        resp["cache_scope"] = cache_scope
    if unchanged is not None:
        resp["unchanged"] = unchanged
    return resp

Build a get_products response.

Matches GetProductsResponse schema, including beta 3 wholesale feed metadata for cache/version-aware enumeration. Pass cache_scope explicitly for spec-valid wholesale responses; the dispatcher only infers public for request paths without an account.

def register_handler_tools(handler_name: str, tools: Iterable[str]) ‑> None
Expand source code
def register_handler_tools(handler_name: str, tools: Iterable[str]) -> None:
    """Register a handler-class-name → tool-set mapping with the framework.

    Public seam. ``get_tools_for_handler`` reads ``_HANDLER_TOOLS`` to
    filter ``tools/list`` per handler subclass; without registration, an
    ``ADCPHandler`` subclass that introduces a new specialism would fall
    through to its parent's tool set (typically ``ADCPHandler``'s
    full-spec surface), over-advertising. Codegen targets like
    ``adcp.decisioning.handler.PlatformHandler`` register here at class
    definition time via ``ADCPHandler.__init_subclass__``; hand-written
    custom bases call this directly before ``serve()``.
    Idempotent on equal input — calling twice with the same tool set
    is a no-op so module re-imports / reload-friendly test harnesses
    don't break.
    Conflicts raise. Unknown tool names raise with a closest-match
    suggestion (typo recovery for adopters working from spec memory).
    :param handler_name: The class name of the handler subclass —
        typically ``cls.__name__`` from inside ``__init_subclass__``.
    :param tools: Iterable of AdCP tool names this handler answers
        (members of ``ADCP_TOOL_DEFINITIONS``). Order doesn't matter.
    :raises ValueError: when ``handler_name`` is already registered with
        a different tool set, or when any tool name is not in the AdCP
        spec surface.
    """
    incoming = frozenset(tools)
    existing = _HANDLER_TOOLS.get(handler_name)
    if existing is not None:
        if frozenset(existing) == incoming:
            return
        raise ValueError(
            f"register_handler_tools({handler_name!r}, ...) called twice "
            f"with different tool sets. Existing: {sorted(existing)}; "
            f"incoming: {sorted(incoming)}. The framework can only hold "
            "one mapping per handler class — pick the canonical set."
        )
    unknown = incoming - _ALL_TOOL_NAMES
    if unknown:
        suggestions: list[str] = []
        for bad in sorted(unknown):
            close = difflib.get_close_matches(bad, _ALL_TOOL_NAMES, n=1)
            if close:
                suggestions.append(f"{bad!r} (did you mean {close[0]!r}?)")
            else:
                suggestions.append(repr(bad))
        raise ValueError(
            f"register_handler_tools({handler_name!r}, ...): unknown tool "
            f"name(s) {', '.join(suggestions)}. Tool names must match the "
            "AdCP spec — see ``adcp.server.mcp_tools.ADCP_TOOL_DEFINITIONS``."
        )
    _HANDLER_TOOLS[handler_name] = set(incoming)

Register a handler-class-name → tool-set mapping with the framework.

Public seam. get_tools_for_handler() reads _HANDLER_TOOLS to filter tools/list per handler subclass; without registration, an ADCPHandler subclass that introduces a new specialism would fall through to its parent's tool set (typically ADCPHandler's full-spec surface), over-advertising. Codegen targets like PlatformHandler register here at class definition time via ADCPHandler.__init_subclass__; hand-written custom bases call this directly before serve(). Idempotent on equal input — calling twice with the same tool set is a no-op so module re-imports / reload-friendly test harnesses don't break. Conflicts raise. Unknown tool names raise with a closest-match suggestion (typo recovery for adopters working from spec memory). :param handler_name: The class name of the handler subclass — typically cls.__name__ from inside __init_subclass__. :param tools: Iterable of AdCP tool names this handler answers (members of ADCP_TOOL_DEFINITIONS). Order doesn't matter. :raises ValueError: when handler_name is already registered with a different tool set, or when any tool name is not in the AdCP spec surface.

def register_test_controller(mcp: Any,
store: TestControllerStore,
*,
context_factory: ContextFactory | None = None,
account_resolver: _AccountResolver | None = None) ‑> None
Expand source code
def register_test_controller(
    mcp: Any,
    store: TestControllerStore,
    *,
    context_factory: ContextFactory | None = None,
    account_resolver: _AccountResolver | None = None,
) -> None:
    """Register the comply_test_controller tool on an MCP server.

    This is the Python equivalent of the JS SDK's registerTestController().
    It adds the comply_test_controller MCP tool backed by your TestControllerStore.

    Args:
        mcp: A FastMCP server instance.
        store: Your TestControllerStore implementation.
        context_factory: Optional ``ContextFactory`` invoked per call to
            build a :class:`ToolContext`. When set, the context is
            threaded into store methods that declare a ``context``
            keyword — which is how sellers whose test runtime reads
            request headers (``AdCPTestContext.from_headers``) combine
            header-driven mock state with the storyboard-driven
            ``comply_test_controller`` skill. Wire the same factory you
            pass to :func:`create_mcp_server` so both paths see the
            same per-request context.
        account_resolver: Async-or-sync callable that resolves a wire
            account ref to a framework :class:`Account`, OR the
            :data:`INSECURE_ALLOW_ALL` sentinel for tests that opt out
            of the gate. The comply controller applies the Phase 1
            sandbox-authority gate against the resolved account: only
            accounts with ``mode in {'sandbox', 'mock'}`` (or legacy
            ``sandbox=True``) are admitted; ``mode='live'`` is denied
            regardless of wire signals. v6 :class:`DecisioningPlatform`
            adopters get this hooked automatically by
            ``decisioning.serve``. Adopters wiring the controller
            manually pass a closure over their own account store.

            **Default fail-closed.** When ``None`` AND ``ADCP_SANDBOX``
            is unset, every comply call is denied — manually-wired
            ``ADCPHandler`` / :class:`ComplianceHandler` deployments are
            protected by default. Tests that intentionally bypass the
            gate pass ``account_resolver=INSECURE_ALLOW_ALL``; dev
            servers can set ``ADCP_SANDBOX=1`` instead.

            See ``docs/proposals/lifecycle-state-and-sandbox-authority.md``.

    Example:
        from adcp.server.test_controller import TestControllerStore, register_test_controller

        class MyStore(TestControllerStore):
            async def force_account_status(self, account_id, status):
                old = self.accounts[account_id]["status"]
                self.accounts[account_id]["status"] = status
                return {"previous_state": old, "current_state": status}

        mcp = create_mcp_server(MySeller(), name="my-agent")
        register_test_controller(mcp, MyStore())
        mcp.run(transport="streamable-http")
    """

    from mcp.server.fastmcp.tools import Tool
    from mcp.server.fastmcp.utilities.func_metadata import ArgModelBase, FuncMetadata
    from pydantic import ConfigDict

    from adcp.server.base import ToolContext as _ToolContext
    from adcp.server.serve import RequestMetadata as _RequestMetadata

    async def comply_test_controller(**kwargs: Any) -> dict[str, Any]:
        context: _ToolContext | None = None
        if context_factory is not None:
            meta = _RequestMetadata(tool_name="comply_test_controller", transport="mcp")
            context = context_factory(meta)
            if not isinstance(context, _ToolContext):
                raise TypeError(
                    "context_factory for comply_test_controller returned "
                    f"{type(context).__name__}, not a ToolContext instance"
                )
        return await _handle_test_controller(
            store,
            kwargs,
            context=context,
            account_resolver=account_resolver,
        )

    tool = Tool.from_function(
        comply_test_controller,
        name="comply_test_controller",
        description="Compliance test controller. Sandbox only, not for production use.",
    )

    # Override schema with the proper comply_test_controller inputSchema.
    # Derived from SCENARIOS so it can't drift from the dispatcher.
    tool.parameters = {
        "type": "object",
        "properties": {
            "account": {"type": "object"},
            "scenario": {
                "type": "string",
                # Derived from SCENARIOS so the enum never drifts from the dispatcher.
                "enum": ["list_scenarios"] + SCENARIOS,
            },
            "params": {"type": "object"},
            "context": {"type": "object"},
        },
        "required": ["scenario"],
    }

    # Override fn_metadata with a permissive model
    class _ControllerArgs(ArgModelBase):
        model_config = ConfigDict(extra="allow")

        def model_dump_one_level(self) -> dict[str, Any]:
            result: dict[str, Any] = {}
            for field_name in self.__class__.model_fields:
                result[field_name] = getattr(self, field_name)
            if self.model_extra:
                result.update(self.model_extra)
            return result

    tool.fn_metadata = FuncMetadata(
        arg_model=_ControllerArgs,
        output_schema=tool.fn_metadata.output_schema,
        output_model=tool.fn_metadata.output_model,
        wrap_output=tool.fn_metadata.wrap_output,
    )

    mcp._tool_manager._tools["comply_test_controller"] = tool

Register the comply_test_controller tool on an MCP server.

This is the Python equivalent of the JS SDK's registerTestController(). It adds the comply_test_controller MCP tool backed by your TestControllerStore.

Args

mcp
A FastMCP server instance.
store
Your TestControllerStore implementation.
context_factory
Optional ContextFactory invoked per call to build a :class:ToolContext. When set, the context is threaded into store methods that declare a context keyword — which is how sellers whose test runtime reads request headers (AdCPTestContext.from_headers) combine header-driven mock state with the storyboard-driven comply_test_controller skill. Wire the same factory you pass to :func:create_mcp_server() so both paths see the same per-request context.
account_resolver

Async-or-sync callable that resolves a wire account ref to a framework :class:Account, OR the :data:INSECURE_ALLOW_ALL sentinel for tests that opt out of the gate. The comply controller applies the Phase 1 sandbox-authority gate against the resolved account: only accounts with mode in {'sandbox', 'mock'} (or legacy sandbox=True) are admitted; mode='live' is denied regardless of wire signals. v6 :class:DecisioningPlatform adopters get this hooked automatically by decisioning.serve. Adopters wiring the controller manually pass a closure over their own account store.

Default fail-closed. When None AND ADCP_SANDBOX is unset, every comply call is denied — manually-wired ADCPHandler / :class:ComplianceHandler deployments are protected by default. Tests that intentionally bypass the gate pass account_resolver=INSECURE_ALLOW_ALL; dev servers can set ADCP_SANDBOX=1 instead.

See docs/proposals/lifecycle-state-and-sandbox-authority.md.

Example

from adcp.server.test_controller import TestControllerStore, register_test_controller

class MyStore(TestControllerStore): async def force_account_status(self, account_id, status): old = self.accounts[account_id]["status"] self.accounts[account_id]["status"] = status return {"previous_state": old, "current_state": status}

mcp = create_mcp_server(MySeller(), name="my-agent") register_test_controller(mcp, MyStore()) mcp.run(transport="streamable-http")

async def resolve_account(params: dict[str, Any], resolver: AccountResolver | None) ‑> tuple[typing.Any | None, dict[str, typing.Any] | None]
Expand source code
async def resolve_account(
    params: dict[str, Any],
    resolver: AccountResolver | None,
) -> tuple[Any | None, dict[str, Any] | None]:
    """Resolve an account reference from request params.

    Returns (account, None) on success, (None, error_dict) on failure,
    or (None, None) if no account field or no resolver configured.

    The resolver can return None (auto-ACCOUNT_NOT_FOUND) or raise
    ``AccountError`` for specific error codes (ACCOUNT_SUSPENDED,
    ACCOUNT_PAYMENT_REQUIRED, ACCOUNT_AMBIGUOUS, etc.).
    """
    if resolver is None or "account" not in params:
        return None, None
    try:
        account = await resolver(params["account"])
    except AccountError as e:
        return None, adcp_error(
            e.code,
            e.error_message,
            field="account",
            suggestion=e.suggestion,
        )
    if account is None:
        return None, adcp_error(
            "ACCOUNT_NOT_FOUND",
            "The specified account does not exist",
            field="account",
            suggestion="Use list_accounts to discover available accounts, "
            "or sync_accounts to create one",
        )
    return account, None

Resolve an account reference from request params.

Returns (account, None) on success, (None, error_dict) on failure, or (None, None) if no account field or no resolver configured.

The resolver can return None (auto-ACCOUNT_NOT_FOUND) or raise AccountError for specific error codes (ACCOUNT_SUSPENDED, ACCOUNT_PAYMENT_REQUIRED, ACCOUNT_AMBIGUOUS, etc.).

async def resolve_account_into_context(params: dict[str, Any],
context: AccountAwareToolContext | None,
resolver: AccountResolver | None,
*,
account_id_attr: str = 'account_id') ‑> dict[str, typing.Any] | None
Expand source code
async def resolve_account_into_context(
    params: dict[str, Any],
    context: AccountAwareToolContext | None,
    resolver: AccountResolver | None,
    *,
    account_id_attr: str = "account_id",
) -> dict[str, Any] | None:
    """Resolve an account reference and populate an
    :class:`~adcp.server.AccountAwareToolContext`.

    Collapses the standard three-line boilerplate (resolve → check error
    → extract id) into one call. Returns ``None`` on success (or when
    there's nothing to resolve); returns an error dict to be returned
    directly from the handler otherwise::

        async def get_products(self, params, context=None):
            err = await resolve_account_into_context(
                params, context, my_resolver,
            )
            if err:
                return err
            return products_response(catalog.for_account(context.account_id))

    :param params: The request params dict, expected to carry an
        ``account`` key with an ``AccountReference``.
    :param context: The handler's context. Must be
        :class:`~adcp.server.AccountAwareToolContext` (or a subclass of
        it) to receive the resolved fields. Passing a plain
        ``ToolContext`` runs resolution for the error path but logs a
        ``UserWarning`` — the silent-skip would otherwise break the
        multi-tenant scope contract.
    :param resolver: An :data:`AccountResolver` — same shape as
        :func:`resolve_account` accepts.
    :param account_id_attr: Attribute name on the resolver's account
        object that holds the stable id. Defaults to ``"account_id"``
        — matches the SDK's spec-generated :class:`~adcp.types.Account`
        type. Override when your resolver returns a domain object
        using a different attr name.
    """
    account, err = await resolve_account(params, resolver)
    if err is not None:
        return err
    if account is None:
        return None

    if not isinstance(context, AccountAwareToolContext):
        warnings.warn(
            "resolve_account_into_context received a context that isn't an "
            "AccountAwareToolContext — account was resolved but context not "
            "mutated. Populate your handler's context_factory to return "
            "AccountAwareToolContext (or a subclass), or parameterise your "
            "handler with ADCPHandler[AccountAwareToolContext]. Silent skip "
            "means downstream cache/audit keys will scope to None.",
            UserWarning,
            stacklevel=2,
        )
        return None

    if not hasattr(account, account_id_attr):
        raise ValueError(
            f"Resolved account of type {type(account).__name__!r} has no "
            f"{account_id_attr!r} attribute. Pass account_id_attr= to "
            f"resolve_account_into_context() if your resolver returns a "
            f"domain object using a different field name."
        )

    context.account = account
    context.account_id = getattr(account, account_id_attr)
    return None

Resolve an account reference and populate an :class:~adcp.server.AccountAwareToolContext.

Collapses the standard three-line boilerplate (resolve → check error → extract id) into one call. Returns None on success (or when there's nothing to resolve); returns an error dict to be returned directly from the handler otherwise::

async def get_products(self, params, context=None):
    err = await resolve_account_into_context(
        params, context, my_resolver,
    )
    if err:
        return err
    return products_response(catalog.for_account(context.account_id))

:param params: The request params dict, expected to carry an account key with an AccountReference. :param context: The handler's context. Must be :class:~adcp.server.AccountAwareToolContext (or a subclass of it) to receive the resolved fields. Passing a plain ToolContext runs resolution for the error path but logs a UserWarning — the silent-skip would otherwise break the multi-tenant scope contract. :param resolver: An :data:AccountResolver — same shape as :func:resolve_account() accepts. :param account_id_attr: Attribute name on the resolver's account object that holds the stable id. Defaults to "account_id" — matches the SDK's spec-generated :class:~adcp.types.Account type. Override when your resolver returns a domain object using a different attr name.

def resolve_requested_adcp_version(payload: Any,
*,
supported: tuple[str, ...] = ('3.0', '3.1', '2.5'),
default: str = '3.0') ‑> str
Expand source code
def resolve_requested_adcp_version(
    payload: Any,
    *,
    supported: tuple[str, ...] = SUPPORTED_WIRE_VERSIONS,
    default: str = DEFAULT_UNNEGOTIATED_ADCP_VERSION,
) -> str:
    """Return the AdCP release this request should be served as.

    This is the public, adopter-facing version of the server dispatcher's
    envelope-field resolution contract:

    * explicit ``adcp_version`` wins and is normalized to release precision;
    * legacy ``adcp_major_version`` maps to that major's base minor when
      available, preserving pre-3.1 response-envelope semantics;
    * no version signal resolves to ``default`` (currently ``"3.0"``).

    The helper is intentionally payload-only. It does not run the dispatcher's
    tool-specific legacy shape probes for adapter-routed versions such as 2.5.

    Unsupported explicit claims, or an unnegotiated request whose default is
    not in ``supported``, raise :class:`UnsupportedVersionError`, just like
    :func:`detect_wire_version`.
    """
    resolved = detect_wire_version(payload, supported=supported)
    if resolved is not None:
        return resolved
    if default not in supported:
        raise UnsupportedVersionError(default, supported)
    return default

Return the AdCP release this request should be served as.

This is the public, adopter-facing version of the server dispatcher's envelope-field resolution contract:

  • explicit adcp_version wins and is normalized to release precision;
  • legacy adcp_major_version maps to that major's base minor when available, preserving pre-3.1 response-envelope semantics;
  • no version signal resolves to default (currently "3.0").

The helper is intentionally payload-only. It does not run the dispatcher's tool-specific legacy shape probes for adapter-routed versions such as 2.5.

Unsupported explicit claims, or an unnegotiated request whose default is not in supported, raise :class:UnsupportedVersionError, just like :func:detect_wire_version.

def serve(handler: ADCPHandler[Any] | Any,
*,
config: ServeConfig | None = None,
name: str = 'adcp-agent',
port: int | None = None,
host: str | None = None,
transport: str = 'streamable-http',
instructions: str | None = None,
test_controller: TestControllerStore | None = None,
test_controller_account_resolver: Any | None = None,
context_factory: ContextFactory | None = None,
task_store: TaskStore | None = None,
push_config_store: PushNotificationConfigStore | None = None,
middleware: Sequence[SkillMiddleware] | None = None,
asgi_middleware: Sequence[ASGIMiddlewareEntry] | None = None,
message_parser: MessageParser | None = None,
advertise_all: bool = False,
max_request_size: int | None = None,
streaming_responses: bool = False,
stateless_http: bool = False,
session_idle_timeout: float | None = 1800.0,
max_active_sessions: int | None = None,
validation: ValidationHookConfig | None = ValidationHookConfig(requests='strict', responses='strict', unknown_fields=None),
pre_validation_hooks: PreValidationHooks | None = None,
response_enhancer: ResponseEnhancer | None = None,
enable_debug_endpoints: bool = False,
debug_traffic_source: Callable[[], dict[str, int]] | None = None,
session_count_source: Callable[[], dict[str, Any]] | None = None,
debug_validate_request: Callable[[dict[str, str]], bool] | None = None,
debug_public: bool = False,
base_url: str | None = None,
specialisms: list[str] | None = None,
description: str | None = None,
allowed_hosts: Sequence[str] | None = None,
allowed_origins: Sequence[str] | None = None,
enable_dns_rebinding_protection: bool | None = None,
auth: BearerTokenAuth | None = None,
public_url: str | PublicUrlResolver | None = None,
on_startup: Sequence[LifespanHook] | None = None,
on_shutdown: Sequence[LifespanHook] | None = None) ‑> None
Expand source code
def serve(
    handler: ADCPHandler[Any] | Any,
    *,
    config: ServeConfig | None = None,
    name: str = "adcp-agent",
    port: int | None = None,
    host: str | None = None,
    transport: str = "streamable-http",
    instructions: str | None = None,
    test_controller: TestControllerStore | None = None,
    test_controller_account_resolver: Any | None = None,
    context_factory: ContextFactory | None = None,
    task_store: TaskStore | None = None,
    push_config_store: PushNotificationConfigStore | None = None,
    middleware: Sequence[SkillMiddleware] | None = None,
    asgi_middleware: Sequence[ASGIMiddlewareEntry] | None = None,
    message_parser: MessageParser | None = None,
    advertise_all: bool = False,
    max_request_size: int | None = None,
    streaming_responses: bool = False,
    stateless_http: bool = False,
    session_idle_timeout: float | None = 1800.0,
    max_active_sessions: int | None = None,
    validation: ValidationHookConfig | None = DEFAULT_VALIDATION,
    pre_validation_hooks: PreValidationHooks | None = None,
    response_enhancer: ResponseEnhancer | None = None,
    enable_debug_endpoints: bool = False,
    debug_traffic_source: Callable[[], dict[str, int]] | None = None,
    session_count_source: Callable[[], dict[str, Any]] | None = None,
    debug_validate_request: Callable[[dict[str, str]], bool] | None = None,
    debug_public: bool = False,
    base_url: str | None = None,
    specialisms: list[str] | None = None,
    description: str | None = None,
    allowed_hosts: Sequence[str] | None = None,
    allowed_origins: Sequence[str] | None = None,
    enable_dns_rebinding_protection: bool | None = None,
    auth: BearerTokenAuth | None = None,
    public_url: str | PublicUrlResolver | None = None,
    on_startup: Sequence[LifespanHook] | None = None,
    on_shutdown: Sequence[LifespanHook] | None = None,
) -> None:
    """Start an MCP or A2A server from an ADCP handler or server builder.

    Accepts either an ``ADCPHandler`` instance or an ``ADCPServerBuilder``
    (from ``adcp_server()``). Builders are auto-converted via ``build_handler()``.

    This is the simplest way to run an ADCP agent. Set ``transport="a2a"``
    to serve over the A2A protocol instead of MCP, or ``transport="both"``
    to serve both protocols on the same port (MCP at ``/mcp``, A2A at
    ``/``).

    Args:
        handler: An ADCPHandler subclass instance with your tool implementations.
        config: Optional :class:`ServeConfig` bundle.  When supplied, all
            field values come from it and any individual kwargs passed
            alongside are ignored.  Use ``dataclasses.replace(config, ...)``
            to vary a single field from a shared base config.
        name: Server name shown to clients / in the A2A agent card.
        port: Port to listen on. Defaults to PORT env var, then 3001.
        transport: ``"streamable-http"`` (default, MCP), ``"a2a"``, or
            ``"both"`` (one Starlette binary serving MCP at ``/mcp``
            and A2A at ``/``). Use ``"both"`` when you want adopters
            on either protocol to reach the same handler with shared
            ``context_factory`` + ``middleware`` wiring — JS hosts both
            on one Express app; this is the Python parity.
        instructions: Optional system instructions for the agent (MCP only).
        test_controller: Optional TestControllerStore instance for storyboard testing.
        context_factory: Optional factory that builds a :class:`ToolContext`
            per tool call — see :data:`ContextFactory`.
        task_store: Optional a2a-sdk ``TaskStore`` for durable A2A task
            persistence (A2A transport only). Defaults to ``InMemoryTaskStore``
            — tasks don't survive restart. See
            ``examples/a2a_db_tasks.py`` for the production pattern.
        push_config_store: Optional a2a-sdk ``PushNotificationConfigStore``
            for push-notif subscription persistence (A2A transport only).
            When unset, a2a-sdk surfaces the push-notif endpoints as
            ``UnsupportedOperationError`` — clients cannot register
            subscriptions at all. See ``examples/a2a_db_tasks.py`` for
            a durable reference implementation.
        middleware: Optional sequence of :data:`SkillMiddleware` callables
            wrapping every skill dispatch on both the MCP and A2A
            transports. Use for audit logging, activity-feed hooks,
            rate limiting, tracing. Composes outermost-first. See
            :data:`SkillMiddleware` for the signature and composition
            semantics.
        asgi_middleware: Optional sequence of ASGI middleware entries
            applied to the outer HTTP app before uvicorn binds. Use for
            cross-cutting HTTP concerns the SDK does not own: tenant
            resolution (:class:`adcp.server.SubdomainTenantMiddleware`),
            CORS, request-id propagation, IP allowlists, custom auth.
            Composes outermost-first — the first entry sees every request
            before later entries. Applied on every HTTP transport
            (``streamable-http``, ``sse``, ``a2a``, ``both``); ignored
            on ``stdio``.

            Each entry is either a ``(MiddlewareClass, kwargs)`` tuple
            invoked as ``cls(app, **kwargs)``, or a callable factory
            ``f(app) -> app``. Both forms can appear in the same list.

            Middleware sees ``lifespan`` and ``websocket`` scopes in
            addition to ``http`` — guard non-HTTP scopes by passing
            them through unchanged (``if scope['type'] != 'http':
            await self.app(scope, receive, send); return``) so the
            framework's lifespan composition still runs.

            Example (tuple form)::

                from starlette.middleware.cors import CORSMiddleware
                serve(handler, asgi_middleware=[
                    (CORSMiddleware, {"allow_origins": ["*"]}),
                ])

            Example (callable factory form, e.g. with ``functools.partial``)::

                import functools
                from starlette.middleware.cors import CORSMiddleware
                serve(handler, asgi_middleware=[
                    functools.partial(CORSMiddleware, allow_origins=["*"]),
                ])
        message_parser: Optional
            :data:`~adcp.server.a2a_server.MessageParser` callable for
            alternative A2A wire shapes (A2A transport only). The
            default parser handles ``DataPart(data={"skill": ...,
            "parameters": ...})`` plus a TextPart JSON fallback; supply
            this hook to accept JSON-RPC 2.0 message bodies or vendor-
            specific DataPart schemas. MCP does not use this kwarg
            (FastMCP owns the wire shape).
        advertise_all: When True, advertise every tool the handler type
            supports even if the subclass didn't override the method.
            Defaults to ``False`` — ``tools/list`` only shows tools the
            handler actually implements, which dramatically shrinks the
            advertised surface. Turn on for spec-compliance storyboards
            or when you want to signal ``not_supported`` on a specific
            tool to clients.
        max_request_size: Maximum request body size in bytes. Defaults
            to 10 MB. Set higher for sellers that legitimately transmit
            very large creative asset payloads; set lower for stricter
            public-facing deployments. Set to ``0`` to disable the cap
            entirely (not recommended — the cap is the only guard
            against adversarial payloads exhausting Pydantic validation
            CPU/memory). See :mod:`adcp.server._size_limit`.
        host: Network interface to bind to (MCP transports only). Defaults
            to the ``ADCP_HOST`` environment variable, then ``"0.0.0.0"``
            (all interfaces). Use ``"127.0.0.1"`` for local-only
            development. Container deployments (Fly.io, k8s, Cloud Run)
            require ``"0.0.0.0"`` so the process listens on the
            container's external interface.
        streaming_responses: When ``False`` (default), the streamable-http
            transport returns one ``application/json`` response per
            request. AdCP tools today don't emit progress events, and
            FastMCP's SSE-internal streaming default has an upstream bug
            that drops the ASGI response without completing — making the
            storyboard runner report ``overall_status: "unreachable"``.
            Set to ``True`` only if your tools genuinely emit progress
            notifications and your clients consume the SSE stream
            (MCP transports only). Note: the legacy ``transport="sse"``
            is a separate (deprecated) MCP transport, unrelated to this
            flag.
        stateless_http: When ``False`` (default), MCP keeps a per-client
            session alive across requests so subsequent ``tools/call``
            posts skip the transport-construction tax — meaningfully
            faster for chatty clients, and the only mode where
            ``StreamableHTTPSessionManager``'s idle-reap path actually
            runs. (Stateless mode in upstream MCP holds GET-SSE streams
            with no idle eviction, which is why production adopters
            saw connections accumulate.) The SDK threads the
            originating Starlette ``Request`` into
            ``RequestMetadata.request_context`` in both modes so
            ``context_factory`` can read auth off ``request.state``;
            the bundled :func:`auth_context_factory` already does
            this. Set ``True`` for stateless deployments — multi-replica
            without sticky LB on ``Mcp-Session-Id``, or where you
            cannot configure session affinity.
        session_idle_timeout: Idle reap deadline (seconds) for stateful
            sessions. Each request pushes the deadline forward; idle
            sessions are terminated and their per-session state freed.
            Defaults to 1800 (30 min); ``None`` disables reaping.
            Ignored when ``stateless_http=True``.
        max_active_sessions: Optional cap for simultaneously active
            stateful MCP sessions. When the cap is reached, new
            session-creating requests are rejected with HTTP 429 while
            requests carrying an existing ``Mcp-Session-Id`` continue.
            Ignored when ``stateless_http=True``.
        enable_debug_endpoints: When ``True``, mount debug routes on
            the outer HTTP app. ``GET /_debug/traffic`` returns the JSON
            dict from ``debug_traffic_source()`` — typically wired to
            the seller's :class:`adcp.decisioning.MockAdServer.get_traffic`.
            ``GET /_debug/sessions`` returns the JSON dict from
            ``session_count_source()``.
            Defaults to ``False`` so production deployments stay
            closed; reference / dev sellers turn it on. Ignored on
            stdio. The traffic endpoint exposes per-method outbound
            call counts for storyboard runners' anti-façade assertions.
        debug_traffic_source: Zero-arg callable returning the
            per-method count snapshot for ``/_debug/traffic``. Required
            when ``enable_debug_endpoints=True`` and no
            ``session_count_source`` is supplied; otherwise ignored.
            Typically ``mock_ad_server.get_traffic``.
        session_count_source: Zero-arg callable returning the active
            MCP session snapshot for ``/_debug/sessions``. Required
            when ``enable_debug_endpoints=True`` and no
            ``debug_traffic_source`` is supplied; otherwise ignored.
        debug_validate_request: Optional callable used to authorize
            ``/_debug/*`` requests. Receives lower-case request headers
            and returns ``True`` to serve the debug response. Required
            when ``enable_debug_endpoints=True`` unless
            ``debug_public=True`` is set.
        debug_public: Set ``True`` only for local/storyboard runners
            where the debug routes are intentionally unauthenticated.
            Defaults to ``False`` so network-reachable deployments must
            opt into public debug visibility explicitly or supply
            ``debug_validate_request``.
        base_url: Optional public origin URL for the binary, used to
            populate the ``url`` field of each entry in the
            ``/.well-known/adcp-agents.json`` discovery manifest.
            Adopters behind a TLS-terminating reverse proxy SHOULD set
            this (e.g. ``"https://sales.example.com"``). When ``None``
            the manifest URLs fall back to ``http://<bind-host>:<port>``,
            which is correct for local development but wrong for
            production.
        specialisms: Optional list of AdCP specialism tags surfaced in
            the discovery manifest (e.g. ``["sales-non-guaranteed"]``).
            See :data:`adcp.server.discovery` for the full list.
            Defaults to a placeholder when omitted — adopters who know
            their specialism SHOULD pass it.
        description: Optional human-readable description surfaced in
            the discovery manifest's per-agent ``description`` field.
        validation: :class:`ValidationHookConfig` enabling schema
            validation of every request and response against the
            bundled AdCP JSON schemas. ``requests="strict"`` raises
            ``VALIDATION_ERROR`` before the handler runs on a malformed
            payload; ``responses="strict"`` raises after the handler
            returns when the response shape drifts from spec.

            **Defaults to** :data:`DEFAULT_VALIDATION` (strict on both
            sides) — wire-conformance by default. This catches the
            class of bug that shipped the ``pricing_options``
            regression past Pydantic ``extra="allow"`` silently
            swallowing an unknown shape. Adopters mid-migration who
            need response drift to warn rather than fail pass
            ``ValidationHookConfig(responses="warn")``; adopters who
            want validation off entirely pass
            ``ValidationHookConfig(requests="off", responses="off")``
            or ``validation=None``. Applies to both MCP and A2A
            transports.
        response_enhancer: Optional server-wide
            :data:`~adcp.server.ResponseEnhancer` applied to
            framework-tool successes, custom-tool successes, the pre-auth
            ``get_adcp_capabilities`` discovery response, and
            raised-error responses — on both the MCP and A2A transports.
            The callback runs after the context echo (so it cannot
            re-introduce a stripped credential) and, on the success path,
            before schema validation (so a conformance-breaking mutation
            surfaces as ``VALIDATION_ERROR`` rather than shipping
            malformed). Both the context-blind ``(result_dict)`` arity and
            the context-aware ``(method_name, result_dict, context)`` arity
            are supported. See :data:`~adcp.server.ResponseEnhancer` for
            the exact coverage (including two non-enhanced paths) and the
            failure / idempotency-replay semantics.

    Security:
        This function does NOT configure authentication. In production,
        use a reverse proxy or middleware that validates credentials
        before forwarding to the endpoint. Without authentication,
        MCP exposes tools/list and A2A exposes /.well-known/agent.json,
        both of which reveal the agent's full capability surface.
        auth: Optional :class:`~adcp.server.auth.BearerTokenAuth` config
            applied to MCP, A2A, and ``transport="both"`` legs from the
            same source of truth. Drives MCP's
            :class:`~adcp.server.auth.BearerTokenAuthMiddleware` and
            A2A's :class:`~adcp.server.auth.BearerTokenContextBuilder`.
            On A2A, ``/.well-known/agent-card.json`` stays publicly
            accessible per A2A spec §4.1 — the agent-card route is
            registered separately and never invokes the builder. On
            stdio, ``auth`` is ignored with a warning (no HTTP layer).
            For non-bearer schemes (mTLS, signed-request derivation),
            wire your own middleware via ``asgi_middleware=`` instead.
        public_url: Public base URL for the A2A agent card
            (``/.well-known/agent-card.json``).  Accepts a static string
            or a :data:`~adcp.server.a2a_server.PublicUrlResolver`
            callable for per-request resolution.

            *Static string* — replaces ``http://localhost:{port}/`` in
            ``supportedInterfaces``.  Falls back to the ``PUBLIC_URL``
            env var when ``None``.  Correct for single-host deployments.

            *Callable* — receives the Starlette ``Request`` per card
            fetch; must return an absolute ``https://`` URL.  Use for
            multi-tenant subdomain deployments where each tenant host
            needs its own card::

                def resolver(request):
                    host = request.headers.get("host", "localhost")
                    return f"https://{host}/"

                serve(handler, transport="a2a", public_url=resolver)

            Ignored for MCP transports.
        on_startup: Optional sequence of :data:`LifespanHook` zero-arg
            async callables fired after both inner MCP and A2A
            lifespans have initialized. Use for adopter background
            work that must run for the lifetime of the server —
            schedulers, queue consumers, cache warmers, connection
            pools. A hook raising aborts boot via
            ``lifespan.startup.failed``. **Today honored only on**
            ``transport="both"``; passing on any other transport
            raises :class:`ValueError` at boot rather than silently
            dropping the hook. See ``examples/scheduler_lifespan.py``.
        on_shutdown: Optional sequence of :data:`LifespanHook` zero-arg
            async callables fired before either inner lifespan tears
            down. Every hook runs on a best-effort basis even if an
            earlier one raised; the first failure re-raises so
            Starlette surfaces it, later failures land in
            ``logger.error``. Same ``transport="both"`` restriction
            as ``on_startup``.

    Example (MCP):
        from adcp.server import ADCPHandler, serve
        from adcp.server.responses import capabilities_response

        class MyAgent(ADCPHandler):
            async def get_adcp_capabilities(self, params, context=None):
                return capabilities_response(["media_buy"])

        serve(MyAgent(), name="my-agent")

    Example (A2A):
        serve(MyAgent(), name="my-agent", transport="a2a")

    With test controller:
        from adcp.server.test_controller import TestControllerStore

        class MyStore(TestControllerStore):
            async def force_account_status(self, account_id, status):
                ...

        serve(MyAgent(), name="my-agent", test_controller=MyStore())
    """
    # When a ServeConfig bundle is provided, extract all fields from it.
    # Individual kwargs are ignored so that config= is the single source of
    # truth.  Callers who need to vary one field should use
    # dataclasses.replace(config, field=value) rather than mixing styles.
    if config is not None:
        name = config.name
        port = config.port
        host = config.host
        transport = config.transport
        instructions = config.instructions
        test_controller = config.test_controller
        context_factory = config.context_factory
        task_store = config.task_store
        push_config_store = config.push_config_store
        middleware = config.middleware
        asgi_middleware = config.asgi_middleware
        message_parser = config.message_parser
        advertise_all = config.advertise_all
        max_request_size = config.max_request_size
        streaming_responses = config.streaming_responses
        stateless_http = config.stateless_http
        session_idle_timeout = config.session_idle_timeout
        max_active_sessions = config.max_active_sessions
        validation = config.validation
        pre_validation_hooks = config.pre_validation_hooks
        response_enhancer = config.response_enhancer
        enable_debug_endpoints = config.enable_debug_endpoints
        debug_traffic_source = config.debug_traffic_source
        session_count_source = config.session_count_source
        debug_validate_request = config.debug_validate_request
        debug_public = config.debug_public
        base_url = config.base_url
        specialisms = config.specialisms
        description = config.description
        public_url = config.public_url
        on_startup = config.on_startup
        on_shutdown = config.on_shutdown

    # Accept ADCPServerBuilder from adcp_server() decorator pattern
    from adcp.server.builder import ADCPServerBuilder

    if isinstance(handler, ADCPServerBuilder):
        if not name or name == "adcp-agent":
            name = handler.name
        handler = handler.build_handler()

    # 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.
    if transport in ("a2a", "both", "streamable-http", "sse"):
        asgi_middleware = _prepend_debug_endpoint(
            asgi_middleware,
            enable_debug_endpoints=enable_debug_endpoints,
            debug_traffic_source=debug_traffic_source,
            session_count_source=session_count_source,
            debug_validate_request=debug_validate_request,
            debug_public=debug_public,
        )

    # Lifespan hooks ship today only for transport="both" because that's
    # the path with an SDK-owned parent Starlette where composition is
    # straightforward (see :func:`_build_mcp_and_a2a_app`). For single-
    # transport paths, FastMCP / a2a-sdk own their inner Starlette and
    # we would have to mutate vendor internals to weave hooks in. Fail
    # closed here so adopters get a clear error at boot instead of
    # silently dropped hooks at runtime.
    if (on_startup or on_shutdown) and transport != "both":
        raise ValueError(
            f"on_startup / on_shutdown hooks require transport='both', got "
            f"transport={transport!r}. The single-transport paths "
            "(streamable-http, sse, a2a, stdio) do not yet expose a "
            "composition point for user lifespan hooks. Either set "
            "transport='both' (see examples/scheduler_lifespan.py for the "
            "pattern) or hand-wire ASGI lifespan-scope middleware. "
            "Single-transport support is tracked as a follow-up to #709."
        )

    if transport == "a2a":
        _serve_a2a(
            handler,
            name=name,
            port=port,
            test_controller=test_controller,
            test_controller_account_resolver=test_controller_account_resolver,
            context_factory=context_factory,
            task_store=task_store,
            push_config_store=push_config_store,
            middleware=middleware,
            asgi_middleware=asgi_middleware,
            message_parser=message_parser,
            advertise_all=advertise_all,
            max_request_size=max_request_size,
            validation=validation,
            pre_validation_hooks=pre_validation_hooks,
            response_enhancer=response_enhancer,
            base_url=base_url,
            specialisms=specialisms,
            description=description,
            auth=auth,
            public_url=public_url,
        )
    elif transport in ("streamable-http", "sse", "stdio"):
        _serve_mcp(
            handler,
            name=name,
            port=port,
            host=host,
            transport=transport,
            instructions=instructions,
            test_controller=test_controller,
            test_controller_account_resolver=test_controller_account_resolver,
            context_factory=context_factory,
            middleware=middleware,
            asgi_middleware=asgi_middleware,
            advertise_all=advertise_all,
            max_request_size=max_request_size,
            streaming_responses=streaming_responses,
            stateless_http=stateless_http,
            session_idle_timeout=session_idle_timeout,
            max_active_sessions=max_active_sessions,
            validation=validation,
            pre_validation_hooks=pre_validation_hooks,
            response_enhancer=response_enhancer,
            base_url=base_url,
            specialisms=specialisms,
            description=description,
            allowed_hosts=allowed_hosts,
            allowed_origins=allowed_origins,
            enable_dns_rebinding_protection=enable_dns_rebinding_protection,
            auth=auth,
        )
    elif transport == "both":
        _serve_mcp_and_a2a(
            handler,
            name=name,
            port=port,
            host=host,
            instructions=instructions,
            test_controller=test_controller,
            test_controller_account_resolver=test_controller_account_resolver,
            context_factory=context_factory,
            task_store=task_store,
            push_config_store=push_config_store,
            middleware=middleware,
            asgi_middleware=asgi_middleware,
            message_parser=message_parser,
            advertise_all=advertise_all,
            max_request_size=max_request_size,
            streaming_responses=streaming_responses,
            stateless_http=stateless_http,
            session_idle_timeout=session_idle_timeout,
            max_active_sessions=max_active_sessions,
            validation=validation,
            pre_validation_hooks=pre_validation_hooks,
            response_enhancer=response_enhancer,
            base_url=base_url,
            specialisms=specialisms,
            description=description,
            allowed_hosts=allowed_hosts,
            allowed_origins=allowed_origins,
            enable_dns_rebinding_protection=enable_dns_rebinding_protection,
            auth=auth,
            public_url=public_url,
            on_startup=on_startup,
            on_shutdown=on_shutdown,
        )
    else:
        valid = ", ".join(sorted(("a2a", "both", "streamable-http", "sse", "stdio")))
        raise ValueError(f"Unknown transport {transport!r}. Valid: {valid}")

Start an MCP or A2A server from an ADCP handler or server builder.

Accepts either an ADCPHandler instance or an ADCPServerBuilder (from adcp_server()). Builders are auto-converted via build_handler().

This is the simplest way to run an ADCP agent. Set transport="a2a" to serve over the A2A protocol instead of MCP, or transport="both" to serve both protocols on the same port (MCP at /mcp, A2A at /).

Args

handler
An ADCPHandler subclass instance with your tool implementations.
config
Optional :class:ServeConfig bundle. When supplied, all field values come from it and any individual kwargs passed alongside are ignored. Use dataclasses.replace(config, …) to vary a single field from a shared base config.
name
Server name shown to clients / in the A2A agent card.
port
Port to listen on. Defaults to PORT env var, then 3001.
transport
"streamable-http" (default, MCP), "a2a", or "both" (one Starlette binary serving MCP at /mcp and A2A at /). Use "both" when you want adopters on either protocol to reach the same handler with shared context_factory + middleware wiring — JS hosts both on one Express app; this is the Python parity.
instructions
Optional system instructions for the agent (MCP only).
test_controller
Optional TestControllerStore instance for storyboard testing.
context_factory
Optional factory that builds a :class:ToolContext per tool call — see :data:ContextFactory.
task_store
Optional a2a-sdk TaskStore for durable A2A task persistence (A2A transport only). Defaults to InMemoryTaskStore — tasks don't survive restart. See examples/a2a_db_tasks.py for the production pattern.
push_config_store
Optional a2a-sdk PushNotificationConfigStore for push-notif subscription persistence (A2A transport only). When unset, a2a-sdk surfaces the push-notif endpoints as UnsupportedOperationError — clients cannot register subscriptions at all. See examples/a2a_db_tasks.py for a durable reference implementation.
middleware
Optional sequence of :data:SkillMiddleware callables wrapping every skill dispatch on both the MCP and A2A transports. Use for audit logging, activity-feed hooks, rate limiting, tracing. Composes outermost-first. See :data:SkillMiddleware for the signature and composition semantics.
asgi_middleware

Optional sequence of ASGI middleware entries applied to the outer HTTP app before uvicorn binds. Use for cross-cutting HTTP concerns the SDK does not own: tenant resolution (:class:SubdomainTenantMiddleware), CORS, request-id propagation, IP allowlists, custom auth. Composes outermost-first — the first entry sees every request before later entries. Applied on every HTTP transport (streamable-http, sse, a2a, both); ignored on stdio.

Each entry is either a (MiddlewareClass, kwargs) tuple invoked as cls(app, **kwargs), or a callable factory f(app) -> app. Both forms can appear in the same list.

Middleware sees lifespan and websocket scopes in addition to http — guard non-HTTP scopes by passing them through unchanged (if scope['type'] != 'http': await self.app(scope, receive, send); return) so the framework's lifespan composition still runs.

Example (tuple form)::

from starlette.middleware.cors import CORSMiddleware
serve(handler, asgi_middleware=[
    (CORSMiddleware, {"allow_origins": ["*"]}),
])

Example (callable factory form, e.g. with functools.partial)::

import functools
from starlette.middleware.cors import CORSMiddleware
serve(handler, asgi_middleware=[
    functools.partial(CORSMiddleware, allow_origins=["*"]),
])
message_parser
Optional :data:~adcp.server.a2a_server.MessageParser callable for alternative A2A wire shapes (A2A transport only). The default parser handles DataPart(data={"skill": ..., "parameters": ...}) plus a TextPart JSON fallback; supply this hook to accept JSON-RPC 2.0 message bodies or vendor- specific DataPart schemas. MCP does not use this kwarg (FastMCP owns the wire shape).
advertise_all
When True, advertise every tool the handler type supports even if the subclass didn't override the method. Defaults to Falsetools/list only shows tools the handler actually implements, which dramatically shrinks the advertised surface. Turn on for spec-compliance storyboards or when you want to signal not_supported() on a specific tool to clients.
max_request_size
Maximum request body size in bytes. Defaults to 10 MB. Set higher for sellers that legitimately transmit very large creative asset payloads; set lower for stricter public-facing deployments. Set to 0 to disable the cap entirely (not recommended — the cap is the only guard against adversarial payloads exhausting Pydantic validation CPU/memory). See :mod:adcp.server._size_limit.
host
Network interface to bind to (MCP transports only). Defaults to the ADCP_HOST environment variable, then "0.0.0.0" (all interfaces). Use "127.0.0.1" for local-only development. Container deployments (Fly.io, k8s, Cloud Run) require "0.0.0.0" so the process listens on the container's external interface.
streaming_responses
When False (default), the streamable-http transport returns one application/json response per request. AdCP tools today don't emit progress events, and FastMCP's SSE-internal streaming default has an upstream bug that drops the ASGI response without completing — making the storyboard runner report overall_status: "unreachable". Set to True only if your tools genuinely emit progress notifications and your clients consume the SSE stream (MCP transports only). Note: the legacy transport="sse" is a separate (deprecated) MCP transport, unrelated to this flag.
stateless_http
When False (default), MCP keeps a per-client session alive across requests so subsequent tools/call posts skip the transport-construction tax — meaningfully faster for chatty clients, and the only mode where StreamableHTTPSessionManager's idle-reap path actually runs. (Stateless mode in upstream MCP holds GET-SSE streams with no idle eviction, which is why production adopters saw connections accumulate.) The SDK threads the originating Starlette Request into RequestMetadata.request_context in both modes so context_factory can read auth off request.state; the bundled :func:auth_context_factory() already does this. Set True for stateless deployments — multi-replica without sticky LB on Mcp-Session-Id, or where you cannot configure session affinity.
session_idle_timeout
Idle reap deadline (seconds) for stateful sessions. Each request pushes the deadline forward; idle sessions are terminated and their per-session state freed. Defaults to 1800 (30 min); None disables reaping. Ignored when stateless_http=True.
max_active_sessions
Optional cap for simultaneously active stateful MCP sessions. When the cap is reached, new session-creating requests are rejected with HTTP 429 while requests carrying an existing Mcp-Session-Id continue. Ignored when stateless_http=True.
enable_debug_endpoints
When True, mount debug routes on the outer HTTP app. GET /_debug/traffic returns the JSON dict from debug_traffic_source() — typically wired to the seller's :class:MockAdServer.get_traffic(). GET /_debug/sessions returns the JSON dict from session_count_source(). Defaults to False so production deployments stay closed; reference / dev sellers turn it on. Ignored on stdio. The traffic endpoint exposes per-method outbound call counts for storyboard runners' anti-façade assertions.
debug_traffic_source
Zero-arg callable returning the per-method count snapshot for /_debug/traffic. Required when enable_debug_endpoints=True and no session_count_source is supplied; otherwise ignored. Typically mock_ad_server.get_traffic.
session_count_source
Zero-arg callable returning the active MCP session snapshot for /_debug/sessions. Required when enable_debug_endpoints=True and no debug_traffic_source is supplied; otherwise ignored.
debug_validate_request
Optional callable used to authorize /_debug/* requests. Receives lower-case request headers and returns True to serve the debug response. Required when enable_debug_endpoints=True unless debug_public=True is set.
debug_public
Set True only for local/storyboard runners where the debug routes are intentionally unauthenticated. Defaults to False so network-reachable deployments must opt into public debug visibility explicitly or supply debug_validate_request.
base_url
Optional public origin URL for the binary, used to populate the url field of each entry in the /.well-known/adcp-agents.json discovery manifest. Adopters behind a TLS-terminating reverse proxy SHOULD set this (e.g. "https://sales.example.com"). When None the manifest URLs fall back to http://<bind-host>:<port>, which is correct for local development but wrong for production.
specialisms
Optional list of AdCP specialism tags surfaced in the discovery manifest (e.g. ["sales-non-guaranteed"]). See :data:adcp.server.discovery for the full list. Defaults to a placeholder when omitted — adopters who know their specialism SHOULD pass it.
description
Optional human-readable description surfaced in the discovery manifest's per-agent description field.
validation

:class:ValidationHookConfig enabling schema validation of every request and response against the bundled AdCP JSON schemas. requests="strict" raises VALIDATION_ERROR before the handler runs on a malformed payload; responses="strict" raises after the handler returns when the response shape drifts from spec.

Defaults to :data:DEFAULT_VALIDATION (strict on both sides) — wire-conformance by default. This catches the class of bug that shipped the pricing_options regression past Pydantic extra="allow" silently swallowing an unknown shape. Adopters mid-migration who need response drift to warn rather than fail pass ValidationHookConfig(responses="warn"); adopters who want validation off entirely pass ValidationHookConfig(requests="off", responses="off") or validation=None. Applies to both MCP and A2A transports.

response_enhancer
Optional server-wide :data:~adcp.server.ResponseEnhancer applied to framework-tool successes, custom-tool successes, the pre-auth get_adcp_capabilities discovery response, and raised-error responses — on both the MCP and A2A transports. The callback runs after the context echo (so it cannot re-introduce a stripped credential) and, on the success path, before schema validation (so a conformance-breaking mutation surfaces as VALIDATION_ERROR rather than shipping malformed). Both the context-blind (result_dict) arity and the context-aware (method_name, result_dict, context) arity are supported. See :data:~adcp.server.ResponseEnhancer for the exact coverage (including two non-enhanced paths) and the failure / idempotency-replay semantics.

Security

This function does NOT configure authentication. In production, use a reverse proxy or middleware that validates credentials before forwarding to the endpoint. Without authentication, MCP exposes tools/list and A2A exposes /.well-known/agent.json, both of which reveal the agent's full capability surface. auth: Optional :class:~adcp.server.auth.BearerTokenAuth config applied to MCP, A2A, and transport="both" legs from the same source of truth. Drives MCP's :class:~adcp.server.auth.BearerTokenAuthMiddleware and A2A's :class:~adcp.server.auth.BearerTokenContextBuilder. On A2A, /.well-known/agent-card.json stays publicly accessible per A2A spec §4.1 — the agent-card route is registered separately and never invokes the builder. On stdio, adcp.server.auth is ignored with a warning (no HTTP layer). For non-bearer schemes (mTLS, signed-request derivation), wire your own middleware via asgi_middleware= instead. public_url: Public base URL for the A2A agent card (/.well-known/agent-card.json). Accepts a static string or a :data:~adcp.server.a2a_server.PublicUrlResolver callable for per-request resolution.

*Static string* — replaces ``http://localhost:{port}/`` in
<code>supportedInterfaces</code>.  Falls back to the <code>PUBLIC\_URL</code>
env var when <code>None</code>.  Correct for single-host deployments.

*Callable* — receives the Starlette <code>Request</code> per card
fetch; must return an absolute ``https://`` URL.  Use for
multi-tenant subdomain deployments where each tenant host
needs its own card::

    def resolver(request):
        host = request.headers.get("host", "localhost")
        return f"https://{host}/"

    serve(handler, transport="a2a", public_url=resolver)

Ignored for MCP transports.

on_startup: Optional sequence of :data:LifespanHook zero-arg async callables fired after both inner MCP and A2A lifespans have initialized. Use for adopter background work that must run for the lifetime of the server — schedulers, queue consumers, cache warmers, connection pools. A hook raising aborts boot via lifespan.startup.failed. Today honored only on transport="both"; passing on any other transport raises :class:ValueError at boot rather than silently dropping the hook. See examples/scheduler_lifespan.py. on_shutdown: Optional sequence of :data:LifespanHook zero-arg async callables fired before either inner lifespan tears down. Every hook runs on a best-effort basis even if an earlier one raised; the first failure re-raises so Starlette surfaces it, later failures land in logger.error. Same transport="both" restriction as on_startup.

Example (MCP): from adcp.server import ADCPHandler, serve from adcp.server.responses import capabilities_response

class MyAgent(ADCPHandler):
    async def get_adcp_capabilities(self, params, context=None):
        return capabilities_response(["media_buy"])

serve(MyAgent(), name="my-agent")

Example (A2A): serve(MyAgent(), name="my-agent", transport="a2a")

With test controller: from adcp.server.test_controller import TestControllerStore

class MyStore(TestControllerStore):
    async def force_account_status(self, account_id, status):
        ...

serve(MyAgent(), name="my-agent", test_controller=MyStore())
def signals_response(signals: list[Any] | None = None,
*,
incomplete: list[Any] | None = None,
pagination: dict[str, Any] | None = None,
wholesale_feed_version: str | None = None,
pricing_version: str | None = None,
cache_scope: str | None = None,
unchanged: bool | None = None,
status: str = 'completed',
sandbox: bool = True) ‑> dict[str, typing.Any]
Expand source code
def signals_response(
    signals: list[Any] | None = None,
    *,
    incomplete: list[Any] | None = None,
    pagination: dict[str, Any] | None = None,
    wholesale_feed_version: str | None = None,
    pricing_version: str | None = None,
    cache_scope: str | None = None,
    unchanged: bool | None = None,
    status: str = "completed",
    sandbox: bool = True,
) -> dict[str, Any]:
    """Build a get_signals response.

    Each signal should include: signal_agent_segment_id, name, description,
    signal_type, data_provider, coverage_percentage, deployments, pricing_options, signal_id.
    Matches GetSignalsResponse schema, including beta 3 wholesale feed
    metadata for cache/version-aware enumeration. Pass ``cache_scope``
    explicitly for spec-valid wholesale responses; the dispatcher only infers
    ``public`` for request paths without an account.
    """
    resp: dict[str, Any] = {"status": status, "sandbox": sandbox}
    if signals is not None:
        resp["signals"] = _serialize(signals)
    if incomplete is not None:
        resp["incomplete"] = _serialize(incomplete)
    if pagination is not None:
        resp["pagination"] = pagination
    if wholesale_feed_version is not None:
        resp["wholesale_feed_version"] = wholesale_feed_version
    if pricing_version is not None:
        resp["pricing_version"] = pricing_version
    if cache_scope is not None:
        resp["cache_scope"] = cache_scope
    if unchanged is not None:
        resp["unchanged"] = unchanged
    return resp

Build a get_signals response.

Each signal should include: signal_agent_segment_id, name, description, signal_type, data_provider, coverage_percentage, deployments, pricing_options, signal_id. Matches GetSignalsResponse schema, including beta 3 wholesale feed metadata for cache/version-aware enumeration. Pass cache_scope explicitly for spec-valid wholesale responses; the dispatcher only infers public for request paths without an account.

def spec_compat_hooks(*,
exclude: Collection[str] | None = None,
creative_agent_url: str = 'https://creative.adcontextprotocol.org') ‑> dict[str, collections.abc.Callable[[str, dict[str, typing.Any]], dict[str, typing.Any]] | collections.abc.Sequence[collections.abc.Callable[[str, dict[str, typing.Any]], dict[str, typing.Any]]]]
Expand source code
def spec_compat_hooks(
    *,
    exclude: Collection[str] | None = None,
    creative_agent_url: str = CANONICAL_CREATIVE_AGENT_URL,
) -> PreValidationHooks:
    """Return built-in spec-compat hooks for pre-v3 / pre-4.4 buyers.

    .. deprecated:: 5.2
        This heuristic-coercion approach is being replaced by the typed
        per-tool adapter registry under :mod:`adcp.compat.legacy`. The
        adapter path validates against the buyer's claimed
        ``adcp_version`` instead of guessing wire shapes, scales
        bounded (one module per tool) instead of growing the hook dict
        unboundedly, and matches the JS SDK's approach.

        For ``sync_creatives``, the adapter (now shipped) replaces the
        format_id wrap / asset_type inference / image-demotion logic
        that this hook used to provide. Buyers claiming
        ``adcp_major_version=2`` or ``adcp_version="2.5"`` automatically
        route through it.

        Calling this function emits a :class:`DeprecationWarning`.
        Migrate by removing the ``pre_validation_hooks=spec_compat_hooks()``
        argument from your ``serve()`` call. Removal target: 6.0.
    """
    import warnings as _warnings

    _warnings.warn(
        "spec_compat_hooks() is deprecated and will be removed in 6.0. "
        "Buyers claiming adcp_version='2.5' or adcp_major_version=2 now "
        "route through adcp.compat.legacy adapters automatically — drop "
        "the pre_validation_hooks=spec_compat_hooks() argument from "
        "serve(). See the adcp.compat.legacy docstring for the migration "
        "path.",
        DeprecationWarning,
        stacklevel=2,
    )
    return _spec_compat_hooks_impl(exclude=exclude, creative_agent_url=creative_agent_url)

Return built-in spec-compat hooks for pre-v3 / pre-4.4 buyers.

Deprecated since version: 5.2

This heuristic-coercion approach is being replaced by the typed per-tool adapter registry under :mod:adcp.compat.legacy. The adapter path validates against the buyer's claimed adcp_version instead of guessing wire shapes, scales bounded (one module per tool) instead of growing the hook dict unboundedly, and matches the JS SDK's approach.

For sync_creatives, the adapter (now shipped) replaces the format_id wrap / asset_type inference / image-demotion logic that this hook used to provide. Buyers claiming adcp_major_version=2 or adcp_version="2.5" automatically route through it.

Calling this function emits a :class:DeprecationWarning. Migrate by removing the pre_validation_hooks=spec_compat_hooks() argument from your serve() call. Removal target: 6.0.

def sync_accounts_response(accounts: list[dict[str, Any]], *, sandbox: bool = True) ‑> dict[str, typing.Any]
Expand source code
def sync_accounts_response(
    accounts: list[dict[str, Any]],
    *,
    sandbox: bool = True,
) -> dict[str, Any]:
    """Build a sync_accounts success response.

    Each account dict should include: account_id, brand, operator,
    action ("created"|"updated"), status ("active"|"pending_approval").

    Matches SyncAccountsResponse1 schema (field: "accounts").

    Items pass through :func:`_serialize` so loose-dict adopters who
    spread an input ``governance_agents`` (with ``authentication``)
    or ``billing_entity`` (with ``bank``) onto the response get the
    write-only credential strip.
    """
    return {"accounts": _serialize(accounts), "sandbox": sandbox}

Build a sync_accounts success response.

Each account dict should include: account_id, brand, operator, action ("created"|"updated"), status ("active"|"pending_approval").

Matches SyncAccountsResponse1 schema (field: "accounts").

Items pass through :func:_serialize so loose-dict adopters who spread an input governance_agents (with authentication) or billing_entity (with bank) onto the response get the write-only credential strip.

def sync_catalogs_response(catalogs: list[dict[str, Any]], *, sandbox: bool = True) ‑> dict[str, typing.Any]
Expand source code
def sync_catalogs_response(
    catalogs: list[dict[str, Any]],
    *,
    sandbox: bool = True,
) -> dict[str, Any]:
    """Build a sync_catalogs success response.

    Each catalog should include: catalog_id, action, item_count, items_approved.
    Matches SyncCatalogsResponse1 (success) schema.
    """
    return {
        "catalogs": catalogs,
        "sandbox": sandbox,
    }

Build a sync_catalogs success response.

Each catalog should include: catalog_id, action, item_count, items_approved. Matches SyncCatalogsResponse1 (success) schema.

def sync_creatives_response(creatives: list[dict[str, Any]], *, sandbox: bool = True) ‑> dict[str, typing.Any]
Expand source code
def sync_creatives_response(
    creatives: list[dict[str, Any]],
    *,
    sandbox: bool = True,
) -> dict[str, Any]:
    """Build a sync_creatives success response.

    Each creative dict should include: creative_id, action ("created"|"updated").
    Optionally: status ("processing"|"pending_review"|"approved"|"rejected"|"archived").
    Matches SyncCreativesResponse1 schema (field: "creatives").
    """
    return {"creatives": _serialize(creatives), "sandbox": sandbox}

Build a sync_creatives success response.

Each creative dict should include: creative_id, action ("created"|"updated"). Optionally: status ("processing"|"pending_review"|"approved"|"rejected"|"archived"). Matches SyncCreativesResponse1 schema (field: "creatives").

def sync_governance_response(accounts: list[dict[str, Any]], *, sandbox: bool = True) ‑> dict[str, typing.Any]
Expand source code
def sync_governance_response(
    accounts: list[dict[str, Any]],
    *,
    sandbox: bool = True,
) -> dict[str, Any]:
    """Build a sync_governance response.

    Each account dict should include: account, status ("synced"),
    governance_agents ([{url, categories}]).

    Items pass through :func:`_serialize` so loose-dict adopters who
    spread an input ``governance_agents`` (with ``authentication``)
    onto the response get the write-only credential strip.
    """
    return {"accounts": _serialize(accounts), "sandbox": sandbox}

Build a sync_governance response.

Each account dict should include: account, status ("synced"), governance_agents ([{url, categories}]).

Items pass through :func:_serialize so loose-dict adopters who spread an input governance_agents (with authentication) onto the response get the write-only credential strip.

def update_media_buy_response(media_buy_id: str,
*,
affected_packages: list[Any] | None = None,
status: str | None = None,
valid_actions: list[str] | None = None,
revision: int | None = None,
adcp_version: str | None = None,
sandbox: bool = True) ‑> dict[str, typing.Any]
Expand source code
def update_media_buy_response(
    media_buy_id: str,
    *,
    affected_packages: list[Any] | None = None,
    status: str | None = None,
    valid_actions: list[str] | None = None,
    revision: int | None = None,
    adcp_version: str | None = None,
    sandbox: bool = True,
) -> dict[str, Any]:
    """Build an update_media_buy success response.

    Matches UpdateMediaBuyResponse1 (success) schema.
    Auto-populates valid_actions from status if not provided. ``revision`` is
    the new optimistic-concurrency token after the update; clients should use
    it on their next mutating ``update_media_buy`` call. Current AdCP response
    shapes require ``revision``. Only explicit pre-3.1 compatibility output may
    omit it.
    Pass ``adcp_version="3.0"`` for the 3.0 top-level lifecycle status
    shape, or an exact 3.1+ supported version for the task-envelope shape
    (``status="completed"`` plus ``media_buy_status``). When omitted, the
    dispatcher projects by the buyer's requested version.
    """
    if (adcp_version is None or _is_adcp_31_or_newer(adcp_version)) and revision is None:
        raise ValueError("revision is required for AdCP 3.1+ update_media_buy_response")

    resp: dict[str, Any] = {
        "media_buy_id": media_buy_id,
        "sandbox": sandbox,
    }
    if revision is not None:
        resp["revision"] = revision
    if affected_packages is not None:
        resp["affected_packages"] = _serialize(affected_packages)
    if status is not None:
        if adcp_version is None or _is_adcp_31_or_newer(adcp_version):
            resp["media_buy_status"] = status
        else:
            resp["status"] = status
        if valid_actions is None:
            resp["valid_actions"] = valid_actions_for_status(status)
        else:
            resp["valid_actions"] = valid_actions
    elif valid_actions is not None:
        resp["valid_actions"] = valid_actions
    if adcp_version is not None and _is_adcp_31_or_newer(adcp_version):
        resp["status"] = "completed"
    return resp

Build an update_media_buy success response.

Matches UpdateMediaBuyResponse1 (success) schema. Auto-populates valid_actions from status if not provided. revision is the new optimistic-concurrency token after the update; clients should use it on their next mutating update_media_buy call. Current AdCP response shapes require revision. Only explicit pre-3.1 compatibility output may omit it. Pass adcp_version="3.0" for the 3.0 top-level lifecycle status shape, or an exact 3.1+ supported version for the task-envelope shape (status="completed" plus media_buy_status). When omitted, the dispatcher projects by the buyer's requested version.

def valid_actions_for_status(status: str) ‑> list[str]
Expand source code
def valid_actions_for_status(status: str) -> list[str]:
    """Get valid buyer actions for a media buy status.

    Returns the list of ``update_media_buy`` actions available to a buyer for
    the given status string. Returns ``[]`` for terminal statuses and for any
    unrecognized status string.

    Valid statuses per ``enums/media-buy-status.json``:
    ``pending_creatives``, ``pending_start``, ``active``, ``paused``,
    ``completed``, ``rejected``, ``canceled``.

    Inspect or extend :data:`MEDIA_BUY_STATE_MACHINE` to add custom actions.
    """
    return list(MEDIA_BUY_STATE_MACHINE.get(status, []))

Get valid buyer actions for a media buy status.

Returns the list of update_media_buy actions available to a buyer for the given status string. Returns [] for terminal statuses and for any unrecognized status string.

Valid statuses per enums/media-buy-status.json: pending_creatives, pending_start, active, paused, completed, rejected, canceled.

Inspect or extend :data:MEDIA_BUY_STATE_MACHINE to add custom actions.

def validate_capabilities(handler: Any, capabilities: GetAdcpCapabilitiesResponse) ‑> list[str]
Expand source code
def validate_capabilities(
    handler: Any,
    capabilities: GetAdcpCapabilitiesResponse,
) -> list[str]:
    """Check that a handler implements the methods required by its declared features.

    Compares the features declared in a capabilities response against the handler's
    method implementations. Returns warnings for features that are declared but
    whose corresponding handler methods are not overridden from the base class.

    This is a development-time check — call it at startup to catch misconfigurations.

    Args:
        handler: An ADCPHandler instance (or any object with handler methods).
        capabilities: The capabilities response the handler will serve.

    Returns:
        List of warning strings. Empty if everything is consistent.
    """
    # Late import to avoid circular dependency: server.base imports from adcp.types
    # which may transitively import from this module.
    from adcp.server.base import ADCPHandler

    resolver = FeatureResolver(capabilities)
    warnings: list[str] = []

    for feature, handler_methods in FEATURE_HANDLER_MAP.items():
        if not resolver.supports(feature):
            continue

        for method_name in handler_methods:
            if not hasattr(handler, method_name):
                warnings.append(
                    f"Feature '{feature}' is declared but handler has no " f"'{method_name}' method"
                )
                continue

            # Walk MRO to check if any class between the leaf and ADCPHandler
            # overrides the method (handles mixin / intermediate-class patterns).
            if isinstance(handler, ADCPHandler):
                overridden = any(
                    method_name in cls.__dict__
                    for cls in type(handler).__mro__
                    if cls is not ADCPHandler and not issubclass(ADCPHandler, cls)
                )
                if not overridden:
                    warnings.append(
                        f"Feature '{feature}' is declared but '{method_name}' "
                        f"is not overridden from ADCPHandler"
                    )

    return warnings

Check that a handler implements the methods required by its declared features.

Compares the features declared in a capabilities response against the handler's method implementations. Returns warnings for features that are declared but whose corresponding handler methods are not overridden from the base class.

This is a development-time check — call it at startup to catch misconfigurations.

Args

handler
An ADCPHandler instance (or any object with handler methods).
capabilities
The capabilities response the handler will serve.

Returns

List of warning strings. Empty if everything is consistent.

def validate_discovery_set(tools: Iterable[str]) ‑> None
Expand source code
def validate_discovery_set(tools: Iterable[str]) -> None:
    """Fail-closed validation for an auth-optional tool set.

    Downstream that extends :data:`DISCOVERY_TOOLS` (``DISCOVERY_TOOLS |
    {"my_public_tool"}``) risks accidentally including a mutation tool,
    which would silently unauthenticate writes over HTTP. This helper
    asserts every name in the set resolves to a known ADCP tool whose
    annotations declare ``readOnlyHint: True`` — it refuses to pass
    anything mutating, destructive, or unknown.

    Call this at server startup on the effective set your middleware
    uses::

        from adcp.server import DISCOVERY_TOOLS, validate_discovery_set

        MY_DISCOVERY = DISCOVERY_TOOLS | {"list_public_formats"}
        validate_discovery_set(MY_DISCOVERY)  # raises early if misconfigured

    :raises ValueError: if any name in ``tools`` is unknown or resolves
        to a non-read-only tool.
    """
    by_name = {t["name"]: t for t in ADCP_TOOL_DEFINITIONS}
    unknown: list[str] = []
    mutating: list[str] = []
    for name in tools:
        tool = by_name.get(name)
        if tool is None:
            unknown.append(name)
            continue
        annotations = tool.get("annotations") or {}
        if not annotations.get("readOnlyHint"):
            mutating.append(name)
    problems: list[str] = []
    if unknown:
        problems.append(f"unknown tool(s): {sorted(unknown)}")
    if mutating:
        problems.append(
            f"non-read-only tool(s) {sorted(mutating)} — adding these to the "
            "auth-optional set would silently unauthenticate mutations"
        )
    if problems:
        raise ValueError("validate_discovery_set rejected the set: " + "; ".join(problems))

Fail-closed validation for an auth-optional tool set.

Downstream that extends :data:DISCOVERY_TOOLS (DISCOVERY_TOOLS | {"my_public_tool"}) risks accidentally including a mutation tool, which would silently unauthenticate writes over HTTP. This helper asserts every name in the set resolves to a known ADCP tool whose annotations declare readOnlyHint: True — it refuses to pass anything mutating, destructive, or unknown.

Call this at server startup on the effective set your middleware uses::

from adcp.server import DISCOVERY_TOOLS, validate_discovery_set

MY_DISCOVERY = DISCOVERY_TOOLS | {"list_public_formats"}
validate_discovery_set(MY_DISCOVERY)  # raises early if misconfigured

:raises ValueError: if any name in tools is unknown or resolves to a non-read-only tool.

def validator_from_token_map(token_map: Mapping[str, Principal]) ‑> SyncTokenValidator
Expand source code
def validator_from_token_map(
    token_map: Mapping[str, Principal],
) -> SyncTokenValidator:
    """Build a :data:`TokenValidator` from a ``{raw_token: Principal}`` map.

    The shape most demo/test agents actually need — a fixed set of
    tokens mapped to principals — without having to write the
    constant-time plumbing. The returned validator hashes each raw
    token at construction time and does constant-time lookups via
    :func:`hmac.compare_digest` on every call, matching the security
    properties of a hand-rolled validator::

        validate_token = validator_from_token_map({
            "token-acme": Principal(caller_identity="p-acme", tenant_id="acme"),
            "token-globex": Principal(caller_identity="p-globex", tenant_id="globex"),
        })
        app.add_middleware(BearerTokenAuthMiddleware, validate_token=validate_token)

    Production agents looking tokens up in Postgres / Redis / Vault
    should write their own async validator instead — this helper is
    for the small-fixed-set case (demo, test, CI fixtures).

    :param token_map: Mapping of raw bearer tokens to their resolved
        :class:`Principal`. Tokens are hashed at construction; the
        plaintext is not retained.
    :returns: A :data:`SyncTokenValidator` (which satisfies
        :data:`TokenValidator`).
    """
    stored_hashes: dict[str, Principal] = {
        hashlib.sha256(token.encode()).hexdigest(): principal
        for token, principal in token_map.items()
    }

    def _validate(token: str) -> Principal | None:
        return constant_time_token_match(token, stored_hashes)

    return _validate

Build a :data:TokenValidator from a {raw_token: Principal} map.

The shape most demo/test agents actually need — a fixed set of tokens mapped to principals — without having to write the constant-time plumbing. The returned validator hashes each raw token at construction time and does constant-time lookups via :func:hmac.compare_digest on every call, matching the security properties of a hand-rolled validator::

validate_token = validator_from_token_map({
    "token-acme": Principal(caller_identity="p-acme", tenant_id="acme"),
    "token-globex": Principal(caller_identity="p-globex", tenant_id="globex"),
})
app.add_middleware(BearerTokenAuthMiddleware, validate_token=validate_token)

Production agents looking tokens up in Postgres / Redis / Vault should write their own async validator instead — this helper is for the small-fixed-set case (demo, test, CI fixtures).

:param token_map: Mapping of raw bearer tokens to their resolved :class:Principal. Tokens are hashed at construction; the plaintext is not retained. :returns: A :data:SyncTokenValidator (which satisfies :data:TokenValidator).

Classes

class A2ABearerAuthMiddleware (app: Any,
config: BearerTokenAuth)
Expand source code
class A2ABearerAuthMiddleware:
    """Pure-ASGI middleware that gates A2A JSON-RPC on a bearer token.

    Wrap the Starlette app produced by
    :func:`adcp.server.a2a_server.create_a2a_server` with this
    middleware to require a valid bearer header on every JSON-RPC
    request, while leaving the spec-mandated public discovery
    surface (``/.well-known/agent-card.json`` and the 0.3 alias
    ``/.well-known/agent.json``) accessible.

    Designed to compose with a2a-sdk's
    :class:`DefaultServerCallContextBuilder`: on auth success the
    middleware writes a duck-typed user object into
    ``scope['user']`` and the principal into ``scope['auth']``,
    matching Starlette's :class:`AuthenticationMiddleware` contract.
    The default builder reads ``scope['user']`` and adapts it via
    :class:`a2a.server.routes.common.StarletteUser`, so downstream
    handlers see ``ServerCallContext.user.user_name`` populated with
    the principal's ``caller_identity`` without a custom builder.

    Also populates :data:`current_principal`, :data:`current_tenant`,
    and :data:`current_principal_metadata` for the duration of the
    downstream call — symmetric with
    :class:`BearerTokenAuthMiddleware`'s contract. Adopters reading
    ``current_principal.get()`` from a platform method see identical
    state on MCP and A2A.

    Composition order matters when ``transport="both"`` is in play:
    wrap the per-leg apps before any outer dispatcher closes over
    them. See ``serve.py:_build_mcp_and_a2a_app`` for the wiring.
    """

    def __init__(self, app: Any, config: BearerTokenAuth) -> None:
        self._app = app
        self._config = config
        self._header_name = config.resolved_a2a_header_name().lower()
        self._bearer_prefix_required = config.resolved_a2a_bearer_prefix_required()

    def _has_bearer(self, scope: Any) -> bool:
        """True if the request carries any non-empty auth header.

        Used only to distinguish "no credential" (pass through under
        ``allow_unauthenticated``) from "credential present but invalid"
        (still rejected). Checks the canonical ``authorization`` header and
        the configured A2A header alias.
        """
        wanted = {"authorization", self._header_name}
        for raw_name, raw_value in scope.get("headers", []):
            if raw_name.decode("latin-1").lower() in wanted and raw_value.strip():
                return True
        return False

    async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
        # Lifespan + websocket pass through unchanged. Auth applies to
        # HTTP requests only.
        if scope.get("type") != "http":
            await self._app(scope, receive, send)
            return

        # CORS preflight is part of the public surface — browser-origin
        # clients send ``OPTIONS`` before any auth'd POST. Returning 401
        # here breaks the preflight and the buyer never gets a chance to
        # retry with a token. Pass through; let the inner app's CORS
        # handler (or operator-supplied ``asgi_middleware``) respond.
        if scope.get("method") == "OPTIONS":
            await self._app(scope, receive, send)
            return

        path = scope.get("path", "")
        if path in _A2A_DISCOVERY_PATHS:
            await self._app(scope, receive, send)
            return

        # Network-trust: a request with NO bearer is passed through — the host
        # resolves identity downstream from trusted headers (the agent is
        # reachable only via the host's authenticated proxy). A token that IS
        # present but invalid still falls through to rejection below.
        if self._config.allow_unauthenticated and not self._has_bearer(scope):
            await self._app(scope, receive, send)
            return

        principal = self._authenticate_scope(scope)
        if principal is None:
            await self._send_unauthenticated(send)
            return

        # Stash both the duck-typed user (for DefaultServerCallContextBuilder)
        # and the raw Principal (for downstream code reading scope['auth']).
        # Mutating the scope dict before delegating propagates state to
        # nested apps without copying.
        principal_metadata = dict(principal.metadata) if principal.metadata else None
        scope["user"] = _A2AAuthenticatedUser(
            display_name=principal.caller_identity,
            tenant_id=principal.tenant_id,
            principal_metadata=principal_metadata,
        )
        scope["auth"] = principal

        # Populate the same ContextVars MCP's ``BearerTokenAuthMiddleware``
        # sets, so adopters reading ``current_principal.get()`` (or the
        # other two) from a platform method see identical state across
        # transports. Without this, A2A handlers fall through to the
        # ``None`` default while MCP handlers see the principal — a silent
        # transport-coupled divergence that breaks tenant policies that
        # require principal-bound calls. See issue #590.
        #
        # ContextVars carry on the A2A leg because the dispatch runs in
        # the same async task as this middleware (no session-task seam
        # like MCP stateful streamable-http). The MCP leg's mirror onto
        # ``request.state`` is what survives the stateful session-task
        # boundary; A2A's dispatcher reads ContextVars directly. If A2A
        # ever grows a long-lived dispatch task that decouples from the
        # request task, we'll need to thread the request through
        # ``RequestMetadata`` on the A2A side too.
        principal_token = current_principal.set(principal.caller_identity)
        tenant_token = current_tenant.set(principal.tenant_id)
        metadata_token = current_principal_metadata.set(principal_metadata)
        try:
            await self._app(scope, receive, send)
        finally:
            current_principal.reset(principal_token)
            current_tenant.reset(tenant_token)
            current_principal_metadata.reset(metadata_token)

    def _authenticate_scope(self, scope: Any) -> Principal | None:
        """Read + validate the bearer header off raw ASGI scope.

        Validator exceptions are projected to :data:`None` (logged for
        operators) so a buggy validator never leaks 500-level stack
        traces or signals path existence to unauthenticated callers.
        Auth-rejection branches log at INFO with a coarse reason code
        so SOC dashboards can detect scanning without bloating logs.
        """
        # ASGI ``headers`` is a list of ``(bytes_lower, bytes)`` tuples.
        target = self._header_name.encode("latin-1")
        raw_value: bytes | None = None
        for name, value in scope.get("headers", ()):
            if name == target:
                raw_value = value
                break

        if raw_value is None:
            logger.info("a2a auth rejected", extra={"reason": "missing_header"})
            return None

        try:
            raw_header = raw_value.decode("latin-1")
        except UnicodeDecodeError:
            logger.info("a2a auth rejected", extra={"reason": "header_decode"})
            return None

        if self._bearer_prefix_required:
            bearer = _parse_bearer_header(raw_header)
        else:
            stripped = raw_header.strip()
            bearer = stripped or None
        if not bearer:
            logger.info("a2a auth rejected", extra={"reason": "wrong_scheme"})
            return None

        try:
            raw = self._config.validate_token(bearer)
        except Exception:
            logger.exception("token validator raised on A2A request")
            return None

        if inspect.isawaitable(raw):
            # Should be unreachable — :func:`_assert_sync_validator` at
            # config time rejects async validators before any traffic
            # lands. This branch is the in-depth catch in case an
            # adopter swaps in an async validator at runtime via a
            # closure that conditionally awaits.
            logger.error(
                "a2a auth rejected: validator returned awaitable at request "
                "time. Async validators are not supported on the A2A leg; "
                "wrap with a sync bridge."
            )
            return None

        if raw is None:
            logger.info("a2a auth rejected", extra={"reason": "invalid_token"})
            return None
        return raw

    async def _send_unauthenticated(self, send: Any) -> None:
        body_obj = self._config.unauthenticated_response or {
            "error": "invalid_token",
            "error_description": "Bearer token missing or invalid",
        }
        body = json.dumps(body_obj).encode("utf-8")
        # RFC 6750 §3 + RFC 7235 §3.1 require ``WWW-Authenticate: Bearer``
        # on every 401. Shared constant with the MCP leg (RFC 7235 §2.2
        # — same protection space).
        await send(
            {
                "type": "http.response.start",
                "status": 401,
                "headers": [
                    (b"content-type", b"application/json"),
                    (b"content-length", str(len(body)).encode("latin-1")),
                    (b"www-authenticate", _WWW_AUTHENTICATE_CHALLENGE.encode("ascii")),
                ],
            }
        )
        await send({"type": "http.response.body", "body": body})

Pure-ASGI middleware that gates A2A JSON-RPC on a bearer token.

Wrap the Starlette app produced by :func:create_a2a_server() with this middleware to require a valid bearer header on every JSON-RPC request, while leaving the spec-mandated public discovery surface (/.well-known/agent-card.json and the 0.3 alias /.well-known/agent.json) accessible.

Designed to compose with a2a-sdk's :class:DefaultServerCallContextBuilder: on auth success the middleware writes a duck-typed user object into scope['user'] and the principal into scope['auth'], matching Starlette's :class:AuthenticationMiddleware contract. The default builder reads scope['user'] and adapts it via :class:a2a.server.routes.common.StarletteUser, so downstream handlers see ServerCallContext.user.user_name populated with the principal's caller_identity without a custom builder.

Also populates :data:current_principal, :data:current_tenant(), and :data:current_principal_metadata for the duration of the downstream call — symmetric with :class:BearerTokenAuthMiddleware's contract. Adopters reading current_principal.get() from a platform method see identical state on MCP and A2A.

Composition order matters when transport="both" is in play: wrap the per-leg apps before any outer dispatcher closes over them. See serve.py:_build_mcp_and_a2a_app for the wiring.

class ADCPAgentExecutor (handler: ADCPHandler[Any],
test_controller: TestControllerStore | None = None,
*,
context_factory: ContextFactory | None = None,
middleware: Sequence[SkillMiddleware] | None = None,
message_parser: MessageParser | None = None,
advertise_all: bool = False,
validation: ValidationHookConfig | None = ValidationHookConfig(requests='strict', responses='strict', unknown_fields=None),
pre_validation_hooks: PreValidationHooks | None = None,
test_controller_account_resolver: Any | None = None,
response_enhancer: ResponseEnhancer | None = None)
Expand source code
class ADCPAgentExecutor(AgentExecutor):
    """Bridges ADCPHandler methods to the a2a-sdk AgentExecutor interface.

    Incoming A2A messages are parsed to extract the ADCP skill name and
    parameters, dispatched to the matching handler method, and the result
    is published back as A2A Task events.

    Expects the explicit skill invocation format used by A2AAdapter:
        Part(data={"skill": "get_products", "parameters": {...}})
    """

    def __init__(
        self,
        handler: ADCPHandler[Any],
        test_controller: TestControllerStore | None = None,
        *,
        context_factory: ContextFactory | None = None,
        middleware: Sequence[SkillMiddleware] | None = None,
        message_parser: MessageParser | None = None,
        advertise_all: bool = False,
        validation: ValidationHookConfig | None = SERVER_DEFAULT_VALIDATION,
        pre_validation_hooks: PreValidationHooks | None = None,
        test_controller_account_resolver: Any | None = None,
        response_enhancer: ResponseEnhancer | None = None,
    ) -> None:
        self._handler = handler
        self._context_factory = context_factory
        self._test_controller_account_resolver = test_controller_account_resolver
        self._response_enhancer = response_enhancer
        # Store as a tuple so the executor can't be mutated from underneath
        # at runtime (a flaky test or a handler reaching self._middleware
        # can't corrupt the dispatch chain). Tuple ordering = runtime
        # ordering; first entry wraps outermost (see ``SkillMiddleware``
        # docstring for the composition semantics).
        self._middleware: tuple[SkillMiddleware, ...] = tuple(middleware or ())
        # Seller-supplied parser for non-default wire shapes (JSON-RPC,
        # bare TextPart with different skill layout, etc.). Falls back
        # to the built-in parser when None.
        self._message_parser: MessageParser | None = message_parser
        self._tool_callers: dict[str, Any] = {}

        # Build tool callers for all tools this handler supports.
        # Skip comply_test_controller unless the seller passed a
        # TestControllerStore; otherwise we would advertise a skill
        # backed only by the handler's not-supported stub.
        tool_defs = get_tools_for_handler(handler, advertise_all=advertise_all)
        for tool_def in tool_defs:
            name = tool_def["name"]
            if name == "comply_test_controller" and test_controller is None:
                continue
            hook = (pre_validation_hooks or {}).get(name)
            self._tool_callers[name] = create_tool_caller(
                handler,
                name,
                validation=validation,
                pre_validation_hook=hook,
                default_unnegotiated_adcp_version=None,
                response_enhancer=response_enhancer,
            )

        if test_controller is not None:
            self._register_test_controller(test_controller)

    @property
    def supported_skills(self) -> list[str]:
        """List of skill names this executor can handle."""
        return list(self._tool_callers.keys())

    def _register_test_controller(self, store: TestControllerStore) -> None:
        """Register comply_test_controller as a callable skill.

        Threads the ToolContext that the A2A executor built for this
        dispatch into the store so header-driven test state (populated
        by ``context_factory`` from ``ServerCallContext.user`` /
        message-metadata headers) composes with the storyboard-driven
        ``comply_test_controller`` skill. See #227.
        """

        resolver = self._test_controller_account_resolver
        response_enhancer = self._response_enhancer

        async def _call_test_controller(
            params: dict[str, Any], context: ToolContext | None = None
        ) -> Any:
            result = await _handle_test_controller(
                store,
                params,
                context=context,
                account_resolver=resolver,
            )
            # This skill bypasses ``create_tool_caller`` (the success-path
            # enhancer site), so apply the enhancer here too — otherwise
            # comply responses would silently skip the seller's
            # cross-cutting stamp. Echo context first so the enhancer runs
            # after the credential-stripped envelope is assembled (the
            # later ``_send_result`` ``inject_context`` then no-ops),
            # preserving the credential-echo invariant the other sites
            # uphold.
            if isinstance(result, dict):
                from adcp.server.helpers import inject_context

                inject_context(params, result)
                _apply_response_enhancer(
                    response_enhancer, "comply_test_controller", result, context
                )
            return result

        self._tool_callers["comply_test_controller"] = _call_test_controller

    async def execute(self, context: RequestContext, event_queue: EventQueue) -> None:
        """Execute an ADCP skill from an incoming A2A message."""
        skill_name, params = self._parse_request(context)

        if skill_name is None:
            await self._send_error(event_queue, context, "No skill specified in message")
            return

        if skill_name not in self._tool_callers:
            await self._send_error(event_queue, context, f"Unknown skill: {skill_name}")
            return

        tool_context = self._build_tool_context(skill_name, context)
        # Catch both the client-side :class:`ADCPError` (raised by
        # framework helpers like ``IdempotencyConflictError``) AND the
        # decisioning-layer :class:`AdcpError` (raised by platform methods
        # adopters write against the decisioning graph). They are
        # disjoint hierarchies; both project onto the same structured
        # ``adcp_error`` envelope per transport-errors.mdx §A2A Binding.
        structured_error_types: tuple[type[BaseException], ...] = (
            ADCPError,
            *_DECISIONING_ADCP_ERROR_TYPES,
        )
        try:
            result = await self._dispatch_with_middleware(skill_name, params, tool_context)
            # ``params`` carries the parsed wire request including any
            # ``context`` extension. Both success and error paths thread
            # it through to the result builder so the context-passthrough
            # contract holds across the dispatch outcome.
            await self._send_result(event_queue, context, skill_name, result, params)
        except structured_error_types as exc:
            # Application-layer AdCP error. Emit a failed task with the
            # adcp_error in a DataPart per transport-errors.mdx §A2A
            # Binding, plus a human-readable text part. The JSON-RPC
            # channel is reserved for transport-level errors (auth
            # rejected, rate-limited pre-dispatch).
            logger.info("AdCP application error for skill %s: %s", skill_name, exc)
            await self._send_adcp_error(
                event_queue, context, exc, params, skill_name=skill_name, tool_context=tool_context
            )
        except Exception:
            logger.exception("Error executing skill %s", skill_name)
            await self._send_error(event_queue, context, f"Skill execution failed: {skill_name}")

    async def _dispatch_with_middleware(
        self,
        skill_name: str,
        params: dict[str, Any],
        tool_context: ToolContext,
    ) -> Any:
        """Run the handler wrapped in the configured middleware chain.

        Delegates to :func:`adcp.server.serve._dispatch_with_middleware`
        so the composition semantics stay identical between transports —
        middleware that works with ``create_a2a_server(middleware=...)``
        works unchanged with ``create_mcp_server(middleware=...)``.

        Middleware exceptions propagate to the executor's normal error
        handling path in ``execute()``; this method does no try/except
        so short-circuiting, transform, and exception-observation all
        work the same way they do for the underlying handler.
        """
        from adcp.server.serve import _dispatch_with_middleware

        async def _call_handler() -> Any:
            return await self._tool_callers[skill_name](params, tool_context)

        return await _dispatch_with_middleware(
            self._middleware, skill_name, params, tool_context, _call_handler
        )

    def _build_tool_context(self, skill_name: str, request: RequestContext) -> ToolContext:
        """Build the :class:`ToolContext` handed to the skill dispatcher.

        When ``context_factory`` is configured, call it with a
        :class:`RequestMetadata` describing this A2A invocation; overlay the
        transport-derived ``caller_identity`` / ``request_id`` afterwards
        **only when the factory left them unset**, so factories that already
        know the principal (e.g. from a ContextVar the seller's auth layer
        populated) aren't clobbered.

        When no factory is configured, fall back to the A2A-only path that
        derives ``caller_identity`` from ``ServerCallContext.user`` —
        preserving behavior for sellers who haven't adopted
        ``context_factory=`` yet.
        """
        if self._context_factory is None:
            return _tool_context_from_request(request)

        from adcp.server.serve import RequestMetadata

        meta = RequestMetadata(
            tool_name=skill_name,
            transport="a2a",
            request_id=request.task_id,
        )
        ctx = self._context_factory(meta)
        if not isinstance(ctx, ToolContext):
            raise TypeError(
                f"context_factory for skill {skill_name!r} returned "
                f"{type(ctx).__name__}, not a ToolContext instance"
            )
        # Fill in transport-derived fields the factory didn't set. This
        # preserves the pre-factory A2A security invariant: if the seller
        # didn't explicitly populate caller_identity in their factory,
        # fall through to ServerCallContext.user (verified by the a2a-sdk
        # auth middleware) rather than silently sending None.
        if ctx.caller_identity is None:
            fallback = _tool_context_from_request(request)
            ctx.caller_identity = fallback.caller_identity
        if ctx.request_id is None:
            ctx.request_id = request.task_id
        return ctx

    async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
        """ADCP operations are synchronous; cancellation sets state to canceled."""
        event = _make_task(
            context,
            state=pb.TaskState.TASK_STATE_CANCELED,
            message="Task canceled",
        )
        await event_queue.enqueue_event(event)

    # ------------------------------------------------------------------
    # Message parsing
    # ------------------------------------------------------------------

    def _parse_request(self, context: RequestContext) -> tuple[str | None, dict[str, Any]]:
        """Extract skill name and parameters from the A2A message.

        Dispatches to the caller-supplied :data:`MessageParser` when the
        executor was constructed with ``message_parser=``; otherwise
        falls through to :meth:`_default_parse_request`, which supports
        the standard shapes (DataPart with explicit skill + TextPart
        JSON fallback).
        """
        if self._message_parser is not None:
            return self._message_parser(context)
        return self._default_parse_request(context)

    def _default_parse_request(self, context: RequestContext) -> tuple[str | None, dict[str, Any]]:
        """Built-in parser. Supports two formats:

        1. Explicit skill invocation via a DataPart:
           ``Part(data={"skill": "get_products", "parameters": {...}})``
        2. Natural language fallback via TextPart (best-effort parse)

        Exposed as a module-level method so custom parsers can compose
        it — e.g. "try my JSON-RPC parser first, fall through to the
        default for legacy clients".
        """
        msg = context.message
        if msg is None or not msg.parts:
            return None, {}

        # Try DataPart first (explicit skill invocation)
        for part in msg.parts:
            data = _part_data_dict(part)
            if data is None:
                continue
            skill = data.get("skill")
            params = data.get("parameters", {})
            if skill:
                return str(skill), _normalize_a2a_parameters(params)

        # Fallback: try to parse TextPart as JSON
        for part in msg.parts:
            text = _part_text(part)
            if text is None:
                continue
            parsed = self._parse_text_request(text)
            if parsed[0] is not None:
                return parsed

        return None, {}

    def _parse_text_request(self, text: str) -> tuple[str | None, dict[str, Any]]:
        """Best-effort parse of a text request for skill + params."""
        try:
            data = json.loads(text)
            if isinstance(data, dict) and "skill" in data:
                return str(data["skill"]), _normalize_a2a_parameters(data.get("parameters", {}))
        except (json.JSONDecodeError, TypeError):
            pass
        return None, {}

    # ------------------------------------------------------------------
    # Response helpers
    # ------------------------------------------------------------------

    async def _send_result(
        self,
        event_queue: EventQueue,
        context: RequestContext,
        skill_name: str,
        result: Any,
        params: dict[str, Any] | None = None,
    ) -> None:
        """Publish a completed task with the skill result.

        When ``params`` is supplied and carries a wire ``context`` field,
        echo it onto the result DataPart per the AdCP context-passthrough
        contract. This mirrors the MCP success path's
        :func:`adcp.server.helpers.inject_context` call in
        :mod:`adcp.server.mcp_tools` and keeps the error path's echo
        (see :meth:`_send_adcp_error`) symmetric on A2A.
        """
        # Normalize result to a JSON-safe dict
        if hasattr(result, "model_dump"):
            data = result.model_dump(mode="json", exclude_none=True)
        elif not isinstance(result, dict):
            data = {"result": result}
        else:
            data = result

        if params is not None and isinstance(data, dict):
            from adcp.server.helpers import inject_context

            inject_context(params, data)

        task = _make_task(
            context,
            state=pb.TaskState.TASK_STATE_COMPLETED,
            data=data,
            message=f"Completed {skill_name}",
        )
        await event_queue.enqueue_event(task)

    async def _send_error(
        self,
        event_queue: EventQueue,
        context: RequestContext,
        error_msg: str,
    ) -> None:
        """Publish a failed task."""
        task = _make_task(
            context,
            state=pb.TaskState.TASK_STATE_FAILED,
            message=error_msg,
        )
        await event_queue.enqueue_event(task)

    async def _send_adcp_error(
        self,
        event_queue: EventQueue,
        context: RequestContext,
        exc: Any,
        params: dict[str, Any] | None = None,
        *,
        skill_name: str = "",
        tool_context: ToolContext | None = None,
    ) -> None:
        """Publish a failed task carrying an AdCP ``adcp_error`` payload.

        Follows transport-errors.mdx §A2A Binding: failed task with artifact
        containing a ``DataPart`` keyed under ``adcp_error`` plus a terse
        ``TextPart`` for human/LLM consumption.

        The structured envelope carries the full spec shape — ``code``,
        ``message``, ``recovery``, ``field``, ``suggestion``,
        ``retry_after``, ``details`` — populated when the raised
        exception supplies them, omitted when ``None``. Field extraction
        is shared with the MCP path via
        :func:`adcp.server.translate._extract_structured_fields`, so
        both transports project off the same source-of-truth shape.

        When ``params`` is supplied and carries a wire ``context`` field,
        that field is echoed alongside ``adcp_error`` in the DataPart —
        symmetric with the success path's
        :func:`adcp.server.helpers.inject_context` call. Without this
        echo, error responses violate the AdCP context-passthrough
        contract and buyers lose correlation IDs across the
        raise-AdcpError boundary.
        """
        # Lazy import — ``translate.py`` pulls in heavier server deps
        # (mcp.types) which the A2A module doesn't otherwise need.
        from adcp.server.helpers import inject_context
        from adcp.server.translate import _extract_structured_fields

        code, message, recovery, field, suggestion, details, _errors = _extract_structured_fields(
            exc
        )

        adcp_error: dict[str, Any] = {
            "code": code,
            "message": message,
            "recovery": recovery,
        }
        if field is not None:
            adcp_error["field"] = field
        if suggestion is not None:
            adcp_error["suggestion"] = suggestion
        # ``retry_after`` lives on decisioning AdcpError; project when present.
        retry_after = getattr(exc, "retry_after", None)
        if retry_after is not None:
            adcp_error["retry_after"] = retry_after
        if details:
            adcp_error["details"] = dict(details)

        data: dict[str, Any] = {"adcp_error": adcp_error}
        if params is not None:
            inject_context(params, data)

        # Run the seller's response enhancer on the error envelope AFTER
        # the context echo (so a stripped credential can't be
        # re-introduced) — symmetric with the MCP error path
        # (``build_mcp_error_result``) and the success path. A buggy
        # enhancer is caught and logged inside the helper.
        _apply_response_enhancer(self._response_enhancer, skill_name, data, tool_context)

        task = _make_task(
            context,
            state=pb.TaskState.TASK_STATE_FAILED,
            data=data,
            message=message,
        )
        await event_queue.enqueue_event(task)

Bridges ADCPHandler methods to the a2a-sdk AgentExecutor interface.

Incoming A2A messages are parsed to extract the ADCP skill name and parameters, dispatched to the matching handler method, and the result is published back as A2A Task events.

Expects the explicit skill invocation format used by A2AAdapter: Part(data={"skill": "get_products", "parameters": {…}})

Ancestors

  • a2a.server.agent_execution.agent_executor.AgentExecutor
  • abc.ABC

Instance variables

prop supported_skills : list[str]
Expand source code
@property
def supported_skills(self) -> list[str]:
    """List of skill names this executor can handle."""
    return list(self._tool_callers.keys())

List of skill names this executor can handle.

Methods

async def cancel(self, context: RequestContext, event_queue: EventQueue) ‑> None
Expand source code
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
    """ADCP operations are synchronous; cancellation sets state to canceled."""
    event = _make_task(
        context,
        state=pb.TaskState.TASK_STATE_CANCELED,
        message="Task canceled",
    )
    await event_queue.enqueue_event(event)

ADCP operations are synchronous; cancellation sets state to canceled.

async def execute(self, context: RequestContext, event_queue: EventQueue) ‑> None
Expand source code
async def execute(self, context: RequestContext, event_queue: EventQueue) -> None:
    """Execute an ADCP skill from an incoming A2A message."""
    skill_name, params = self._parse_request(context)

    if skill_name is None:
        await self._send_error(event_queue, context, "No skill specified in message")
        return

    if skill_name not in self._tool_callers:
        await self._send_error(event_queue, context, f"Unknown skill: {skill_name}")
        return

    tool_context = self._build_tool_context(skill_name, context)
    # Catch both the client-side :class:`ADCPError` (raised by
    # framework helpers like ``IdempotencyConflictError``) AND the
    # decisioning-layer :class:`AdcpError` (raised by platform methods
    # adopters write against the decisioning graph). They are
    # disjoint hierarchies; both project onto the same structured
    # ``adcp_error`` envelope per transport-errors.mdx §A2A Binding.
    structured_error_types: tuple[type[BaseException], ...] = (
        ADCPError,
        *_DECISIONING_ADCP_ERROR_TYPES,
    )
    try:
        result = await self._dispatch_with_middleware(skill_name, params, tool_context)
        # ``params`` carries the parsed wire request including any
        # ``context`` extension. Both success and error paths thread
        # it through to the result builder so the context-passthrough
        # contract holds across the dispatch outcome.
        await self._send_result(event_queue, context, skill_name, result, params)
    except structured_error_types as exc:
        # Application-layer AdCP error. Emit a failed task with the
        # adcp_error in a DataPart per transport-errors.mdx §A2A
        # Binding, plus a human-readable text part. The JSON-RPC
        # channel is reserved for transport-level errors (auth
        # rejected, rate-limited pre-dispatch).
        logger.info("AdCP application error for skill %s: %s", skill_name, exc)
        await self._send_adcp_error(
            event_queue, context, exc, params, skill_name=skill_name, tool_context=tool_context
        )
    except Exception:
        logger.exception("Error executing skill %s", skill_name)
        await self._send_error(event_queue, context, f"Skill execution failed: {skill_name}")

Execute an ADCP skill from an incoming A2A message.

class ADCPHandler
Expand source code
class ADCPHandler(ABC, Generic[TContext]):
    """Base class for ADCP operation handlers.

    Subclass this to implement ADCP operations. All operations have default
    implementations that return 'not supported', allowing you to implement
    only the operations your agent supports.

    Parameterise over a :class:`ToolContext` subclass — ``class MyAgent(ADCPHandler[MyContext])``
    — to get typed ``context`` arguments on every method signature. See
    :data:`TContext` for the pattern.

    For protocol-specific handlers, use:
    - ContentStandardsHandler: For content standards agents
    - SponsoredIntelligenceHandler: For sponsored intelligence agents
    - GovernanceHandler: For governance agents

    **Tool advertisement** (`advertised_tools` class attribute):

    A subclass that introduces a new specialism — i.e., a custom base
    that needs its own ``tools/list`` filter rather than inheriting one
    from a built-in handler — declares the tool set on the class body::

        class PlatformHandler(ADCPHandler):
            advertised_tools: ClassVar[set[str]] = {
                "get_products",
                "create_media_buy",
                ...
            }

    The framework registers ``PlatformHandler -> advertised_tools`` with
    :func:`adcp.server.mcp_tools.register_handler_tools` at class
    definition time. Subclasses that DON'T introduce a new specialism
    (a custom ``MyContentAgent(ContentStandardsHandler)``, for example)
    inherit their parent's tool set unchanged — no class attr needed.

    Hand-written equivalent (no ``advertised_tools`` declaration)::

        from adcp.server.mcp_tools import register_handler_tools
        register_handler_tools("PlatformHandler", {...})

    Either path is fine; codegen targets emit the class attribute so the
    declaration sits next to the class definition.
    """

    _agent_type: str = "this agent"

    def __init_subclass__(cls, **kwargs: Any) -> None:
        """Auto-register subclass-declared tool advertisement.

        Reads ``cls.__dict__["advertised_tools"]`` (subclass-defined-only
        — inherited values don't trigger re-registration) and routes
        through :func:`adcp.server.mcp_tools.register_handler_tools`.
        Only fires when the subclass declares the attribute on its own
        class body; intermediate subclasses (multi-level hierarchy)
        register at the level that introduces the attribute.

        The lazy import avoids a base.py ↔ mcp_tools.py circular —
        mcp_tools imports ADCPHandler at module load, so register is
        looked up only when a subclass is actually being created.
        """
        super().__init_subclass__(**kwargs)
        if "advertised_tools" in cls.__dict__:
            from adcp.server.mcp_tools import register_handler_tools

            register_handler_tools(cls.__name__, cls.__dict__["advertised_tools"])

    def _not_supported(self, operation: str) -> NotImplementedResponse:
        """Create a not-supported response that includes the agent type."""
        return not_supported(f"{operation} is not supported by {self._agent_type}")

    # ========================================================================
    # Core Catalog Operations
    # ========================================================================

    async def get_products(
        self, params: GetProductsRequest | dict[str, Any], context: TContext | None = None
    ) -> Any:
        """Get advertising products.

        Override this to provide product catalog functionality.
        """
        return self._not_supported("get_products")

    async def list_creative_formats(
        self,
        params: ListCreativeFormatsRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> Any:
        """List supported creative formats.

        Override this to provide creative format information.
        """
        return self._not_supported("list_creative_formats")

    # ========================================================================
    # Creative Operations
    # ========================================================================

    async def sync_creatives(
        self, params: SyncCreativesRequest | dict[str, Any], context: TContext | None = None
    ) -> Any:
        """Sync creatives.

        Override this to handle creative synchronization.
        """
        return self._not_supported("sync_creatives")

    async def list_creatives(
        self, params: ListCreativesRequest | dict[str, Any], context: TContext | None = None
    ) -> Any:
        """List creatives.

        Override this to list synced creatives.
        """
        return self._not_supported("list_creatives")

    async def build_creative(
        self, params: BuildCreativeRequest | dict[str, Any], context: TContext | None = None
    ) -> Any:
        """Build a creative.

        Override this to build creatives from assets.
        """
        return self._not_supported("build_creative")

    async def preview_creative(
        self, params: PreviewCreativeRequest | dict[str, Any], context: TContext | None = None
    ) -> Any:
        """Preview a creative rendering.

        Override this to provide creative preview functionality.
        """
        return self._not_supported("preview_creative")

    async def get_creative_delivery(
        self,
        params: GetCreativeDeliveryRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> Any:
        """Get creative delivery metrics.

        Override this to provide functionality.
        """
        return self._not_supported("get_creative_delivery")

    async def list_transformers(
        self,
        params: ListTransformersRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> Any:
        """List creative transformers.

        Override this to advertise available creative transformation options.
        """
        return self._not_supported("list_transformers")

    # ========================================================================
    # Media Buy Operations
    # ========================================================================

    async def create_media_buy(
        self, params: CreateMediaBuyRequest | dict[str, Any], context: TContext | None = None
    ) -> Any:
        """Create a media buy.

        Override this to handle media buy creation.
        """
        return self._not_supported("create_media_buy")

    async def update_media_buy(
        self, params: UpdateMediaBuyRequest | dict[str, Any], context: TContext | None = None
    ) -> Any:
        """Update a media buy.

        Override this to handle media buy updates.
        """
        return self._not_supported("update_media_buy")

    async def get_media_buy_delivery(
        self,
        params: GetMediaBuyDeliveryRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> Any:
        """Get media buy delivery metrics.

        Override this to provide delivery reporting.
        """
        return self._not_supported("get_media_buy_delivery")

    async def get_media_buys(
        self, params: GetMediaBuysRequest | dict[str, Any], context: TContext | None = None
    ) -> Any:
        """Get media buys with status and optional delivery snapshots.

        Override this to provide media buy listing functionality.
        """
        return self._not_supported("get_media_buys")

    # ========================================================================
    # Signal Operations
    # ========================================================================

    async def get_signals(
        self, params: GetSignalsRequest | dict[str, Any], context: TContext | None = None
    ) -> Any:
        """Get available signals.

        Override this to provide signal catalog.
        """
        return self._not_supported("get_signals")

    async def activate_signal(
        self, params: ActivateSignalRequest | dict[str, Any], context: TContext | None = None
    ) -> Any:
        """Activate a signal.

        Override this to handle signal activation.
        """
        return self._not_supported("activate_signal")

    # ========================================================================
    # Feedback Operations
    # ========================================================================

    async def provide_performance_feedback(
        self,
        params: ProvidePerformanceFeedbackRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> Any:
        """Provide performance feedback.

        Override this to handle performance feedback ingestion.
        """
        return self._not_supported("provide_performance_feedback")

    # ========================================================================
    # Account Operations
    # ========================================================================

    async def list_accounts(
        self, params: ListAccountsRequest | dict[str, Any], context: TContext | None = None
    ) -> Any:
        """List accounts.

        Override this to provide functionality.
        """
        return self._not_supported("list_accounts")

    async def sync_accounts(
        self, params: SyncAccountsRequest | dict[str, Any], context: TContext | None = None
    ) -> Any:
        """Sync accounts.

        Override this to provide functionality.
        """
        return self._not_supported("sync_accounts")

    async def get_account_financials(
        self,
        params: GetAccountFinancialsRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> Any:
        """Get account financials.

        Override this to provide account financial reporting.
        """
        return self._not_supported("get_account_financials")

    async def report_usage(
        self, params: ReportUsageRequest | dict[str, Any], context: TContext | None = None
    ) -> Any:
        """Report account usage.

        Override this to ingest account usage.
        """
        return self._not_supported("report_usage")

    # ========================================================================
    # Event Operations
    # ========================================================================

    async def log_event(
        self, params: LogEventRequest | dict[str, Any], context: TContext | None = None
    ) -> Any:
        """Log event.

        Override this to provide functionality.
        """
        return self._not_supported("log_event")

    async def sync_event_sources(
        self, params: SyncEventSourcesRequest | dict[str, Any], context: TContext | None = None
    ) -> Any:
        """Sync event sources.

        Override this to provide functionality.
        """
        return self._not_supported("sync_event_sources")

    async def sync_audiences(
        self, params: SyncAudiencesRequest | dict[str, Any], context: TContext | None = None
    ) -> Any:
        """Sync audiences.

        Override this to provide audience synchronization.
        """
        return self._not_supported("sync_audiences")

    async def sync_governance(
        self, params: SyncGovernanceRequest | dict[str, Any], context: TContext | None = None
    ) -> Any:
        """Sync governance agents for accounts.

        Override this to handle governance agent registration.
        """
        return self._not_supported("sync_governance")

    async def sync_catalogs(
        self, params: SyncCatalogsRequest | dict[str, Any], context: TContext | None = None
    ) -> Any:
        """Sync catalogs.

        Override this to provide catalog synchronization.
        """
        return self._not_supported("sync_catalogs")

    # ========================================================================
    # V3 Protocol Discovery
    # ========================================================================

    async def get_adcp_capabilities(
        self,
        params: GetAdcpCapabilitiesRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> Any:
        """Get ADCP capabilities.

        Override this to advertise your agent's capabilities.
        """
        return self._not_supported("get_adcp_capabilities")

    async def get_task_status(
        self,
        params: GetTaskStatusRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> Any:
        """Get task status.

        Override this to expose persisted async task status.
        """
        return self._not_supported("get_task_status")

    async def list_tasks(
        self,
        params: ListTasksRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> Any:
        """List tasks.

        Override this to expose persisted async tasks.
        """
        return self._not_supported("list_tasks")

    # ========================================================================
    # V3 Content Standards Operations
    # ========================================================================

    async def create_content_standards(
        self,
        params: CreateContentStandardsRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> Any:
        """Create content standards configuration.

        Override this in ContentStandardsHandler subclasses.
        """
        return self._not_supported("create_content_standards")

    async def get_content_standards(
        self,
        params: GetContentStandardsRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> Any:
        """Get content standards configuration.

        Override this in ContentStandardsHandler subclasses.
        """
        return self._not_supported("get_content_standards")

    async def list_content_standards(
        self,
        params: ListContentStandardsRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> Any:
        """List content standards configurations.

        Override this in ContentStandardsHandler subclasses.
        """
        return self._not_supported("list_content_standards")

    async def update_content_standards(
        self,
        params: UpdateContentStandardsRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> Any:
        """Update content standards configuration.

        Override this in ContentStandardsHandler subclasses.
        """
        return self._not_supported("update_content_standards")

    async def calibrate_content(
        self, params: CalibrateContentRequest | dict[str, Any], context: TContext | None = None
    ) -> Any:
        """Calibrate content against standards.

        Override this in ContentStandardsHandler subclasses.
        """
        return self._not_supported("calibrate_content")

    async def validate_content_delivery(
        self,
        params: ValidateContentDeliveryRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> Any:
        """Validate content delivery against standards.

        Override this in ContentStandardsHandler subclasses.
        """
        return self._not_supported("validate_content_delivery")

    async def get_media_buy_artifacts(
        self,
        params: GetMediaBuyArtifactsRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> Any:
        """Get artifacts associated with a media buy.

        Override this in ContentStandardsHandler subclasses.
        """
        return self._not_supported("get_media_buy_artifacts")

    # ========================================================================
    # V3 Sponsored Intelligence Operations
    # ========================================================================

    async def si_get_offering(
        self, params: SiGetOfferingRequest | dict[str, Any], context: TContext | None = None
    ) -> Any:
        """Get sponsored intelligence offering.

        Override this in SponsoredIntelligenceHandler subclasses.
        """
        return self._not_supported("si_get_offering")

    async def si_initiate_session(
        self, params: SiInitiateSessionRequest | dict[str, Any], context: TContext | None = None
    ) -> Any:
        """Initiate sponsored intelligence session.

        Override this in SponsoredIntelligenceHandler subclasses.
        """
        return self._not_supported("si_initiate_session")

    async def si_send_message(
        self, params: SiSendMessageRequest | dict[str, Any], context: TContext | None = None
    ) -> Any:
        """Send message in sponsored intelligence session.

        Override this in SponsoredIntelligenceHandler subclasses.
        """
        return self._not_supported("si_send_message")

    async def si_terminate_session(
        self, params: SiTerminateSessionRequest | dict[str, Any], context: TContext | None = None
    ) -> Any:
        """Terminate sponsored intelligence session.

        Override this in SponsoredIntelligenceHandler subclasses.
        """
        return self._not_supported("si_terminate_session")

    # ========================================================================
    # V3 Governance Operations
    # ========================================================================

    async def get_creative_features(
        self,
        params: GetCreativeFeaturesRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> Any:
        """Evaluate governance features for a creative.

        Override this in GovernanceHandler subclasses.
        """
        return self._not_supported("get_creative_features")

    async def sync_plans(
        self, params: SyncPlansRequest | dict[str, Any], context: TContext | None = None
    ) -> Any:
        """Sync campaign governance plans.

        Override this in GovernanceHandler subclasses.
        """
        return self._not_supported("sync_plans")

    async def check_governance(
        self, params: CheckGovernanceRequest | dict[str, Any], context: TContext | None = None
    ) -> Any:
        """Check an action against campaign governance.

        Override this in GovernanceHandler subclasses.
        """
        return self._not_supported("check_governance")

    async def report_plan_outcome(
        self, params: ReportPlanOutcomeRequest | dict[str, Any], context: TContext | None = None
    ) -> Any:
        """Report the outcome of a governed action.

        Override this in GovernanceHandler subclasses.
        """
        return self._not_supported("report_plan_outcome")

    async def get_plan_audit_logs(
        self, params: GetPlanAuditLogsRequest | dict[str, Any], context: TContext | None = None
    ) -> Any:
        """Retrieve governance audit logs for plans.

        Override this in GovernanceHandler subclasses.
        """
        return self._not_supported("get_plan_audit_logs")

    async def create_property_list(
        self, params: CreatePropertyListRequest | dict[str, Any], context: TContext | None = None
    ) -> Any:
        """Create a property list for governance filtering.

        Override this in GovernanceHandler subclasses.
        """
        return self._not_supported("create_property_list")

    async def get_property_list(
        self, params: GetPropertyListRequest | dict[str, Any], context: TContext | None = None
    ) -> Any:
        """Get a property list with optional resolution.

        Override this in GovernanceHandler subclasses.
        """
        return self._not_supported("get_property_list")

    async def list_property_lists(
        self, params: ListPropertyListsRequest | dict[str, Any], context: TContext | None = None
    ) -> Any:
        """List property lists.

        Override this in GovernanceHandler subclasses.
        """
        return self._not_supported("list_property_lists")

    async def update_property_list(
        self, params: UpdatePropertyListRequest | dict[str, Any], context: TContext | None = None
    ) -> Any:
        """Update a property list.

        Override this in GovernanceHandler subclasses.
        """
        return self._not_supported("update_property_list")

    async def delete_property_list(
        self, params: DeletePropertyListRequest | dict[str, Any], context: TContext | None = None
    ) -> Any:
        """Delete a property list.

        Override this in GovernanceHandler subclasses.
        """
        return self._not_supported("delete_property_list")

    # ========================================================================
    # V3 Governance (Collection Lists) Operations
    # ========================================================================

    async def create_collection_list(
        self,
        params: CreateCollectionListRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> Any:
        """Create a collection list for governance filtering.

        Override this in GovernanceHandler subclasses.
        """
        return self._not_supported("create_collection_list")

    async def get_collection_list(
        self, params: GetCollectionListRequest | dict[str, Any], context: TContext | None = None
    ) -> Any:
        """Get a collection list with optional resolution.

        Override this in GovernanceHandler subclasses.
        """
        return self._not_supported("get_collection_list")

    async def list_collection_lists(
        self,
        params: ListCollectionListsRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> Any:
        """List collection lists.

        Override this in GovernanceHandler subclasses.
        """
        return self._not_supported("list_collection_lists")

    async def update_collection_list(
        self,
        params: UpdateCollectionListRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> Any:
        """Update a collection list.

        Override this in GovernanceHandler subclasses.
        """
        return self._not_supported("update_collection_list")

    async def delete_collection_list(
        self,
        params: DeleteCollectionListRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> Any:
        """Delete a collection list.

        Override this in GovernanceHandler subclasses.
        """
        return self._not_supported("delete_collection_list")

    # ========================================================================
    # V3 TMP Operations
    # ========================================================================

    async def context_match(
        self, params: ContextMatchRequest | dict[str, Any], context: TContext | None = None
    ) -> Any:
        """Match ad context to buyer packages.

        Override this to provide TMP context matching.
        """
        return self._not_supported("context_match")

    async def identity_match(
        self, params: IdentityMatchRequest | dict[str, Any], context: TContext | None = None
    ) -> Any:
        """Match user identity for package eligibility.

        Override this to provide TMP identity matching.
        """
        return self._not_supported("identity_match")

    # ========================================================================
    # V3 Brand Rights Operations
    # ========================================================================

    async def get_brand_identity(
        self, params: GetBrandIdentityRequest | dict[str, Any], context: TContext | None = None
    ) -> Any:
        """Get brand identity information.

        Override this in BrandHandler subclasses.
        """
        return self._not_supported("get_brand_identity")

    async def get_rights(
        self, params: GetRightsRequest | dict[str, Any], context: TContext | None = None
    ) -> Any:
        """Get available rights for licensing.

        Override this in BrandHandler subclasses.
        """
        return self._not_supported("get_rights")

    async def acquire_rights(
        self, params: AcquireRightsRequest | dict[str, Any], context: TContext | None = None
    ) -> Any:
        """Acquire rights for brand content usage.

        Override this in BrandHandler subclasses.
        """
        return self._not_supported("acquire_rights")

    async def update_rights(
        self, params: UpdateRightsRequest | dict[str, Any], context: TContext | None = None
    ) -> Any:
        """Update terms of an existing rights acquisition.

        Override this in BrandHandler subclasses. Partial update: the
        request carries ``rights_id`` plus any subset of the mutable fields
        (``end_date``, ``impression_cap``, ``pricing_option_id``, ``paused``).

        Seller responsibilities you own when implementing this:

        * Reject updates on expired or revoked acquisitions with an
          appropriate error code — do not partial-commit.
        * Reject ``pricing_option_id`` swaps to incompatible options — the
          new option's terms must be a strict superset of the original.
        * Apply all accepted fields atomically — callers should never
          observe a half-applied update on failure.
        """
        return self._not_supported("update_rights")

    async def validate_input(self, params: dict[str, Any], context: TContext | None = None) -> Any:
        """Validate creative input."""
        return self._not_supported("validate_input")

    async def verify_brand_claim(
        self, params: dict[str, Any], context: TContext | None = None
    ) -> Any:
        """Verify a single brand claim."""
        return self._not_supported("verify_brand_claim")

    async def verify_brand_claims(
        self, params: dict[str, Any], context: TContext | None = None
    ) -> Any:
        """Verify multiple brand claims."""
        return self._not_supported("verify_brand_claims")

    # ========================================================================
    # V3 Compliance Operations
    # ========================================================================

    async def comply_test_controller(
        self,
        params: ComplyTestControllerRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> Any:
        """Compliance test controller (sandbox only).

        Override this in ComplianceHandler subclasses.
        """
        return self._not_supported("comply_test_controller")

Base class for ADCP operation handlers.

Subclass this to implement ADCP operations. All operations have default implementations that return 'not supported', allowing you to implement only the operations your agent supports.

Parameterise over a :class:ToolContext subclass — class MyAgent(ADCPHandler[MyContext]) — to get typed context arguments on every method signature. See :data:TContext for the pattern.

For protocol-specific handlers, use: - ContentStandardsHandler: For content standards agents - SponsoredIntelligenceHandler: For sponsored intelligence agents - GovernanceHandler: For governance agents

Tool advertisement (advertised_tools class attribute):

A subclass that introduces a new specialism — i.e., a custom base that needs its own tools/list filter rather than inheriting one from a built-in handler — declares the tool set on the class body::

class PlatformHandler(ADCPHandler):
    advertised_tools: ClassVar[set[str]] = {
        "get_products",
        "create_media_buy",
        ...
    }

The framework registers PlatformHandler -> advertised_tools with :func:register_handler_tools() at class definition time. Subclasses that DON'T introduce a new specialism (a custom MyContentAgent(ContentStandardsHandler), for example) inherit their parent's tool set unchanged — no class attr needed.

Hand-written equivalent (no advertised_tools declaration)::

from adcp.server.mcp_tools import register_handler_tools
register_handler_tools("PlatformHandler", {...})

Either path is fine; codegen targets emit the class attribute so the declaration sits next to the class definition.

Ancestors

  • abc.ABC
  • typing.Generic

Subclasses

Methods

async def acquire_rights(self,
params: AcquireRightsRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def acquire_rights(
    self, params: AcquireRightsRequest | dict[str, Any], context: TContext | None = None
) -> Any:
    """Acquire rights for brand content usage.

    Override this in BrandHandler subclasses.
    """
    return self._not_supported("acquire_rights")

Acquire rights for brand content usage.

Override this in BrandHandler subclasses.

async def activate_signal(self,
params: ActivateSignalRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def activate_signal(
    self, params: ActivateSignalRequest | dict[str, Any], context: TContext | None = None
) -> Any:
    """Activate a signal.

    Override this to handle signal activation.
    """
    return self._not_supported("activate_signal")

Activate a signal.

Override this to handle signal activation.

async def build_creative(self,
params: BuildCreativeRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def build_creative(
    self, params: BuildCreativeRequest | dict[str, Any], context: TContext | None = None
) -> Any:
    """Build a creative.

    Override this to build creatives from assets.
    """
    return self._not_supported("build_creative")

Build a creative.

Override this to build creatives from assets.

async def calibrate_content(self,
params: CalibrateContentRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def calibrate_content(
    self, params: CalibrateContentRequest | dict[str, Any], context: TContext | None = None
) -> Any:
    """Calibrate content against standards.

    Override this in ContentStandardsHandler subclasses.
    """
    return self._not_supported("calibrate_content")

Calibrate content against standards.

Override this in ContentStandardsHandler subclasses.

async def check_governance(self,
params: CheckGovernanceRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def check_governance(
    self, params: CheckGovernanceRequest | dict[str, Any], context: TContext | None = None
) -> Any:
    """Check an action against campaign governance.

    Override this in GovernanceHandler subclasses.
    """
    return self._not_supported("check_governance")

Check an action against campaign governance.

Override this in GovernanceHandler subclasses.

async def comply_test_controller(self,
params: ComplyTestControllerRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def comply_test_controller(
    self,
    params: ComplyTestControllerRequest | dict[str, Any],
    context: TContext | None = None,
) -> Any:
    """Compliance test controller (sandbox only).

    Override this in ComplianceHandler subclasses.
    """
    return self._not_supported("comply_test_controller")

Compliance test controller (sandbox only).

Override this in ComplianceHandler subclasses.

async def context_match(self,
params: ContextMatchRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def context_match(
    self, params: ContextMatchRequest | dict[str, Any], context: TContext | None = None
) -> Any:
    """Match ad context to buyer packages.

    Override this to provide TMP context matching.
    """
    return self._not_supported("context_match")

Match ad context to buyer packages.

Override this to provide TMP context matching.

async def create_collection_list(self,
params: CreateCollectionListRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def create_collection_list(
    self,
    params: CreateCollectionListRequest | dict[str, Any],
    context: TContext | None = None,
) -> Any:
    """Create a collection list for governance filtering.

    Override this in GovernanceHandler subclasses.
    """
    return self._not_supported("create_collection_list")

Create a collection list for governance filtering.

Override this in GovernanceHandler subclasses.

async def create_content_standards(self,
params: CreateContentStandardsRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def create_content_standards(
    self,
    params: CreateContentStandardsRequest | dict[str, Any],
    context: TContext | None = None,
) -> Any:
    """Create content standards configuration.

    Override this in ContentStandardsHandler subclasses.
    """
    return self._not_supported("create_content_standards")

Create content standards configuration.

Override this in ContentStandardsHandler subclasses.

async def create_media_buy(self,
params: CreateMediaBuyRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def create_media_buy(
    self, params: CreateMediaBuyRequest | dict[str, Any], context: TContext | None = None
) -> Any:
    """Create a media buy.

    Override this to handle media buy creation.
    """
    return self._not_supported("create_media_buy")

Create a media buy.

Override this to handle media buy creation.

async def create_property_list(self,
params: CreatePropertyListRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def create_property_list(
    self, params: CreatePropertyListRequest | dict[str, Any], context: TContext | None = None
) -> Any:
    """Create a property list for governance filtering.

    Override this in GovernanceHandler subclasses.
    """
    return self._not_supported("create_property_list")

Create a property list for governance filtering.

Override this in GovernanceHandler subclasses.

async def delete_collection_list(self,
params: DeleteCollectionListRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def delete_collection_list(
    self,
    params: DeleteCollectionListRequest | dict[str, Any],
    context: TContext | None = None,
) -> Any:
    """Delete a collection list.

    Override this in GovernanceHandler subclasses.
    """
    return self._not_supported("delete_collection_list")

Delete a collection list.

Override this in GovernanceHandler subclasses.

async def delete_property_list(self,
params: DeletePropertyListRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def delete_property_list(
    self, params: DeletePropertyListRequest | dict[str, Any], context: TContext | None = None
) -> Any:
    """Delete a property list.

    Override this in GovernanceHandler subclasses.
    """
    return self._not_supported("delete_property_list")

Delete a property list.

Override this in GovernanceHandler subclasses.

async def get_account_financials(self,
params: GetAccountFinancialsRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def get_account_financials(
    self,
    params: GetAccountFinancialsRequest | dict[str, Any],
    context: TContext | None = None,
) -> Any:
    """Get account financials.

    Override this to provide account financial reporting.
    """
    return self._not_supported("get_account_financials")

Get account financials.

Override this to provide account financial reporting.

async def get_adcp_capabilities(self,
params: GetAdcpCapabilitiesRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def get_adcp_capabilities(
    self,
    params: GetAdcpCapabilitiesRequest | dict[str, Any],
    context: TContext | None = None,
) -> Any:
    """Get ADCP capabilities.

    Override this to advertise your agent's capabilities.
    """
    return self._not_supported("get_adcp_capabilities")

Get ADCP capabilities.

Override this to advertise your agent's capabilities.

async def get_brand_identity(self,
params: GetBrandIdentityRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def get_brand_identity(
    self, params: GetBrandIdentityRequest | dict[str, Any], context: TContext | None = None
) -> Any:
    """Get brand identity information.

    Override this in BrandHandler subclasses.
    """
    return self._not_supported("get_brand_identity")

Get brand identity information.

Override this in BrandHandler subclasses.

async def get_collection_list(self,
params: GetCollectionListRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def get_collection_list(
    self, params: GetCollectionListRequest | dict[str, Any], context: TContext | None = None
) -> Any:
    """Get a collection list with optional resolution.

    Override this in GovernanceHandler subclasses.
    """
    return self._not_supported("get_collection_list")

Get a collection list with optional resolution.

Override this in GovernanceHandler subclasses.

async def get_content_standards(self,
params: GetContentStandardsRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def get_content_standards(
    self,
    params: GetContentStandardsRequest | dict[str, Any],
    context: TContext | None = None,
) -> Any:
    """Get content standards configuration.

    Override this in ContentStandardsHandler subclasses.
    """
    return self._not_supported("get_content_standards")

Get content standards configuration.

Override this in ContentStandardsHandler subclasses.

async def get_creative_delivery(self,
params: GetCreativeDeliveryRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def get_creative_delivery(
    self,
    params: GetCreativeDeliveryRequest | dict[str, Any],
    context: TContext | None = None,
) -> Any:
    """Get creative delivery metrics.

    Override this to provide functionality.
    """
    return self._not_supported("get_creative_delivery")

Get creative delivery metrics.

Override this to provide functionality.

async def get_creative_features(self,
params: GetCreativeFeaturesRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def get_creative_features(
    self,
    params: GetCreativeFeaturesRequest | dict[str, Any],
    context: TContext | None = None,
) -> Any:
    """Evaluate governance features for a creative.

    Override this in GovernanceHandler subclasses.
    """
    return self._not_supported("get_creative_features")

Evaluate governance features for a creative.

Override this in GovernanceHandler subclasses.

async def get_media_buy_artifacts(self,
params: GetMediaBuyArtifactsRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def get_media_buy_artifacts(
    self,
    params: GetMediaBuyArtifactsRequest | dict[str, Any],
    context: TContext | None = None,
) -> Any:
    """Get artifacts associated with a media buy.

    Override this in ContentStandardsHandler subclasses.
    """
    return self._not_supported("get_media_buy_artifacts")

Get artifacts associated with a media buy.

Override this in ContentStandardsHandler subclasses.

async def get_media_buy_delivery(self,
params: GetMediaBuyDeliveryRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def get_media_buy_delivery(
    self,
    params: GetMediaBuyDeliveryRequest | dict[str, Any],
    context: TContext | None = None,
) -> Any:
    """Get media buy delivery metrics.

    Override this to provide delivery reporting.
    """
    return self._not_supported("get_media_buy_delivery")

Get media buy delivery metrics.

Override this to provide delivery reporting.

async def get_media_buys(self,
params: GetMediaBuysRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def get_media_buys(
    self, params: GetMediaBuysRequest | dict[str, Any], context: TContext | None = None
) -> Any:
    """Get media buys with status and optional delivery snapshots.

    Override this to provide media buy listing functionality.
    """
    return self._not_supported("get_media_buys")

Get media buys with status and optional delivery snapshots.

Override this to provide media buy listing functionality.

async def get_plan_audit_logs(self,
params: GetPlanAuditLogsRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def get_plan_audit_logs(
    self, params: GetPlanAuditLogsRequest | dict[str, Any], context: TContext | None = None
) -> Any:
    """Retrieve governance audit logs for plans.

    Override this in GovernanceHandler subclasses.
    """
    return self._not_supported("get_plan_audit_logs")

Retrieve governance audit logs for plans.

Override this in GovernanceHandler subclasses.

async def get_products(self,
params: GetProductsRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def get_products(
    self, params: GetProductsRequest | dict[str, Any], context: TContext | None = None
) -> Any:
    """Get advertising products.

    Override this to provide product catalog functionality.
    """
    return self._not_supported("get_products")

Get advertising products.

Override this to provide product catalog functionality.

async def get_property_list(self,
params: GetPropertyListRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def get_property_list(
    self, params: GetPropertyListRequest | dict[str, Any], context: TContext | None = None
) -> Any:
    """Get a property list with optional resolution.

    Override this in GovernanceHandler subclasses.
    """
    return self._not_supported("get_property_list")

Get a property list with optional resolution.

Override this in GovernanceHandler subclasses.

async def get_rights(self,
params: GetRightsRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def get_rights(
    self, params: GetRightsRequest | dict[str, Any], context: TContext | None = None
) -> Any:
    """Get available rights for licensing.

    Override this in BrandHandler subclasses.
    """
    return self._not_supported("get_rights")

Get available rights for licensing.

Override this in BrandHandler subclasses.

async def get_signals(self,
params: GetSignalsRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def get_signals(
    self, params: GetSignalsRequest | dict[str, Any], context: TContext | None = None
) -> Any:
    """Get available signals.

    Override this to provide signal catalog.
    """
    return self._not_supported("get_signals")

Get available signals.

Override this to provide signal catalog.

async def get_task_status(self,
params: GetTaskStatusRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def get_task_status(
    self,
    params: GetTaskStatusRequest | dict[str, Any],
    context: TContext | None = None,
) -> Any:
    """Get task status.

    Override this to expose persisted async task status.
    """
    return self._not_supported("get_task_status")

Get task status.

Override this to expose persisted async task status.

async def identity_match(self,
params: IdentityMatchRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def identity_match(
    self, params: IdentityMatchRequest | dict[str, Any], context: TContext | None = None
) -> Any:
    """Match user identity for package eligibility.

    Override this to provide TMP identity matching.
    """
    return self._not_supported("identity_match")

Match user identity for package eligibility.

Override this to provide TMP identity matching.

async def list_accounts(self,
params: ListAccountsRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def list_accounts(
    self, params: ListAccountsRequest | dict[str, Any], context: TContext | None = None
) -> Any:
    """List accounts.

    Override this to provide functionality.
    """
    return self._not_supported("list_accounts")

List accounts.

Override this to provide functionality.

async def list_collection_lists(self,
params: ListCollectionListsRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def list_collection_lists(
    self,
    params: ListCollectionListsRequest | dict[str, Any],
    context: TContext | None = None,
) -> Any:
    """List collection lists.

    Override this in GovernanceHandler subclasses.
    """
    return self._not_supported("list_collection_lists")

List collection lists.

Override this in GovernanceHandler subclasses.

async def list_content_standards(self,
params: ListContentStandardsRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def list_content_standards(
    self,
    params: ListContentStandardsRequest | dict[str, Any],
    context: TContext | None = None,
) -> Any:
    """List content standards configurations.

    Override this in ContentStandardsHandler subclasses.
    """
    return self._not_supported("list_content_standards")

List content standards configurations.

Override this in ContentStandardsHandler subclasses.

async def list_creative_formats(self,
params: ListCreativeFormatsRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def list_creative_formats(
    self,
    params: ListCreativeFormatsRequest | dict[str, Any],
    context: TContext | None = None,
) -> Any:
    """List supported creative formats.

    Override this to provide creative format information.
    """
    return self._not_supported("list_creative_formats")

List supported creative formats.

Override this to provide creative format information.

async def list_creatives(self,
params: ListCreativesRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def list_creatives(
    self, params: ListCreativesRequest | dict[str, Any], context: TContext | None = None
) -> Any:
    """List creatives.

    Override this to list synced creatives.
    """
    return self._not_supported("list_creatives")

List creatives.

Override this to list synced creatives.

async def list_property_lists(self,
params: ListPropertyListsRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def list_property_lists(
    self, params: ListPropertyListsRequest | dict[str, Any], context: TContext | None = None
) -> Any:
    """List property lists.

    Override this in GovernanceHandler subclasses.
    """
    return self._not_supported("list_property_lists")

List property lists.

Override this in GovernanceHandler subclasses.

async def list_tasks(self,
params: ListTasksRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def list_tasks(
    self,
    params: ListTasksRequest | dict[str, Any],
    context: TContext | None = None,
) -> Any:
    """List tasks.

    Override this to expose persisted async tasks.
    """
    return self._not_supported("list_tasks")

List tasks.

Override this to expose persisted async tasks.

async def list_transformers(self,
params: ListTransformersRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def list_transformers(
    self,
    params: ListTransformersRequest | dict[str, Any],
    context: TContext | None = None,
) -> Any:
    """List creative transformers.

    Override this to advertise available creative transformation options.
    """
    return self._not_supported("list_transformers")

List creative transformers.

Override this to advertise available creative transformation options.

async def log_event(self, params: LogEventRequest | dict[str, Any], context: TContext | None = None) ‑> Any
Expand source code
async def log_event(
    self, params: LogEventRequest | dict[str, Any], context: TContext | None = None
) -> Any:
    """Log event.

    Override this to provide functionality.
    """
    return self._not_supported("log_event")

Log event.

Override this to provide functionality.

async def preview_creative(self,
params: PreviewCreativeRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def preview_creative(
    self, params: PreviewCreativeRequest | dict[str, Any], context: TContext | None = None
) -> Any:
    """Preview a creative rendering.

    Override this to provide creative preview functionality.
    """
    return self._not_supported("preview_creative")

Preview a creative rendering.

Override this to provide creative preview functionality.

async def provide_performance_feedback(self,
params: ProvidePerformanceFeedbackRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def provide_performance_feedback(
    self,
    params: ProvidePerformanceFeedbackRequest | dict[str, Any],
    context: TContext | None = None,
) -> Any:
    """Provide performance feedback.

    Override this to handle performance feedback ingestion.
    """
    return self._not_supported("provide_performance_feedback")

Provide performance feedback.

Override this to handle performance feedback ingestion.

async def report_plan_outcome(self,
params: ReportPlanOutcomeRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def report_plan_outcome(
    self, params: ReportPlanOutcomeRequest | dict[str, Any], context: TContext | None = None
) -> Any:
    """Report the outcome of a governed action.

    Override this in GovernanceHandler subclasses.
    """
    return self._not_supported("report_plan_outcome")

Report the outcome of a governed action.

Override this in GovernanceHandler subclasses.

async def report_usage(self,
params: ReportUsageRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def report_usage(
    self, params: ReportUsageRequest | dict[str, Any], context: TContext | None = None
) -> Any:
    """Report account usage.

    Override this to ingest account usage.
    """
    return self._not_supported("report_usage")

Report account usage.

Override this to ingest account usage.

async def si_get_offering(self,
params: SiGetOfferingRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def si_get_offering(
    self, params: SiGetOfferingRequest | dict[str, Any], context: TContext | None = None
) -> Any:
    """Get sponsored intelligence offering.

    Override this in SponsoredIntelligenceHandler subclasses.
    """
    return self._not_supported("si_get_offering")

Get sponsored intelligence offering.

Override this in SponsoredIntelligenceHandler subclasses.

async def si_initiate_session(self,
params: SiInitiateSessionRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def si_initiate_session(
    self, params: SiInitiateSessionRequest | dict[str, Any], context: TContext | None = None
) -> Any:
    """Initiate sponsored intelligence session.

    Override this in SponsoredIntelligenceHandler subclasses.
    """
    return self._not_supported("si_initiate_session")

Initiate sponsored intelligence session.

Override this in SponsoredIntelligenceHandler subclasses.

async def si_send_message(self,
params: SiSendMessageRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def si_send_message(
    self, params: SiSendMessageRequest | dict[str, Any], context: TContext | None = None
) -> Any:
    """Send message in sponsored intelligence session.

    Override this in SponsoredIntelligenceHandler subclasses.
    """
    return self._not_supported("si_send_message")

Send message in sponsored intelligence session.

Override this in SponsoredIntelligenceHandler subclasses.

async def si_terminate_session(self,
params: SiTerminateSessionRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def si_terminate_session(
    self, params: SiTerminateSessionRequest | dict[str, Any], context: TContext | None = None
) -> Any:
    """Terminate sponsored intelligence session.

    Override this in SponsoredIntelligenceHandler subclasses.
    """
    return self._not_supported("si_terminate_session")

Terminate sponsored intelligence session.

Override this in SponsoredIntelligenceHandler subclasses.

async def sync_accounts(self,
params: SyncAccountsRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def sync_accounts(
    self, params: SyncAccountsRequest | dict[str, Any], context: TContext | None = None
) -> Any:
    """Sync accounts.

    Override this to provide functionality.
    """
    return self._not_supported("sync_accounts")

Sync accounts.

Override this to provide functionality.

async def sync_audiences(self,
params: SyncAudiencesRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def sync_audiences(
    self, params: SyncAudiencesRequest | dict[str, Any], context: TContext | None = None
) -> Any:
    """Sync audiences.

    Override this to provide audience synchronization.
    """
    return self._not_supported("sync_audiences")

Sync audiences.

Override this to provide audience synchronization.

async def sync_catalogs(self,
params: SyncCatalogsRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def sync_catalogs(
    self, params: SyncCatalogsRequest | dict[str, Any], context: TContext | None = None
) -> Any:
    """Sync catalogs.

    Override this to provide catalog synchronization.
    """
    return self._not_supported("sync_catalogs")

Sync catalogs.

Override this to provide catalog synchronization.

async def sync_creatives(self,
params: SyncCreativesRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def sync_creatives(
    self, params: SyncCreativesRequest | dict[str, Any], context: TContext | None = None
) -> Any:
    """Sync creatives.

    Override this to handle creative synchronization.
    """
    return self._not_supported("sync_creatives")

Sync creatives.

Override this to handle creative synchronization.

async def sync_event_sources(self,
params: SyncEventSourcesRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def sync_event_sources(
    self, params: SyncEventSourcesRequest | dict[str, Any], context: TContext | None = None
) -> Any:
    """Sync event sources.

    Override this to provide functionality.
    """
    return self._not_supported("sync_event_sources")

Sync event sources.

Override this to provide functionality.

async def sync_governance(self,
params: SyncGovernanceRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def sync_governance(
    self, params: SyncGovernanceRequest | dict[str, Any], context: TContext | None = None
) -> Any:
    """Sync governance agents for accounts.

    Override this to handle governance agent registration.
    """
    return self._not_supported("sync_governance")

Sync governance agents for accounts.

Override this to handle governance agent registration.

async def sync_plans(self,
params: SyncPlansRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def sync_plans(
    self, params: SyncPlansRequest | dict[str, Any], context: TContext | None = None
) -> Any:
    """Sync campaign governance plans.

    Override this in GovernanceHandler subclasses.
    """
    return self._not_supported("sync_plans")

Sync campaign governance plans.

Override this in GovernanceHandler subclasses.

async def update_collection_list(self,
params: UpdateCollectionListRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def update_collection_list(
    self,
    params: UpdateCollectionListRequest | dict[str, Any],
    context: TContext | None = None,
) -> Any:
    """Update a collection list.

    Override this in GovernanceHandler subclasses.
    """
    return self._not_supported("update_collection_list")

Update a collection list.

Override this in GovernanceHandler subclasses.

async def update_content_standards(self,
params: UpdateContentStandardsRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def update_content_standards(
    self,
    params: UpdateContentStandardsRequest | dict[str, Any],
    context: TContext | None = None,
) -> Any:
    """Update content standards configuration.

    Override this in ContentStandardsHandler subclasses.
    """
    return self._not_supported("update_content_standards")

Update content standards configuration.

Override this in ContentStandardsHandler subclasses.

async def update_media_buy(self,
params: UpdateMediaBuyRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def update_media_buy(
    self, params: UpdateMediaBuyRequest | dict[str, Any], context: TContext | None = None
) -> Any:
    """Update a media buy.

    Override this to handle media buy updates.
    """
    return self._not_supported("update_media_buy")

Update a media buy.

Override this to handle media buy updates.

async def update_property_list(self,
params: UpdatePropertyListRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def update_property_list(
    self, params: UpdatePropertyListRequest | dict[str, Any], context: TContext | None = None
) -> Any:
    """Update a property list.

    Override this in GovernanceHandler subclasses.
    """
    return self._not_supported("update_property_list")

Update a property list.

Override this in GovernanceHandler subclasses.

async def update_rights(self,
params: UpdateRightsRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def update_rights(
    self, params: UpdateRightsRequest | dict[str, Any], context: TContext | None = None
) -> Any:
    """Update terms of an existing rights acquisition.

    Override this in BrandHandler subclasses. Partial update: the
    request carries ``rights_id`` plus any subset of the mutable fields
    (``end_date``, ``impression_cap``, ``pricing_option_id``, ``paused``).

    Seller responsibilities you own when implementing this:

    * Reject updates on expired or revoked acquisitions with an
      appropriate error code — do not partial-commit.
    * Reject ``pricing_option_id`` swaps to incompatible options — the
      new option's terms must be a strict superset of the original.
    * Apply all accepted fields atomically — callers should never
      observe a half-applied update on failure.
    """
    return self._not_supported("update_rights")

Update terms of an existing rights acquisition.

Override this in BrandHandler subclasses. Partial update: the request carries rights_id plus any subset of the mutable fields (end_date, impression_cap, pricing_option_id, paused).

Seller responsibilities you own when implementing this:

  • Reject updates on expired or revoked acquisitions with an appropriate error code — do not partial-commit.
  • Reject pricing_option_id swaps to incompatible options — the new option's terms must be a strict superset of the original.
  • Apply all accepted fields atomically — callers should never observe a half-applied update on failure.
async def validate_content_delivery(self,
params: ValidateContentDeliveryRequest | dict[str, Any],
context: TContext | None = None) ‑> Any
Expand source code
async def validate_content_delivery(
    self,
    params: ValidateContentDeliveryRequest | dict[str, Any],
    context: TContext | None = None,
) -> Any:
    """Validate content delivery against standards.

    Override this in ContentStandardsHandler subclasses.
    """
    return self._not_supported("validate_content_delivery")

Validate content delivery against standards.

Override this in ContentStandardsHandler subclasses.

async def validate_input(self, params: dict[str, Any], context: TContext | None = None) ‑> Any
Expand source code
async def validate_input(self, params: dict[str, Any], context: TContext | None = None) -> Any:
    """Validate creative input."""
    return self._not_supported("validate_input")

Validate creative input.

async def verify_brand_claim(self, params: dict[str, Any], context: TContext | None = None) ‑> Any
Expand source code
async def verify_brand_claim(
    self, params: dict[str, Any], context: TContext | None = None
) -> Any:
    """Verify a single brand claim."""
    return self._not_supported("verify_brand_claim")

Verify a single brand claim.

async def verify_brand_claims(self, params: dict[str, Any], context: TContext | None = None) ‑> Any
Expand source code
async def verify_brand_claims(
    self, params: dict[str, Any], context: TContext | None = None
) -> Any:
    """Verify multiple brand claims."""
    return self._not_supported("verify_brand_claims")

Verify multiple brand claims.

class ADCPServerBuilder (name: str, *, version: str = '1.0.0', adcp_version: str | None = None)
Expand source code
class ADCPServerBuilder:
    """Declarative server builder using decorators.

    Use ``adcp_server()`` to create an instance, then register handlers
    with decorators. The builder can be passed directly to ``serve()``.

    Example::

        server = adcp_server("my-seller")

        @server.get_products
        async def get_products(params, context=None):
            return products_response(MY_PRODUCTS)

        serve(server, name="my-seller")
    """

    def __init__(
        self,
        name: str,
        *,
        version: str = "1.0.0",
        adcp_version: str | None = None,
    ) -> None:
        from adcp._version import resolve_adcp_version

        self.name = name
        self.version = version
        self._adcp_version: str = resolve_adcp_version(adcp_version)
        self._handlers: dict[str, Callable[..., Any]] = {}

    def get_adcp_version(self) -> str:
        """Return the AdCP protocol release this server is pinned to.

        Resolved at construction from the ``adcp_version`` kwarg, with
        fallback to the SDK's compile-time pin (``ADCP_VERSION``
        packaged with the wheel). Stage 2 plumbing — Stage 3 will use
        this to select which schema set the server validates handler
        responses against and which capability shape it advertises.
        """
        return self._adcp_version

    def __getattr__(self, task_name: str) -> Callable[..., Any]:
        """Return a decorator that registers a handler for the given task."""
        if task_name.startswith("_"):
            raise AttributeError(task_name)

        def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
            if task_name not in HANDLER_TO_DOMAIN and task_name != "get_adcp_capabilities":
                raise ValueError(f"'{task_name}' is not a known ADCP task. " f"Check for typos.")
            self._handlers[task_name] = fn
            return fn

        return decorator

    def _detect_domains(self) -> list[str]:
        """Detect which ADCP domains the registered handlers cover."""
        domains: set[str] = set()
        for handler_name in self._handlers:
            domain = HANDLER_TO_DOMAIN.get(handler_name)
            if domain:
                domains.add(domain)
        return sorted(domains)

    def build_handler(self) -> ADCPHandler[Any]:
        """Build an ADCPHandler from registered decorators.

        If ``get_adcp_capabilities`` is not registered, it will be
        auto-generated from the detected domains.
        """
        handlers = dict(self._handlers)

        # Auto-generate capabilities if not provided
        if "get_adcp_capabilities" not in handlers:
            domains = self._detect_domains()
            if domains:
                from adcp.server.responses import capabilities_response

                pinned_version = self._adcp_version

                async def auto_capabilities(params: Any, context: Any = None) -> dict[str, Any]:
                    return capabilities_response(
                        domains,
                        adcp_version=pinned_version,
                    )

                handlers["get_adcp_capabilities"] = auto_capabilities

        # Create a dynamic subclass. ``ADCPHandler[Any]`` because the
        # decorator-builder path doesn't thread a specific ToolContext
        # subclass — callers who want typed context go through the
        # class-based ``ADCPHandler[MyContext]`` route instead.
        class DynamicHandler(ADCPHandler[Any]):
            pass

        for task_name, fn in handlers.items():
            # Wrap standalone functions to accept self
            async def _bound_method(
                self: Any,
                params: Any,
                context: Any = None,
                _fn: Callable[..., Any] = fn,
            ) -> Any:
                return await _fn(params, context)

            setattr(DynamicHandler, task_name, _bound_method)

        return DynamicHandler()

Declarative server builder using decorators.

Use adcp_server() to create an instance, then register handlers with decorators. The builder can be passed directly to serve().

Example::

server = adcp_server("my-seller")

@server.get_products
async def get_products(params, context=None):
    return products_response(MY_PRODUCTS)

serve(server, name="my-seller")

Methods

def build_handler(self) ‑> ADCPHandler[typing.Any]
Expand source code
def build_handler(self) -> ADCPHandler[Any]:
    """Build an ADCPHandler from registered decorators.

    If ``get_adcp_capabilities`` is not registered, it will be
    auto-generated from the detected domains.
    """
    handlers = dict(self._handlers)

    # Auto-generate capabilities if not provided
    if "get_adcp_capabilities" not in handlers:
        domains = self._detect_domains()
        if domains:
            from adcp.server.responses import capabilities_response

            pinned_version = self._adcp_version

            async def auto_capabilities(params: Any, context: Any = None) -> dict[str, Any]:
                return capabilities_response(
                    domains,
                    adcp_version=pinned_version,
                )

            handlers["get_adcp_capabilities"] = auto_capabilities

    # Create a dynamic subclass. ``ADCPHandler[Any]`` because the
    # decorator-builder path doesn't thread a specific ToolContext
    # subclass — callers who want typed context go through the
    # class-based ``ADCPHandler[MyContext]`` route instead.
    class DynamicHandler(ADCPHandler[Any]):
        pass

    for task_name, fn in handlers.items():
        # Wrap standalone functions to accept self
        async def _bound_method(
            self: Any,
            params: Any,
            context: Any = None,
            _fn: Callable[..., Any] = fn,
        ) -> Any:
            return await _fn(params, context)

        setattr(DynamicHandler, task_name, _bound_method)

    return DynamicHandler()

Build an ADCPHandler from registered decorators.

If get_adcp_capabilities is not registered, it will be auto-generated from the detected domains.

def get_adcp_version(self) ‑> str
Expand source code
def get_adcp_version(self) -> str:
    """Return the AdCP protocol release this server is pinned to.

    Resolved at construction from the ``adcp_version`` kwarg, with
    fallback to the SDK's compile-time pin (``ADCP_VERSION``
    packaged with the wheel). Stage 2 plumbing — Stage 3 will use
    this to select which schema set the server validates handler
    responses against and which capability shape it advertises.
    """
    return self._adcp_version

Return the AdCP protocol release this server is pinned to.

Resolved at construction from the adcp_version kwarg, with fallback to the SDK's compile-time pin (ADCP_VERSION packaged with the wheel). Stage 2 plumbing — Stage 3 will use this to select which schema set the server validates handler responses against and which capability shape it advertises.

class AccountAwareToolContext (request_id: str | None = None,
caller_identity: str | None = None,
tenant_id: str | None = None,
metadata: dict[str, Any] = <factory>,
resolved_adcp_version: str | None = None,
account_id: str | None = None,
account: Any | None = None)
Expand source code
@dataclass
class AccountAwareToolContext(ToolContext):
    """ToolContext subclass carrying a resolved account scope.

    AdCP is account-aware: many operations accept an ``account`` field
    (:class:`~adcp.types.AccountReference`) that the seller resolves to
    a concrete account before executing the request. Handlers that need
    ``account_id`` throughout their business logic shouldn't have to
    re-derive it on every call — this subclass carries the resolved
    result on the context itself.

    The typical flow::

        class MyAgent(ADCPHandler[AccountAwareToolContext]):
            async def get_products(self, params, context=None):
                err = await resolve_account_into_context(
                    params, context, my_resolver,
                )
                if err:
                    return err  # ACCOUNT_NOT_FOUND / SUSPENDED / etc.
                # context.account_id is now populated
                return products_response(self.catalog.for_account(context.account_id))

    Sellers whose account scope is fixed by the authenticated principal
    (e.g. per-tenant API keys that map 1:1 to an account) can populate
    ``account_id`` directly in their ``context_factory`` and skip the
    per-call resolution entirely.

    :param account_id: The resolved, stable account identifier. Safe to
        use as a cache key, audit log field, or authorization scope.
    :param account: The resolver's opaque account object — whatever the
        seller's :func:`resolve_account` resolver returned. Typed as
        ``Any`` so sellers aren't forced to match the SDK's shape.
    """

    account_id: str | None = None
    account: Any | None = None

ToolContext subclass carrying a resolved account scope.

AdCP is account-aware: many operations accept an account field (:class:~adcp.types.AccountReference) that the seller resolves to a concrete account before executing the request. Handlers that need account_id throughout their business logic shouldn't have to re-derive it on every call — this subclass carries the resolved result on the context itself.

The typical flow::

class MyAgent(ADCPHandler[AccountAwareToolContext]):
    async def get_products(self, params, context=None):
        err = await resolve_account_into_context(
            params, context, my_resolver,
        )
        if err:
            return err  # ACCOUNT_NOT_FOUND / SUSPENDED / etc.
        # context.account_id is now populated
        return products_response(self.catalog.for_account(context.account_id))

Sellers whose account scope is fixed by the authenticated principal (e.g. per-tenant API keys that map 1:1 to an account) can populate account_id directly in their context_factory and skip the per-call resolution entirely.

:param account_id: The resolved, stable account identifier. Safe to use as a cache key, audit log field, or authorization scope. :param account: The resolver's opaque account object — whatever the seller's :func:resolve_account() resolver returned. Typed as Any so sellers aren't forced to match the SDK's shape.

Ancestors

Instance variables

var account : typing.Any | None
var account_id : str | None
class AccountError (code: str, message: str | None = None, *, suggestion: str | None = None)
Expand source code
class AccountError(Exception):
    """Raised by account resolvers to indicate a specific account error.

    Use this in your resolver to return structured errors for cases
    beyond simple "not found"::

        async def my_resolver(ref):
            account = db.find(ref)
            if not account:
                return None  # auto-returns ACCOUNT_NOT_FOUND
            if account.status == "suspended":
                raise AccountError("ACCOUNT_SUSPENDED", "Account is suspended")
            if account.status == "payment_required":
                raise AccountError("ACCOUNT_PAYMENT_REQUIRED",
                    suggestion="Update payment method at https://...")
            return account
    """

    def __init__(
        self,
        code: str,
        message: str | None = None,
        *,
        suggestion: str | None = None,
    ):
        self.code = code
        self.error_message = message
        self.suggestion = suggestion
        super().__init__(message or code)

Raised by account resolvers to indicate a specific account error.

Use this in your resolver to return structured errors for cases beyond simple "not found"::

async def my_resolver(ref):
    account = db.find(ref)
    if not account:
        return None  # auto-returns ACCOUNT_NOT_FOUND
    if account.status == "suspended":
        raise AccountError("ACCOUNT_SUSPENDED", "Account is suspended")
    if account.status == "payment_required":
        raise AccountError("ACCOUNT_PAYMENT_REQUIRED",
            suggestion="Update payment method at <https://...">)
    return account

Ancestors

  • builtins.Exception
  • builtins.BaseException
class AsyncTokenValidator (*args, **kwargs)
Expand source code
class AsyncTokenValidator(Protocol):
    """Asynchronous token validator —
    ``async def validate_token(token) -> Principal | None``."""

    def __call__(self, token: str) -> Awaitable[Principal | None]: ...

Asynchronous token validator — async def validate_token(token) -> Principal | None.

Ancestors

  • typing.Protocol
  • typing.Generic
class BearerTokenAuth (validate_token: TokenValidator,
header_name: str | None = None,
bearer_prefix_required: bool | None = None,
mcp_header_name: str | None = None,
mcp_bearer_prefix_required: bool | None = None,
a2a_header_name: str | None = None,
a2a_bearer_prefix_required: bool | None = None,
unauthenticated_response: dict[str, Any] | None = None,
legacy_header_aliases: Sequence[str] | None = None,
mcp_legacy_header_aliases: Sequence[str] | None = None,
a2a_legacy_header_aliases: Sequence[str] | None = None,
legacy_aliases_bearer_prefix_required: bool = False,
allow_unauthenticated: bool = False,
mcp_discovery_tools: Collection[str] | None = None)
Expand source code
@dataclass(frozen=True)
class BearerTokenAuth:
    """Cross-transport bearer-token auth config for :func:`adcp.server.serve`.

    Single source of truth that wires the same ``validate_token``
    callback into both the MCP-side :class:`BearerTokenAuthMiddleware`
    and the A2A-side :class:`A2ABearerAuthMiddleware`. Pass via
    ``serve(auth=BearerTokenAuth(...))`` and both legs are
    authenticated against the same token store with no per-leg
    drift::

        from adcp.server import serve
        from adcp.server.auth import BearerTokenAuth, validator_from_token_map

        serve(
            handler,
            transport="both",
            auth=BearerTokenAuth(
                validate_token=validator_from_token_map({
                    "secret-token": Principal(caller_identity="p", tenant_id="acme"),
                }),
            ),
        )

    On MCP, requests without a valid token receive a JSON ``401``
    body. On A2A, requests without a valid token receive an HTTP
    ``401``. Discovery bypasses are transport-specific:

    * **MCP**: ``initialize`` / ``tools/list`` / ``notifications/initialized``
      / ``get_adcp_capabilities`` (JSON-RPC method-level bypass).
    * **A2A**: ``/.well-known/agent-card.json`` (path-based — the
      agent-card route is registered alongside the JSON-RPC routes
      and the middleware exempts the well-known path).

    **Canonical carrier: ``Authorization: Bearer <token>`` (RFC 6750).**
    Both legs default to this. It is the only header backed by an actual
    RFC, what every off-the-shelf MCP / A2A / HTTP client emits by
    default, and what the AdCP spec is moving toward as canonical for
    both transports. Reach for ``BearerTokenAuth(validate_token=...)``
    with no other knobs and you get the protocol-canonical setup —
    including a ``bearerAuth`` ``HTTPAuthSecurityScheme`` (``scheme="bearer"``)
    auto-published on the agent card so a2a-sdk-based clients attach
    credentials without seller-side intervention.

    **``x-adcp-auth`` is a legacy-compat alias, not a recommended
    default.** Some early MCP adopters baked in a custom ``x-adcp-auth``
    header carrying a raw token (no scheme prefix) before the spec
    settled. Sellers with deployed clients that can't be updated opt
    in additively — ``Authorization: Bearer`` is still accepted, the
    alias is consulted only when the canonical header is absent::

        # Recommended new-shape (#720). Accepts both wire carriers.
        BearerTokenAuth(
            validate_token=...,
            mcp_legacy_header_aliases=["x-adcp-auth"],   # MCP additive
            # A2A keeps the canonical RFC 6750 carrier by default
        )

    Selecting a non-``Authorization`` header on the A2A leg is
    discouraged — buyers using non-a2a-sdk HTTP clients may not parse
    the resulting :class:`APIKeySecurityScheme` shape, and you lose
    interop with off-the-shelf A2A tooling. Use only when you control
    every buyer client.

    **Legacy single-knob compatibility.** ``header_name`` and
    ``bearer_prefix_required`` are still accepted: when set, they
    apply to *both* legs and override the per-leg defaults. Setting
    both ``header_name`` and a per-leg ``*_header_name`` (or both
    ``bearer_prefix_required`` and a per-leg
    ``*_bearer_prefix_required``) raises at construction — the
    framework can't decide which the operator intended.

    **Widening the MCP discovery gate.** Set ``mcp_discovery_tools``
    to allow extra ``tools/call`` names through unauthenticated. The
    spec default is just ``get_adcp_capabilities``; sellers who want
    product discovery (or other read-only surfaces) callable pre-auth
    extend the set::

        from adcp.server import serve
        from adcp.server.auth import BearerTokenAuth
        from adcp.server.mcp_tools import DISCOVERY_TOOLS

        serve(
            handler,
            transport="both",
            auth=BearerTokenAuth(
                validate_token=...,
                mcp_discovery_tools=DISCOVERY_TOOLS | {"get_products"},
            ),
        )

    ``__post_init__`` runs
    :func:`adcp.server.mcp_tools.validate_discovery_set` on the value —
    adding a mutating tool (``create_media_buy``, ``activate_signal``)
    or an unknown tool name fails loudly at boot rather than silently
    unauthenticating writes. Adopters with custom non-ADCP read-only
    tools that don't pass the spec validator should construct
    :class:`BearerTokenAuthMiddleware` directly with ``discovery_tools=``
    instead — the middleware constructor accepts the same kwarg without
    this stricter check, by design.

    A2A's discovery bypass is path-based
    (``/.well-known/agent-card.json``); there's no parallel A2A knob.
    """

    validate_token: TokenValidator
    # Legacy single-knob — applies to BOTH legs when set. Mutually
    # exclusive with the per-leg knobs below. Adopters who want the
    # canonical RFC 6750 setup should leave these unset (defaults
    # resolve to ``Authorization`` + ``Bearer`` prefix).
    header_name: str | None = None
    bearer_prefix_required: bool | None = None
    # Per-leg knobs — opt-in escape hatch for adopters with legacy
    # clients that send a raw token in a custom header (e.g.
    # ``x-adcp-auth``). The protocol-canonical carrier is
    # ``Authorization: Bearer <token>`` on both legs; reach for these
    # only when you can't update the client side.
    mcp_header_name: str | None = None
    mcp_bearer_prefix_required: bool | None = None
    a2a_header_name: str | None = None
    a2a_bearer_prefix_required: bool | None = None
    unauthenticated_response: dict[str, Any] | None = None
    # NEW (#720) — additive legacy aliases. ``Authorization: Bearer``
    # is ALWAYS accepted regardless of these fields; this is purely
    # additive opt-in for adopters mid-migration from custom headers.
    # Resolution order on each request: ``Authorization: Bearer``
    # first; if absent, each alias in order, first non-empty wins.
    #
    # Pick cross-leg ``legacy_header_aliases`` when both MCP and A2A
    # adopters send the same custom header (most common case during
    # migration). Pick per-leg ``mcp_legacy_header_aliases`` /
    # ``a2a_legacy_header_aliases`` when only one transport had
    # legacy clients (e.g. MCP rolled out earlier, A2A always used
    # the spec carrier). Both can coexist; per-leg values are
    # appended to the cross-leg list during resolution.
    #
    # Typed ``Sequence[str]`` so adopters can pass lists or tuples —
    # ``__post_init__`` rejects bare strings (the trailing-comma
    # tuple foot-gun: ``("x-adcp-auth")`` is a string, not a tuple).
    legacy_header_aliases: Sequence[str] | None = None
    mcp_legacy_header_aliases: Sequence[str] | None = None
    a2a_legacy_header_aliases: Sequence[str] | None = None
    legacy_aliases_bearer_prefix_required: bool = False
    # Network-trust mode (both legs). When True, requests carrying NO bearer
    # token are passed through to the app instead of receiving a 401 — identity
    # is resolved downstream by the host (e.g. from trusted X-Identity-* /
    # X-Principal-Id headers, where the agent is reachable only via the host's
    # authenticated proxy). A token that IS present but invalid is still
    # rejected. Default False preserves bearer-required auth on every request.
    allow_unauthenticated: bool = False
    # MCP-only — A2A's discovery bypass is path-based
    # (``/.well-known/agent-card.json``) and doesn't consult a tool
    # set. Set to widen the unauthenticated tool surface beyond the
    # spec-mandated default (:data:`adcp.server.mcp_tools.DISCOVERY_TOOLS`,
    # i.e. just ``get_adcp_capabilities``). The handshake methods
    # (``initialize``, ``tools/list``, ``notifications/initialized``)
    # always bypass regardless — this only widens the ``tools/call``
    # gate.
    #
    # Typed ``Collection[str]`` (not ``Sequence``) so the canonical
    # ``DISCOVERY_TOOLS | {"get_products"}`` example — which produces
    # a ``frozenset`` — type-checks cleanly. Lists/tuples still work
    # for adopters preferring those.
    #
    # ``__post_init__`` runs :func:`adcp.server.mcp_tools.validate_discovery_set`
    # on every non-empty value here, so adding a mutating tool
    # (``create_media_buy``, ``activate_signal``) or a name that
    # doesn't resolve to a known ADCP tool fails loudly at boot.
    # Adopters with custom non-ADCP read-only tools should construct
    # :class:`BearerTokenAuthMiddleware` directly with
    # ``discovery_tools=`` instead — the middleware constructor
    # accepts the same kwarg without the strictness, the dataclass
    # is the "safe-by-default" path.
    mcp_discovery_tools: Collection[str] | None = None

    def __post_init__(self) -> None:
        if self.header_name is not None and (
            self.mcp_header_name is not None or self.a2a_header_name is not None
        ):
            raise ValueError(
                "BearerTokenAuth: set either header_name (applies to both legs) "
                "or mcp_header_name / a2a_header_name (per-leg) — not both."
            )
        if self.bearer_prefix_required is not None and (
            self.mcp_bearer_prefix_required is not None
            or self.a2a_bearer_prefix_required is not None
        ):
            raise ValueError(
                "BearerTokenAuth: set either bearer_prefix_required (applies "
                "to both legs) or mcp_bearer_prefix_required / "
                "a2a_bearer_prefix_required (per-leg) — not both."
            )

        # Reject empty-string headers — they would silently 401 every
        # request because no wire header matches an empty name. A typo
        # like ``header_name=""`` should fail loudly at construction.
        for field_name in ("header_name", "mcp_header_name", "a2a_header_name"):
            value = getattr(self, field_name)
            if value is not None and not value.strip():
                raise ValueError(f"BearerTokenAuth: {field_name} must be a non-empty string.")

        # ``Authorization`` is reserved by RFC 7235 for ``<scheme>
        # <credentials>``. Carrying a raw token in ``Authorization``
        # breaks RFC-compliant intermediaries and a2a-sdk's auth
        # interceptor (which treats the header as bearer-shaped). If an
        # adopter wants a raw token, they need a custom header name.
        for header_field, prefix_field, leg in (
            ("header_name", "bearer_prefix_required", "both"),
            ("mcp_header_name", "mcp_bearer_prefix_required", "MCP"),
            ("a2a_header_name", "a2a_bearer_prefix_required", "A2A"),
        ):
            header = getattr(self, header_field)
            prefix = getattr(self, prefix_field)
            if header is not None and header.lower() == "authorization" and prefix is False:
                raise ValueError(
                    f"BearerTokenAuth: {header_field}='Authorization' with "
                    f"{prefix_field}=False on the {leg} leg violates RFC 7235 "
                    "(Authorization carries '<scheme> <credentials>'). Use a "
                    "custom header name (e.g. 'x-adcp-auth') for raw-token "
                    "schemes."
                )

        # ``mcp_discovery_tools`` validation: same trailing-comma
        # foot-gun as the alias fields, plus reject empty strings and
        # non-string entries. ``"tools/list"`` and similar handshake
        # methods don't belong here — they're matched by
        # ``DISCOVERY_METHODS`` independently of the tool set; listing
        # them as a "discovery tool" is a no-op and almost always a
        # config error. Reject loudly so the misuse doesn't sit silent.
        if self.mcp_discovery_tools is not None:
            if isinstance(self.mcp_discovery_tools, str):
                raise ValueError(
                    "BearerTokenAuth: mcp_discovery_tools must be a "
                    f"list/tuple/set of tool names, got bare str "
                    f"{self.mcp_discovery_tools!r}. Did you forget the "
                    f"trailing comma? Use "
                    f"``mcp_discovery_tools=({self.mcp_discovery_tools!r},)`` "
                    f"for a single-item tuple."
                )
            # Materialize once — both the per-entry validation below and
            # the readOnly check after iterate the collection; if the
            # adopter passes a generator the second pass would see an
            # empty exhausted iterator and silently skip validation.
            tools_list = list(self.mcp_discovery_tools)
            for name in tools_list:
                if not isinstance(name, str) or not name.strip():
                    raise ValueError(
                        "BearerTokenAuth: mcp_discovery_tools entries "
                        f"must be non-empty strings, got {name!r}."
                    )
            # Reject the empty-collection case explicitly. ``[]`` is a
            # plausible "disable all bypass" attempt, but the
            # middleware's ``None``-sentinel fallback would silently
            # restore the spec default — surprising and undocumented.
            # An operator who genuinely wants every ``tools/call`` to
            # require auth (including ``get_adcp_capabilities``, which
            # the spec mandates is callable pre-auth) should subclass
            # :class:`BearerTokenAuthMiddleware` and override
            # :meth:`is_discovery_request` — a deliberate spec deviation
            # deserves an explicit code change, not an empty list.
            if not tools_list:
                raise ValueError(
                    "BearerTokenAuth: mcp_discovery_tools is empty. "
                    "Either omit the field (``None`` keeps the spec "
                    "default) or subclass BearerTokenAuthMiddleware and "
                    "override is_discovery_request if you want to "
                    "tighten beyond the spec."
                )
            # Fail-closed safety check: reject mutating or unknown ADCP
            # tools. Adding ``create_media_buy`` to the auth-optional
            # set would silently unauthenticate writes — the exact
            # foot-gun :func:`validate_discovery_set` exists to catch.
            # Adopters with custom non-ADCP read-only tools should
            # construct :class:`BearerTokenAuthMiddleware` directly
            # with ``discovery_tools=``; the middleware constructor
            # accepts the same kwarg without this stricter check, by
            # design.
            from adcp.server.mcp_tools import validate_discovery_set

            validate_discovery_set(tools_list)

        # #720: validate the new ``*_legacy_header_aliases`` fields
        # at construction so silent misconfig fails loudly.
        for alias_field in (
            "legacy_header_aliases",
            "mcp_legacy_header_aliases",
            "a2a_legacy_header_aliases",
        ):
            value = getattr(self, alias_field)
            if value is None:
                continue
            if isinstance(value, str):
                # Bare string: the trailing-comma tuple foot-gun.
                # ``mcp_legacy_header_aliases="x-adcp-auth"`` is a
                # str, which is iterable letter-by-letter — the
                # resolver would happily treat each letter as a
                # separate alias. Fail loudly instead.
                raise ValueError(
                    f"BearerTokenAuth: {alias_field} must be a list/tuple "
                    f"of header names, got bare str {value!r}. Did you "
                    f"forget the trailing comma? Use "
                    f"``{alias_field}=({value!r},)`` for a single-item "
                    f"tuple, or ``{alias_field}=[{value!r}]`` for a list."
                )
            for name in value:
                if not isinstance(name, str) or not name.strip():
                    raise ValueError(
                        f"BearerTokenAuth: {alias_field} entries must be "
                        f"non-empty strings, got {name!r}."
                    )
                if name.lower() == "authorization":
                    # ``Authorization`` is always accepted by the
                    # middleware (the spec-canonical carrier per RFC
                    # 6750). Listing it as a legacy alias is a no-op,
                    # so flagging the misconfig loudly here is
                    # friendlier than silently dropping it.
                    raise ValueError(
                        f"BearerTokenAuth: {alias_field} cannot include "
                        "'authorization' — the spec-canonical header is "
                        "always accepted by the middleware unconditionally. "
                        "Listing it as a legacy alias is a no-op and "
                        "almost always a config error."
                    )

        # #720: deprecate the EXCLUSIVE per-leg / cross-leg header_name
        # kwargs in favor of additive ``*_legacy_header_aliases``.
        # Setting any of them maps the value into the resolved alias
        # list AND keeps ``Authorization: Bearer`` accepted — fixes
        # the silent-401 against spec-compliant clients (security
        # baseline ``probe_api_key`` storyboard).
        _legacy_to_new = {
            "header_name": "legacy_header_aliases",
            "mcp_header_name": "mcp_legacy_header_aliases",
            "a2a_header_name": "a2a_legacy_header_aliases",
        }
        for legacy_field, new_field in _legacy_to_new.items():
            value = getattr(self, legacy_field)
            if value is not None and value.lower() != "authorization":
                warnings.warn(
                    f"BearerTokenAuth({legacy_field}={value!r}) is "
                    "deprecated — the EXCLUSIVE single-header model "
                    "silently rejects spec-compliant clients sending "
                    f"``Authorization: Bearer``. Migrate to "
                    f"``{new_field}=({value!r},)``: the new shape ADDS "
                    "your custom header as an alias while keeping the "
                    "RFC 6750 canonical header accepted. See #720.",
                    DeprecationWarning,
                    stacklevel=3,
                )

    def resolved_mcp_header_name(self) -> str:
        """Effective MCP header name after legacy + default fallback.

        Resolution order: legacy ``header_name`` → ``mcp_header_name``
        → ``"authorization"`` (RFC 6750, the protocol-canonical carrier
        on MCP). Adopters with legacy clients sending ``x-adcp-auth``
        opt in via ``mcp_header_name``; the default itself stays on
        ``Authorization`` because that's what the spec is moving
        toward as canonical.
        """
        if self.header_name is not None:
            return self.header_name
        if self.mcp_header_name is not None:
            return self.mcp_header_name
        return "authorization"

    def resolved_mcp_bearer_prefix_required(self) -> bool:
        """Effective MCP bearer-prefix flag after legacy + default fallback.

        Resolution order: legacy ``bearer_prefix_required`` →
        ``mcp_bearer_prefix_required`` → ``True`` (RFC 6750 — the
        canonical setup is ``Authorization: Bearer <token>``).
        """
        if self.bearer_prefix_required is not None:
            return self.bearer_prefix_required
        if self.mcp_bearer_prefix_required is not None:
            return self.mcp_bearer_prefix_required
        return True

    def resolved_a2a_header_name(self) -> str:
        """Effective A2A header name after legacy + default fallback.

        Resolution order: legacy ``header_name`` → ``a2a_header_name``
        → ``"Authorization"`` (RFC 6750 — what a2a-sdk and every
        off-the-shelf HTTP library send by default). Setting
        ``a2a_header_name`` to anything else is discouraged: buyers
        using non-a2a-sdk HTTP clients may not parse the resulting
        :class:`APIKeySecurityScheme` shape on the agent card and
        you lose interop with off-the-shelf A2A tooling.
        """
        if self.header_name is not None:
            return self.header_name
        if self.a2a_header_name is not None:
            return self.a2a_header_name
        return "Authorization"

    def resolved_a2a_bearer_prefix_required(self) -> bool:
        """Effective A2A bearer-prefix flag after legacy + default fallback.

        Resolution order: legacy ``bearer_prefix_required`` →
        ``a2a_bearer_prefix_required`` → ``True`` (RFC 6750 — the
        canonical setup is ``Authorization: Bearer <token>``).
        """
        if self.bearer_prefix_required is not None:
            return self.bearer_prefix_required
        if self.a2a_bearer_prefix_required is not None:
            return self.a2a_bearer_prefix_required
        return True

    def resolved_mcp_legacy_aliases(self) -> list[str]:
        """Effective MCP legacy-alias list after legacy + new-shape merge.

        Per #720, ``Authorization: Bearer`` is ALWAYS accepted; this
        method returns the additional header names accepted alongside.
        Sources, in order:

        1. ``legacy_header_aliases`` (cross-leg, new-shape).
        2. ``mcp_legacy_header_aliases`` (per-leg, new-shape).
        3. Deprecated ``header_name`` when set to a non-canonical
           value (folded in as an alias for back-compat).
        4. Deprecated ``mcp_header_name`` when set to a non-canonical
           value (same).

        Returns an empty list when the adopter wants spec-canonical
        only (no legacy clients to keep working).
        """
        return _merge_alias_sources(
            self.legacy_header_aliases,
            self.mcp_legacy_header_aliases,
            self.header_name,
            self.mcp_header_name,
        )

    def resolved_a2a_legacy_aliases(self) -> list[str]:
        """Effective A2A legacy-alias list — analog of
        :meth:`resolved_mcp_legacy_aliases` for the A2A leg. See #720."""
        return _merge_alias_sources(
            self.legacy_header_aliases,
            self.a2a_legacy_header_aliases,
            self.header_name,
            self.a2a_header_name,
        )

    def resolved_mcp_discovery_tools(self) -> frozenset[str] | None:
        """Effective MCP discovery-tool set, or ``None`` to use the
        spec-mandated default (:data:`adcp.server.mcp_tools.DISCOVERY_TOOLS`).

        Returns a :class:`frozenset` so the middleware's discovery
        check stays an O(1) membership test, and so the value is
        hashable / safe to share across requests without defensive
        copying. ``None`` means the middleware falls back to the
        module-level default rather than constructing a parallel
        empty set (avoids drift if the spec default ever changes).
        """
        if self.mcp_discovery_tools is None:
            return None
        return frozenset(self.mcp_discovery_tools)

Cross-transport bearer-token auth config for :func:serve().

Single source of truth that wires the same validate_token callback into both the MCP-side :class:BearerTokenAuthMiddleware and the A2A-side :class:A2ABearerAuthMiddleware. Pass via serve(auth=BearerTokenAuth(...)) and both legs are authenticated against the same token store with no per-leg drift::

from adcp.server import serve
from adcp.server.auth import BearerTokenAuth, validator_from_token_map

serve(
    handler,
    transport="both",
    auth=BearerTokenAuth(
        validate_token=validator_from_token_map({
            "secret-token": Principal(caller_identity="p", tenant_id="acme"),
        }),
    ),
)

On MCP, requests without a valid token receive a JSON 401 body. On A2A, requests without a valid token receive an HTTP 401. Discovery bypasses are transport-specific:

  • MCP: initialize / tools/list / notifications/initialized / get_adcp_capabilities (JSON-RPC method-level bypass).
  • A2A: /.well-known/agent-card.json (path-based — the agent-card route is registered alongside the JSON-RPC routes and the middleware exempts the well-known path).

Canonical carrier: Authorization: Bearer <token> (RFC 6750). Both legs default to this. It is the only header backed by an actual RFC, what every off-the-shelf MCP / A2A / HTTP client emits by default, and what the AdCP spec is moving toward as canonical for both transports. Reach for BearerTokenAuth(validate_token=...) with no other knobs and you get the protocol-canonical setup — including a bearerAuth HTTPAuthSecurityScheme (scheme="bearer") auto-published on the agent card so a2a-sdk-based clients attach credentials without seller-side intervention.

x-adcp-auth is a legacy-compat alias, not a recommended default. Some early MCP adopters baked in a custom x-adcp-auth header carrying a raw token (no scheme prefix) before the spec settled. Sellers with deployed clients that can't be updated opt in additively — Authorization: Bearer is still accepted, the alias is consulted only when the canonical header is absent::

# Recommended new-shape (#720). Accepts both wire carriers.
BearerTokenAuth(
    validate_token=...,
    mcp_legacy_header_aliases=["x-adcp-auth"],   # MCP additive
    # A2A keeps the canonical RFC 6750 carrier by default
)

Selecting a non-Authorization header on the A2A leg is discouraged — buyers using non-a2a-sdk HTTP clients may not parse the resulting :class:APIKeySecurityScheme shape, and you lose interop with off-the-shelf A2A tooling. Use only when you control every buyer client.

Legacy single-knob compatibility. header_name and bearer_prefix_required are still accepted: when set, they apply to both legs and override the per-leg defaults. Setting both header_name and a per-leg *_header_name (or both bearer_prefix_required and a per-leg *_bearer_prefix_required) raises at construction — the framework can't decide which the operator intended.

Widening the MCP discovery gate. Set mcp_discovery_tools to allow extra tools/call names through unauthenticated. The spec default is just get_adcp_capabilities; sellers who want product discovery (or other read-only surfaces) callable pre-auth extend the set::

from adcp.server import serve
from adcp.server.auth import BearerTokenAuth
from adcp.server.mcp_tools import DISCOVERY_TOOLS

serve(
    handler,
    transport="both",
    auth=BearerTokenAuth(
        validate_token=...,
        mcp_discovery_tools=DISCOVERY_TOOLS | {"get_products"},
    ),
)

__post_init__ runs :func:validate_discovery_set() on the value — adding a mutating tool (create_media_buy, activate_signal) or an unknown tool name fails loudly at boot rather than silently unauthenticating writes. Adopters with custom non-ADCP read-only tools that don't pass the spec validator should construct :class:BearerTokenAuthMiddleware directly with discovery_tools= instead — the middleware constructor accepts the same kwarg without this stricter check, by design.

A2A's discovery bypass is path-based (/.well-known/agent-card.json); there's no parallel A2A knob.

Instance variables

var a2a_bearer_prefix_required : bool | None
var a2a_header_name : str | None
var a2a_legacy_header_aliases : collections.abc.Sequence[str] | None
var allow_unauthenticated : bool
var bearer_prefix_required : bool | None
var header_name : str | None
var legacy_aliases_bearer_prefix_required : bool
var legacy_header_aliases : collections.abc.Sequence[str] | None
var mcp_bearer_prefix_required : bool | None
var mcp_discovery_tools : collections.abc.Collection[str] | None
var mcp_header_name : str | None
var mcp_legacy_header_aliases : collections.abc.Sequence[str] | None
var unauthenticated_response : dict[str, typing.Any] | None
var validate_tokenSyncTokenValidator | AsyncTokenValidator

Methods

def resolved_a2a_bearer_prefix_required(self) ‑> bool
Expand source code
def resolved_a2a_bearer_prefix_required(self) -> bool:
    """Effective A2A bearer-prefix flag after legacy + default fallback.

    Resolution order: legacy ``bearer_prefix_required`` →
    ``a2a_bearer_prefix_required`` → ``True`` (RFC 6750 — the
    canonical setup is ``Authorization: Bearer <token>``).
    """
    if self.bearer_prefix_required is not None:
        return self.bearer_prefix_required
    if self.a2a_bearer_prefix_required is not None:
        return self.a2a_bearer_prefix_required
    return True

Effective A2A bearer-prefix flag after legacy + default fallback.

Resolution order: legacy bearer_prefix_requireda2a_bearer_prefix_requiredTrue (RFC 6750 — the canonical setup is Authorization: Bearer <token>).

def resolved_a2a_header_name(self) ‑> str
Expand source code
def resolved_a2a_header_name(self) -> str:
    """Effective A2A header name after legacy + default fallback.

    Resolution order: legacy ``header_name`` → ``a2a_header_name``
    → ``"Authorization"`` (RFC 6750 — what a2a-sdk and every
    off-the-shelf HTTP library send by default). Setting
    ``a2a_header_name`` to anything else is discouraged: buyers
    using non-a2a-sdk HTTP clients may not parse the resulting
    :class:`APIKeySecurityScheme` shape on the agent card and
    you lose interop with off-the-shelf A2A tooling.
    """
    if self.header_name is not None:
        return self.header_name
    if self.a2a_header_name is not None:
        return self.a2a_header_name
    return "Authorization"

Effective A2A header name after legacy + default fallback.

Resolution order: legacy header_namea2a_header_name"Authorization" (RFC 6750 — what a2a-sdk and every off-the-shelf HTTP library send by default). Setting a2a_header_name to anything else is discouraged: buyers using non-a2a-sdk HTTP clients may not parse the resulting :class:APIKeySecurityScheme shape on the agent card and you lose interop with off-the-shelf A2A tooling.

def resolved_a2a_legacy_aliases(self) ‑> list[str]
Expand source code
def resolved_a2a_legacy_aliases(self) -> list[str]:
    """Effective A2A legacy-alias list — analog of
    :meth:`resolved_mcp_legacy_aliases` for the A2A leg. See #720."""
    return _merge_alias_sources(
        self.legacy_header_aliases,
        self.a2a_legacy_header_aliases,
        self.header_name,
        self.a2a_header_name,
    )

Effective A2A legacy-alias list — analog of :meth:resolved_mcp_legacy_aliases for the A2A leg. See #720.

def resolved_mcp_bearer_prefix_required(self) ‑> bool
Expand source code
def resolved_mcp_bearer_prefix_required(self) -> bool:
    """Effective MCP bearer-prefix flag after legacy + default fallback.

    Resolution order: legacy ``bearer_prefix_required`` →
    ``mcp_bearer_prefix_required`` → ``True`` (RFC 6750 — the
    canonical setup is ``Authorization: Bearer <token>``).
    """
    if self.bearer_prefix_required is not None:
        return self.bearer_prefix_required
    if self.mcp_bearer_prefix_required is not None:
        return self.mcp_bearer_prefix_required
    return True

Effective MCP bearer-prefix flag after legacy + default fallback.

Resolution order: legacy bearer_prefix_requiredmcp_bearer_prefix_requiredTrue (RFC 6750 — the canonical setup is Authorization: Bearer <token>).

def resolved_mcp_discovery_tools(self) ‑> frozenset[str] | None
Expand source code
def resolved_mcp_discovery_tools(self) -> frozenset[str] | None:
    """Effective MCP discovery-tool set, or ``None`` to use the
    spec-mandated default (:data:`adcp.server.mcp_tools.DISCOVERY_TOOLS`).

    Returns a :class:`frozenset` so the middleware's discovery
    check stays an O(1) membership test, and so the value is
    hashable / safe to share across requests without defensive
    copying. ``None`` means the middleware falls back to the
    module-level default rather than constructing a parallel
    empty set (avoids drift if the spec default ever changes).
    """
    if self.mcp_discovery_tools is None:
        return None
    return frozenset(self.mcp_discovery_tools)

Effective MCP discovery-tool set, or None to use the spec-mandated default (:data:adcp.server.mcp_tools.DISCOVERY_TOOLS).

Returns a :class:frozenset so the middleware's discovery check stays an O(1) membership test, and so the value is hashable / safe to share across requests without defensive copying. None means the middleware falls back to the module-level default rather than constructing a parallel empty set (avoids drift if the spec default ever changes).

def resolved_mcp_header_name(self) ‑> str
Expand source code
def resolved_mcp_header_name(self) -> str:
    """Effective MCP header name after legacy + default fallback.

    Resolution order: legacy ``header_name`` → ``mcp_header_name``
    → ``"authorization"`` (RFC 6750, the protocol-canonical carrier
    on MCP). Adopters with legacy clients sending ``x-adcp-auth``
    opt in via ``mcp_header_name``; the default itself stays on
    ``Authorization`` because that's what the spec is moving
    toward as canonical.
    """
    if self.header_name is not None:
        return self.header_name
    if self.mcp_header_name is not None:
        return self.mcp_header_name
    return "authorization"

Effective MCP header name after legacy + default fallback.

Resolution order: legacy header_namemcp_header_name"authorization" (RFC 6750, the protocol-canonical carrier on MCP). Adopters with legacy clients sending x-adcp-auth opt in via mcp_header_name; the default itself stays on Authorization because that's what the spec is moving toward as canonical.

def resolved_mcp_legacy_aliases(self) ‑> list[str]
Expand source code
def resolved_mcp_legacy_aliases(self) -> list[str]:
    """Effective MCP legacy-alias list after legacy + new-shape merge.

    Per #720, ``Authorization: Bearer`` is ALWAYS accepted; this
    method returns the additional header names accepted alongside.
    Sources, in order:

    1. ``legacy_header_aliases`` (cross-leg, new-shape).
    2. ``mcp_legacy_header_aliases`` (per-leg, new-shape).
    3. Deprecated ``header_name`` when set to a non-canonical
       value (folded in as an alias for back-compat).
    4. Deprecated ``mcp_header_name`` when set to a non-canonical
       value (same).

    Returns an empty list when the adopter wants spec-canonical
    only (no legacy clients to keep working).
    """
    return _merge_alias_sources(
        self.legacy_header_aliases,
        self.mcp_legacy_header_aliases,
        self.header_name,
        self.mcp_header_name,
    )

Effective MCP legacy-alias list after legacy + new-shape merge.

Per #720, Authorization: Bearer is ALWAYS accepted; this method returns the additional header names accepted alongside. Sources, in order:

  1. legacy_header_aliases (cross-leg, new-shape).
  2. mcp_legacy_header_aliases (per-leg, new-shape).
  3. Deprecated header_name when set to a non-canonical value (folded in as an alias for back-compat).
  4. Deprecated mcp_header_name when set to a non-canonical value (same).

Returns an empty list when the adopter wants spec-canonical only (no legacy clients to keep working).

class BearerTokenAuthMiddleware (app: Any,
*,
validate_token: TokenValidator,
unauthenticated_response: dict[str, Any] | None = None,
legacy_header_aliases: list[str] | None = None,
legacy_aliases_bearer_prefix_required: bool = False,
header_name: str | None = None,
bearer_prefix_required: bool | None = None,
discovery_tools: frozenset[str] | None = None,
allow_unauthenticated: bool = False)
Expand source code
class BearerTokenAuthMiddleware(BaseHTTPMiddleware):
    """Starlette HTTP middleware that gates every non-discovery JSON-RPC
    request on a valid bearer token.

    Instantiate via ``app.add_middleware`` with a seller-supplied
    :data:`TokenValidator`::

        app.add_middleware(
            BearerTokenAuthMiddleware,
            validate_token=my_validate_token,
        )

    On success, populates :data:`current_principal`,
    :data:`current_tenant`, and :data:`current_principal_metadata`
    for the duration of the downstream call. On failure, returns
    ``401`` without invoking the handler.

    **Discovery bypass.** ``initialize``, ``notifications/initialized``,
    and ``tools/list`` (MCP handshake) plus ``get_adcp_capabilities``
    (AdCP handshake) are always exempt — these run before any client
    has credentials. Pass ``discovery_tools=`` to widen the
    ``tools/call`` gate beyond the spec default — useful for sellers
    that want product discovery (or other read-only surfaces)
    callable pre-auth without subclassing. Operators who consider
    their tool surface sensitive can still subclass and override
    :meth:`is_discovery_request` to tighten the bypass (e.g. require
    auth on ``tools/list``).

    **Body is peeked, not consumed.** The middleware reads the
    JSON-RPC payload to identify the ``method`` / ``tool`` name for
    the discovery gate; Starlette caches the body on the request so
    handlers still read it normally.

    :param app: The inner ASGI app. Passed by Starlette —
        ``app.add_middleware`` supplies it automatically.
    :param validate_token: Your token lookup. See :data:`TokenValidator`.
    :param unauthenticated_response: Optional override for the 401
        response body. Default is ``{"error": "unauthenticated"}``.
    :param legacy_header_aliases: Optional list of legacy header names
        to accept in addition to the spec-canonical ``Authorization:
        Bearer``. Resolution order on every request: ``Authorization:
        Bearer <token>`` first; if absent, each alias in order; first
        non-empty wins. The aliases path is for adopters mid-migration
        from a custom header (e.g. ``x-adcp-auth``) — both work
        simultaneously so no flag-day cutover is needed. **The
        spec-canonical header is always accepted; aliases are
        purely additive.**
    :param legacy_aliases_bearer_prefix_required: Whether legacy alias
        headers must carry a ``"Bearer "`` prefix. Default ``False``
        — legacy custom-header schemes (``x-adcp-auth: <token>``)
        carry the raw token. RFC 6750 ``Authorization`` always
        requires the prefix regardless of this flag.
    :param header_name: **DEPRECATED.** Set to a custom header name to
        accept that header as a legacy alias. Maps internally to
        ``legacy_header_aliases=[header_name]``. The historical
        EXCLUSIVE behavior — "accept only this header, reject
        Authorization" — was a foot-gun: every spec-compliant client
        (browsers, every off-the-shelf HTTP library, the SDK's
        ``security_baseline/probe_api_key`` probe) sends
        ``Authorization: Bearer`` and was getting 401 against valid
        tokens. The new behavior is ADDITIVE; pass
        ``legacy_header_aliases=[...]`` explicitly for new code.
        See #720.
    :param bearer_prefix_required: **DEPRECATED.** Maps to
        ``legacy_aliases_bearer_prefix_required`` when ``header_name``
        is also set. Ignored when ``header_name`` is the canonical
        ``"authorization"`` (which always requires ``Bearer``).
    :param discovery_tools: Optional override for the per-instance
        ``tools/call`` discovery set. ``None`` (default) keeps the
        spec-mandated default
        (:data:`adcp.server.mcp_tools.DISCOVERY_TOOLS`, currently
        ``{"get_adcp_capabilities"}``). Pass an extended set to
        allow more tools through unauthenticated. Common pattern::

            from adcp.server.mcp_tools import DISCOVERY_TOOLS

            BearerTokenAuthMiddleware(
                app,
                validate_token=...,
                discovery_tools=DISCOVERY_TOOLS | {"get_products"},
            )

        **No validation happens at this layer** — adopters wiring
        the middleware directly are trusted to know what they're
        unauthenticating. The dataclass path
        (:attr:`BearerTokenAuth.mcp_discovery_tools`) runs
        :func:`adcp.server.mcp_tools.validate_discovery_set` for you,
        refusing to add mutating or unknown ADCP tools. **Prefer
        the dataclass path** unless you have a custom non-ADCP
        read-only tool that the spec validator rejects.

        The handshake methods always bypass regardless — this only
        widens ``tools/call``. An empty collection here means
        literally "no tools/call bypasses auth" (the spec default
        is restored only when the kwarg is ``None``); the dataclass
        path rejects empty collections so adopters don't hit this
        accidentally.
    """

    def __init__(
        self,
        app: Any,
        *,
        validate_token: TokenValidator,
        unauthenticated_response: dict[str, Any] | None = None,
        legacy_header_aliases: list[str] | None = None,
        legacy_aliases_bearer_prefix_required: bool = False,
        header_name: str | None = None,
        bearer_prefix_required: bool | None = None,
        discovery_tools: frozenset[str] | None = None,
        allow_unauthenticated: bool = False,
    ) -> None:
        super().__init__(app)
        self._validate_token = validate_token
        self._allow_unauthenticated = allow_unauthenticated
        self._unauth_body = unauthenticated_response or {"error": "unauthenticated"}
        # Per-instance discovery-tool set delivers on the extension hook
        # promised in ``adcp.server.mcp_tools`` (see ``DISCOVERY_TOOLS``
        # docstring): adopters who want product-discovery callable without
        # onboarding pass ``discovery_tools=DISCOVERY_TOOLS | {"get_products"}``
        # without having to subclass and override ``is_discovery_request``.
        # ``None`` preserves the spec-mandated default — a single tool,
        # ``get_adcp_capabilities``, available pre-auth.
        self._discovery_tools = discovery_tools

        # Back-compat shim for ``header_name`` / ``bearer_prefix_required``:
        # the old EXCLUSIVE semantics ("only this header is accepted")
        # broke every spec-compliant client because the middleware
        # silently ignored ``Authorization: Bearer``. The new ADDITIVE
        # model treats a custom ``header_name`` as a legacy alias — the
        # canonical ``Authorization: Bearer`` stays accepted alongside.
        # Emit a deprecation warning so adopters migrate to the explicit
        # ``legacy_header_aliases`` kwarg.
        aliases: list[str] = list(legacy_header_aliases or [])
        alias_prefix_required = legacy_aliases_bearer_prefix_required
        if header_name is not None and header_name.lower() != "authorization":
            warnings.warn(
                "BearerTokenAuthMiddleware(header_name=...) is deprecated. "
                "Pass legacy_header_aliases=[header_name] instead. The new "
                "shape is ADDITIVE — ``Authorization: Bearer`` is always "
                "accepted alongside the alias, fixing the silent-401 bug "
                "spec-compliant clients hit against the old EXCLUSIVE "
                "behavior. See #720.",
                DeprecationWarning,
                stacklevel=2,
            )
            if header_name not in aliases:
                aliases.insert(0, header_name)
            if bearer_prefix_required is not None:
                alias_prefix_required = bearer_prefix_required
        elif bearer_prefix_required is not None:
            warnings.warn(
                "BearerTokenAuthMiddleware(bearer_prefix_required=...) is "
                "deprecated. Pass legacy_aliases_bearer_prefix_required "
                "for alias headers; ``Authorization: Bearer`` always "
                "requires the ``Bearer`` prefix regardless. See #720.",
                DeprecationWarning,
                stacklevel=2,
            )
        self._alias_header_names = tuple(h.lower() for h in aliases)
        self._alias_prefix_required = alias_prefix_required

    async def dispatch(self, request: Request, call_next: Any) -> Any:
        method, tool = await self._peek_jsonrpc(request)

        principal_token = None
        tenant_token = None
        metadata_token = None
        try:
            if self.is_discovery_request(method, tool):
                principal_token = current_principal.set(None)
                tenant_token = current_tenant.set(None)
                metadata_token = current_principal_metadata.set(None)
                _set_request_state(request, None, None, None)
                return await call_next(request)

            bearer = self._extract_bearer(request)
            if not bearer:
                if self._allow_unauthenticated:
                    # Network-trust deployment: no bearer is expected on this
                    # leg — the agent is reachable only via the host's
                    # authenticated proxy, which propagates identity downstream
                    # (e.g. X-Identity-* / X-Principal-Id). Pass through with no
                    # principal, exactly like the discovery bypass; the app
                    # resolves and enforces identity. A token that IS present but
                    # invalid still falls through to rejection below.
                    principal_token = current_principal.set(None)
                    tenant_token = current_tenant.set(None)
                    metadata_token = current_principal_metadata.set(None)
                    _set_request_state(request, None, None, None)
                    return await call_next(request)
                return self._unauthenticated()

            try:
                raw = self._validate_token(bearer)
                principal: Principal | None
                if inspect.isawaitable(raw):
                    principal = await raw
                else:
                    principal = raw
            except Exception:
                # Validator failure must not leak stack info to the caller.
                # Fail closed — a buggy validator is an auth failure, not a
                # 500. Logged for operators.
                logger.exception("token validator raised")
                return self._unauthenticated()

            if principal is None:
                return self._unauthenticated()

            principal_metadata = dict(principal.metadata) if principal.metadata else None
            principal_token = current_principal.set(principal.caller_identity)
            tenant_token = current_tenant.set(principal.tenant_id)
            metadata_token = current_principal_metadata.set(principal_metadata)
            # Mirror onto ``request.state`` so the dispatch-side
            # ``context_factory`` can read the principal even when the
            # MCP server is in stateful mode (where the session task is a
            # separate async task than this middleware's task and does
            # not see the ContextVar set above). ``request.state`` is the
            # standard Starlette per-request scratchpad and travels with
            # the request through any nested ASGI app.
            _set_request_state(
                request,
                principal.caller_identity,
                principal.tenant_id,
                principal_metadata,
            )
            return await call_next(request)
        finally:
            # Reset unconditionally so a later task sharing this context
            # doesn't read a stale principal. Matches the idempotency
            # store's "fail fast on missing caller_identity" contract.
            if principal_token is not None:
                current_principal.reset(principal_token)
            if tenant_token is not None:
                current_tenant.reset(tenant_token)
            if metadata_token is not None:
                current_principal_metadata.reset(metadata_token)

    def _extract_bearer(self, request: Request) -> str | None:
        """Resolve the token from incoming headers.

        Per RFC 6750 §2.1 the canonical carrier is ``Authorization:
        Bearer <token>``; check that first. If absent, walk the
        configured ``legacy_header_aliases`` in order — first non-empty
        wins. Legacy aliases carry raw tokens (no scheme prefix) unless
        ``legacy_aliases_bearer_prefix_required=True``. Both paths
        coexist so adopters mid-migration can move clients from a
        custom header to ``Authorization: Bearer`` without a flag day
        (#720).
        """
        # 1. Spec-canonical first.
        canonical = request.headers.get("authorization", "")
        bearer = _parse_bearer_header(canonical)
        if bearer:
            return bearer

        # 2. Legacy aliases — additive opt-in.
        for alias in self._alias_header_names:
            raw = request.headers.get(alias, "")
            if not raw:
                continue
            if self._alias_prefix_required:
                token = _parse_bearer_header(raw)
            else:
                token = raw.strip() or None
            if token:
                return token

        return None

    def is_discovery_request(self, method: str | None, tool: str | None) -> bool:
        """True when the request should bypass auth.

        Defaults to the spec-mandated discovery set
        (:data:`adcp.server.mcp_tools.DISCOVERY_TOOLS`, currently
        ``{"get_adcp_capabilities"}``). Two ways to widen the gate:

        1. Pass ``discovery_tools=`` at construction (preferred — no
           subclass needed). The override is per-instance and
           composes with the spec-mandated MCP handshake methods
           (``initialize`` / ``tools/list`` / etc., which always
           bypass regardless). When wiring via :class:`BearerTokenAuth`,
           strongly prefer the dataclass field — it runs
           :func:`adcp.server.mcp_tools.validate_discovery_set` for
           you, refusing to silently unauthenticate mutating tools.
        2. Subclass + override this method when the discovery
           predicate needs to inspect the request more deeply than
           a static tool-name set (e.g. allow ``tools/call`` only
           when accompanied by a specific header).

        Operators who want to *tighten* — e.g. require auth on
        ``tools/list`` — still need to subclass; ``discovery_tools``
        only widens.
        """
        if method in DISCOVERY_METHODS:
            return True
        if method != "tools/call":
            return False
        # Explicit ``is not None`` (not ``or``) so an empty frozenset
        # produced by some upstream filter doesn't silently fall back
        # to the spec default. Empty collections are rejected at the
        # dataclass layer; middleware-direct callers who pass an empty
        # set get the literal "nothing bypasses ``tools/call``"
        # behavior they asked for.
        discovery_tools = (
            self._discovery_tools if self._discovery_tools is not None else DISCOVERY_TOOLS
        )
        return tool in discovery_tools

    def _unauthenticated(self) -> JSONResponse:
        # RFC 6750 §3 + RFC 7235 §3.1 require ``WWW-Authenticate: Bearer``
        # on every 401 from a Bearer-protected resource. Always emit;
        # even when the operator overrides ``unauthenticated_response``,
        # the header stays for protocol compliance. Realm matches the
        # A2A leg (RFC 7235 §2.2 — same protection space).
        return JSONResponse(
            self._unauth_body,
            status_code=401,
            headers={"WWW-Authenticate": _WWW_AUTHENTICATE_CHALLENGE},
        )

    @staticmethod
    async def _peek_jsonrpc(request: Request) -> tuple[str | None, str | None]:
        """Inspect the JSON-RPC body without preventing handlers from
        reading it downstream. Returns ``(method, tool_name)``.

        Explicitly caches the body on ``request._body`` so downstream
        handlers receive the same bytes. Starlette's ``Request`` caches
        the first ``.body()`` call via this attribute, but relying on
        that behavior implicitly is fragile — nested ASGI apps that
        read the raw ``receive`` callable (as FastMCP's streamable-HTTP
        transport does) will otherwise observe an empty body. The
        explicit assignment matches the documented Starlette middleware
        body-peek pattern.

        Fails closed on batch arrays — the JSON-RPC 2.0 spec allows
        them, but the handshake methods never come in batches and
        permitting them here would let a client smuggle a mutation past
        the discovery gate inside a batch.
        """
        body = await request.body()
        # Ensure the body is cached for downstream reads. ``request.body()``
        # already sets ``_body``; the explicit re-assignment is a belt-and-
        # suspenders guard against Starlette internals changing and a
        # pinned target for the body-round-trip test.
        request._body = body
        if not body:
            return None, None
        try:
            payload = json.loads(body)
        except ValueError:
            return None, None
        if not isinstance(payload, dict):
            return None, None
        method = payload.get("method")
        method = method if isinstance(method, str) else None
        if method != "tools/call":
            return method, None
        params = payload.get("params") or {}
        name = params.get("name") if isinstance(params, dict) else None
        return method, (name if isinstance(name, str) else None)

Starlette HTTP middleware that gates every non-discovery JSON-RPC request on a valid bearer token.

Instantiate via app.add_middleware with a seller-supplied :data:TokenValidator::

app.add_middleware(
    BearerTokenAuthMiddleware,
    validate_token=my_validate_token,
)

On success, populates :data:current_principal, :data:current_tenant(), and :data:current_principal_metadata for the duration of the downstream call. On failure, returns 401 without invoking the handler.

Discovery bypass. initialize, notifications/initialized, and tools/list (MCP handshake) plus get_adcp_capabilities (AdCP handshake) are always exempt — these run before any client has credentials. Pass discovery_tools= to widen the tools/call gate beyond the spec default — useful for sellers that want product discovery (or other read-only surfaces) callable pre-auth without subclassing. Operators who consider their tool surface sensitive can still subclass and override :meth:is_discovery_request to tighten the bypass (e.g. require auth on tools/list).

Body is peeked, not consumed. The middleware reads the JSON-RPC payload to identify the method / tool name for the discovery gate; Starlette caches the body on the request so handlers still read it normally.

:param app: The inner ASGI app. Passed by Starlette — app.add_middleware supplies it automatically. :param validate_token: Your token lookup. See :data:TokenValidator. :param unauthenticated_response: Optional override for the 401 response body. Default is {"error": "unauthenticated"}. :param legacy_header_aliases: Optional list of legacy header names to accept in addition to the spec-canonical Authorization: Bearer. Resolution order on every request: Authorization: Bearer <token> first; if absent, each alias in order; first non-empty wins. The aliases path is for adopters mid-migration from a custom header (e.g. x-adcp-auth) — both work simultaneously so no flag-day cutover is needed. The spec-canonical header is always accepted; aliases are purely additive. :param legacy_aliases_bearer_prefix_required: Whether legacy alias headers must carry a "Bearer " prefix. Default False — legacy custom-header schemes (x-adcp-auth: <token>) carry the raw token. RFC 6750 Authorization always requires the prefix regardless of this flag. :param header_name: DEPRECATED. Set to a custom header name to accept that header as a legacy alias. Maps internally to legacy_header_aliases=[header_name]. The historical EXCLUSIVE behavior — "accept only this header, reject Authorization" — was a foot-gun: every spec-compliant client (browsers, every off-the-shelf HTTP library, the SDK's security_baseline/probe_api_key probe) sends Authorization: Bearer and was getting 401 against valid tokens. The new behavior is ADDITIVE; pass legacy_header_aliases=[...] explicitly for new code. See #720. :param bearer_prefix_required: DEPRECATED. Maps to legacy_aliases_bearer_prefix_required when header_name is also set. Ignored when header_name is the canonical "authorization" (which always requires Bearer). :param discovery_tools: Optional override for the per-instance tools/call discovery set. None (default) keeps the spec-mandated default (:data:adcp.server.mcp_tools.DISCOVERY_TOOLS, currently {"get_adcp_capabilities"}). Pass an extended set to allow more tools through unauthenticated. Common pattern::

    from adcp.server.mcp_tools import DISCOVERY_TOOLS

    BearerTokenAuthMiddleware(
        app,
        validate_token=...,
        discovery_tools=DISCOVERY_TOOLS | {"get_products"},
    )

**No validation happens at this layer** — adopters wiring
the middleware directly are trusted to know what they're
unauthenticating. The dataclass path
(:attr:<code><a title="adcp.server.BearerTokenAuth.mcp_discovery_tools" href="#adcp.server.BearerTokenAuth.mcp_discovery_tools">BearerTokenAuth.mcp\_discovery\_tools</a></code>) runs
:func:<code><a title="adcp.server.mcp_tools.validate_discovery_set" href="mcp_tools.html#adcp.server.mcp_tools.validate_discovery_set">validate\_discovery\_set()</a></code> for you,
refusing to add mutating or unknown ADCP tools. **Prefer
the dataclass path** unless you have a custom non-ADCP
read-only tool that the spec validator rejects.

The handshake methods always bypass regardless — this only
widens ``tools/call``. An empty collection here means
literally "no tools/call bypasses auth" (the spec default
is restored only when the kwarg is <code>None</code>); the dataclass
path rejects empty collections so adopters don't hit this
accidentally.

Ancestors

  • starlette.middleware.base.BaseHTTPMiddleware

Methods

async def dispatch(self, request: Request, call_next: Any) ‑> Any
Expand source code
async def dispatch(self, request: Request, call_next: Any) -> Any:
    method, tool = await self._peek_jsonrpc(request)

    principal_token = None
    tenant_token = None
    metadata_token = None
    try:
        if self.is_discovery_request(method, tool):
            principal_token = current_principal.set(None)
            tenant_token = current_tenant.set(None)
            metadata_token = current_principal_metadata.set(None)
            _set_request_state(request, None, None, None)
            return await call_next(request)

        bearer = self._extract_bearer(request)
        if not bearer:
            if self._allow_unauthenticated:
                # Network-trust deployment: no bearer is expected on this
                # leg — the agent is reachable only via the host's
                # authenticated proxy, which propagates identity downstream
                # (e.g. X-Identity-* / X-Principal-Id). Pass through with no
                # principal, exactly like the discovery bypass; the app
                # resolves and enforces identity. A token that IS present but
                # invalid still falls through to rejection below.
                principal_token = current_principal.set(None)
                tenant_token = current_tenant.set(None)
                metadata_token = current_principal_metadata.set(None)
                _set_request_state(request, None, None, None)
                return await call_next(request)
            return self._unauthenticated()

        try:
            raw = self._validate_token(bearer)
            principal: Principal | None
            if inspect.isawaitable(raw):
                principal = await raw
            else:
                principal = raw
        except Exception:
            # Validator failure must not leak stack info to the caller.
            # Fail closed — a buggy validator is an auth failure, not a
            # 500. Logged for operators.
            logger.exception("token validator raised")
            return self._unauthenticated()

        if principal is None:
            return self._unauthenticated()

        principal_metadata = dict(principal.metadata) if principal.metadata else None
        principal_token = current_principal.set(principal.caller_identity)
        tenant_token = current_tenant.set(principal.tenant_id)
        metadata_token = current_principal_metadata.set(principal_metadata)
        # Mirror onto ``request.state`` so the dispatch-side
        # ``context_factory`` can read the principal even when the
        # MCP server is in stateful mode (where the session task is a
        # separate async task than this middleware's task and does
        # not see the ContextVar set above). ``request.state`` is the
        # standard Starlette per-request scratchpad and travels with
        # the request through any nested ASGI app.
        _set_request_state(
            request,
            principal.caller_identity,
            principal.tenant_id,
            principal_metadata,
        )
        return await call_next(request)
    finally:
        # Reset unconditionally so a later task sharing this context
        # doesn't read a stale principal. Matches the idempotency
        # store's "fail fast on missing caller_identity" contract.
        if principal_token is not None:
            current_principal.reset(principal_token)
        if tenant_token is not None:
            current_tenant.reset(tenant_token)
        if metadata_token is not None:
            current_principal_metadata.reset(metadata_token)
def is_discovery_request(self, method: str | None, tool: str | None) ‑> bool
Expand source code
def is_discovery_request(self, method: str | None, tool: str | None) -> bool:
    """True when the request should bypass auth.

    Defaults to the spec-mandated discovery set
    (:data:`adcp.server.mcp_tools.DISCOVERY_TOOLS`, currently
    ``{"get_adcp_capabilities"}``). Two ways to widen the gate:

    1. Pass ``discovery_tools=`` at construction (preferred — no
       subclass needed). The override is per-instance and
       composes with the spec-mandated MCP handshake methods
       (``initialize`` / ``tools/list`` / etc., which always
       bypass regardless). When wiring via :class:`BearerTokenAuth`,
       strongly prefer the dataclass field — it runs
       :func:`adcp.server.mcp_tools.validate_discovery_set` for
       you, refusing to silently unauthenticate mutating tools.
    2. Subclass + override this method when the discovery
       predicate needs to inspect the request more deeply than
       a static tool-name set (e.g. allow ``tools/call`` only
       when accompanied by a specific header).

    Operators who want to *tighten* — e.g. require auth on
    ``tools/list`` — still need to subclass; ``discovery_tools``
    only widens.
    """
    if method in DISCOVERY_METHODS:
        return True
    if method != "tools/call":
        return False
    # Explicit ``is not None`` (not ``or``) so an empty frozenset
    # produced by some upstream filter doesn't silently fall back
    # to the spec default. Empty collections are rejected at the
    # dataclass layer; middleware-direct callers who pass an empty
    # set get the literal "nothing bypasses ``tools/call``"
    # behavior they asked for.
    discovery_tools = (
        self._discovery_tools if self._discovery_tools is not None else DISCOVERY_TOOLS
    )
    return tool in discovery_tools

True when the request should bypass auth.

Defaults to the spec-mandated discovery set (:data:adcp.server.mcp_tools.DISCOVERY_TOOLS, currently {"get_adcp_capabilities"}). Two ways to widen the gate:

  1. Pass discovery_tools= at construction (preferred — no subclass needed). The override is per-instance and composes with the spec-mandated MCP handshake methods (initialize / tools/list / etc., which always bypass regardless). When wiring via :class:BearerTokenAuth, strongly prefer the dataclass field — it runs :func:validate_discovery_set() for you, refusing to silently unauthenticate mutating tools.
  2. Subclass + override this method when the discovery predicate needs to inspect the request more deeply than a static tool-name set (e.g. allow tools/call only when accompanied by a specific header).

Operators who want to tighten — e.g. require auth on tools/list — still need to subclass; discovery_tools only widens.

class BrandHandler
Expand source code
class BrandHandler(ADCPHandler[TContext], Generic[TContext]):
    """Handler for brand rights operations.

    Subclass this to implement brand identity and rights management.
    Only brand rights tools will be exposed via MCP.

    Example:
        class MyBrandAgent(BrandHandler):
            async def get_brand_identity(self, params, context=None):
                # Implement brand identity lookup
                pass
    """

    _agent_type = "Brand agents"

Handler for brand rights operations.

Subclass this to implement brand identity and rights management. Only brand rights tools will be exposed via MCP.

Example

class MyBrandAgent(BrandHandler): async def get_brand_identity(self, params, context=None): # Implement brand identity lookup pass

Ancestors

Inherited members

class CallableSubdomainTenantRouter (resolver: TenantResolver, *, cache_size: int = 0, cache_ttl_seconds: float = 0.0)
Expand source code
class CallableSubdomainTenantRouter:
    """Adopter-callable :class:`SubdomainTenantRouter` for DB-backed lookups.

    The adopter passes a single callable mapping a normalized host to a
    :class:`Tenant` (or ``None`` for 404). The framework owns host
    normalization (lower-case + port-strip), so adopters write only the
    lookup itself — typically a single SQL query against their tenant
    table.

    The callable may be sync or async; the router awaits at call time.

    Example::

        from sqlalchemy import select
        from adcp.server import CallableSubdomainTenantRouter, Tenant

        async def lookup(host: str) -> Tenant | None:
            subdomain = host.split(".", 1)[0]  # 'acme.example.com' -> 'acme'
            async with my_db.session() as s:
                row = await s.scalar(
                    select(TenantRow).filter_by(subdomain=subdomain, is_active=True)
                )
            return Tenant(id=row.tenant_id, display_name=row.name) if row else None

        router = CallableSubdomainTenantRouter(lookup)

    Optional bounded TTL cache absorbs hot-path lookups without adopters
    reimplementing — useful when the resolver hits a remote DB on every
    request. Defaults to **no caching** (``cache_size=0``); adopters opt
    in with explicit bounds:

    ::

        router = CallableSubdomainTenantRouter(
            lookup,
            cache_size=1024,           # bounded LRU; never grows beyond this
            cache_ttl_seconds=60.0,    # expire entries after 60s
        )

    Cache bounds are mandatory when caching is enabled — there is no
    "cache forever, unbounded size" mode by design. Tenants come and go
    (suspension, deactivation); long-lived caches without TTL hand
    adopters a stale-cache footgun. The ``cache_ttl_seconds`` ceiling is
    the explicit knob.

    **Negative-cache + tenant onboarding race.** When caching is enabled,
    ``None`` results are cached too (to absorb probing for unknown hosts).
    This creates a race on tenant creation: if a probe for
    ``acme.example.com`` hits at T=0 (host doesn't exist yet) and the
    tenant is provisioned at T=1, the cached ``None`` causes 404s for up
    to ``cache_ttl_seconds`` afterward. Call ``invalidate(host)`` from
    your tenant *creation* path — not only deactivation — to clear the
    negative entry immediately::

        # on tenant create / re-activate
        router.invalidate("acme.example.com")

    Memory profile
    --------------
    Without caching: zero state held by the router. Each ``resolve()``
    call awaits the adopter callable directly.

    With caching: bounded by ``cache_size`` entries. Maximum memory is
    ``cache_size × (sizeof(host_str) + sizeof(your_Tenant) + 16)``
    where ``sizeof(your_Tenant)`` depends on what you store in
    :attr:`Tenant.ext` — the router can't predict it. The cache never
    grows beyond ``cache_size`` entries regardless of payload size.
    """

    def __init__(
        self,
        resolver: TenantResolver,
        *,
        cache_size: int = 0,
        cache_ttl_seconds: float = 0.0,
    ) -> None:
        """Construct the router.

        :param resolver: Callable taking a normalized host string and
            returning ``Tenant | None`` (sync or async). Receives
            already-normalized hosts — lower-cased with any
            ``:port`` suffix stripped.
        :param cache_size: Maximum number of cached lookups. ``0``
            disables caching entirely (the adopter callable is awaited
            on every request). Must be ``>= 0``.
        :param cache_ttl_seconds: Per-entry TTL in seconds. Must be
            ``> 0`` when ``cache_size > 0``. There is no "cache forever"
            mode — see the class docstring for rationale.
        :raises ValueError: If ``cache_size > 0`` and
            ``cache_ttl_seconds <= 0`` (cache requires explicit TTL).
        """
        if cache_size < 0:
            raise ValueError(f"cache_size must be >= 0, got {cache_size}")
        if cache_size > 0 and cache_ttl_seconds <= 0:
            raise ValueError(
                "cache_ttl_seconds must be > 0 when cache_size > 0; "
                "explicit TTL prevents stale-tenant footguns. Pass a "
                "value like 60.0 (one-minute cache) to opt in."
            )
        self._resolver = resolver
        self._cache_size = cache_size
        self._cache_ttl = cache_ttl_seconds
        # OrderedDict gives us LRU-by-move-to-end for free; bounded by
        # popitem(last=False) when over cache_size. Each entry is
        # (Tenant | None, expires_at_monotonic). Negative results are
        # cached too so DOS-style probing doesn't bypass the cache.
        self._cache: OrderedDict[str, tuple[Tenant | None, float]] = OrderedDict()

    async def resolve(self, host: str) -> Tenant | None:
        normalized = _normalize_host(host)

        if self._cache_size > 0:
            cached = self._cache_get(normalized)
            if cached is not _CACHE_MISS:
                return cached  # type: ignore[return-value]

        result = self._resolver(normalized)
        if inspect.isawaitable(result):
            result = await result

        if self._cache_size > 0:
            self._cache_put(normalized, result)

        return result

    # ----- cache internals (request-path; keep tight) ---------------------

    def _cache_get(self, host: str) -> Tenant | None | object:
        entry = self._cache.get(host)
        if entry is None:
            return _CACHE_MISS
        tenant, expires_at = entry
        if time.monotonic() > expires_at:
            # Expired — drop and miss. Don't await a fresh resolve here;
            # the caller does that. Avoids holding the entry through the
            # adopter callable's network round-trip.
            self._cache.pop(host, None)
            return _CACHE_MISS
        # LRU touch
        self._cache.move_to_end(host)
        return tenant

    def _cache_put(self, host: str, tenant: Tenant | None) -> None:
        expires_at = time.monotonic() + self._cache_ttl
        self._cache[host] = (tenant, expires_at)
        self._cache.move_to_end(host)
        # Bound size — evict oldest until under limit.
        while len(self._cache) > self._cache_size:
            self._cache.popitem(last=False)

    def invalidate(self, host: str | None = None) -> None:
        """Drop a cached entry (or all entries when ``host`` is ``None``).

        Adopters call this from their tenant-creation, -deactivation, and
        -modification flows to evict stale entries before the TTL fires.
        Creation matters because negative results (``None``) are cached —
        see the class docstring for details. Safe to call even when caching
        is disabled (no-op).

        :param host: Specific host to evict (raw or normalized — the
            method normalizes internally). ``None`` clears the entire
            cache.
        """
        if host is None:
            self._cache.clear()
            return
        self._cache.pop(_normalize_host(host), None)

Adopter-callable :class:SubdomainTenantRouter for DB-backed lookups.

The adopter passes a single callable mapping a normalized host to a :class:Tenant (or None for 404). The framework owns host normalization (lower-case + port-strip), so adopters write only the lookup itself — typically a single SQL query against their tenant table.

The callable may be sync or async; the router awaits at call time.

Example::

from sqlalchemy import select
from adcp.server import CallableSubdomainTenantRouter, Tenant

async def lookup(host: str) -> Tenant | None:
    subdomain = host.split(".", 1)[0]  # 'acme.example.com' -> 'acme'
    async with my_db.session() as s:
        row = await s.scalar(
            select(TenantRow).filter_by(subdomain=subdomain, is_active=True)
        )
    return Tenant(id=row.tenant_id, display_name=row.name) if row else None

router = CallableSubdomainTenantRouter(lookup)

Optional bounded TTL cache absorbs hot-path lookups without adopters reimplementing — useful when the resolver hits a remote DB on every request. Defaults to no caching (cache_size=0); adopters opt in with explicit bounds:

::

router = CallableSubdomainTenantRouter(
    lookup,
    cache_size=1024,           # bounded LRU; never grows beyond this
    cache_ttl_seconds=60.0,    # expire entries after 60s
)

Cache bounds are mandatory when caching is enabled — there is no "cache forever, unbounded size" mode by design. Tenants come and go (suspension, deactivation); long-lived caches without TTL hand adopters a stale-cache footgun. The cache_ttl_seconds ceiling is the explicit knob.

Negative-cache + tenant onboarding race. When caching is enabled, None results are cached too (to absorb probing for unknown hosts). This creates a race on tenant creation: if a probe for acme.example.com hits at T=0 (host doesn't exist yet) and the tenant is provisioned at T=1, the cached None causes 404s for up to cache_ttl_seconds afterward. Call invalidate(host) from your tenant creation path — not only deactivation — to clear the negative entry immediately::

# on tenant create / re-activate
router.invalidate("acme.example.com")

Memory Profile

Without caching: zero state held by the router. Each resolve() call awaits the adopter callable directly.

With caching: bounded by cache_size entries. Maximum memory is cache_size × (sizeof(host_str) + sizeof(your_Tenant) + 16) where sizeof(your_Tenant) depends on what you store in :attr:Tenant.ext — the router can't predict it. The cache never grows beyond cache_size entries regardless of payload size.

Construct the router.

:param resolver: Callable taking a normalized host string and returning Tenant | None (sync or async). Receives already-normalized hosts — lower-cased with any :port suffix stripped. :param cache_size: Maximum number of cached lookups. 0 disables caching entirely (the adopter callable is awaited on every request). Must be >= 0. :param cache_ttl_seconds: Per-entry TTL in seconds. Must be > 0 when cache_size > 0. There is no "cache forever" mode — see the class docstring for rationale. :raises ValueError: If cache_size > 0 and cache_ttl_seconds <= 0 (cache requires explicit TTL).

Methods

def invalidate(self, host: str | None = None) ‑> None
Expand source code
def invalidate(self, host: str | None = None) -> None:
    """Drop a cached entry (or all entries when ``host`` is ``None``).

    Adopters call this from their tenant-creation, -deactivation, and
    -modification flows to evict stale entries before the TTL fires.
    Creation matters because negative results (``None``) are cached —
    see the class docstring for details. Safe to call even when caching
    is disabled (no-op).

    :param host: Specific host to evict (raw or normalized — the
        method normalizes internally). ``None`` clears the entire
        cache.
    """
    if host is None:
        self._cache.clear()
        return
    self._cache.pop(_normalize_host(host), None)

Drop a cached entry (or all entries when host is None).

Adopters call this from their tenant-creation, -deactivation, and -modification flows to evict stale entries before the TTL fires. Creation matters because negative results (None) are cached — see the class docstring for details. Safe to call even when caching is disabled (no-op).

:param host: Specific host to evict (raw or normalized — the method normalizes internally). None clears the entire cache.

async def resolve(self, host: str) ‑> Tenant | None
Expand source code
async def resolve(self, host: str) -> Tenant | None:
    normalized = _normalize_host(host)

    if self._cache_size > 0:
        cached = self._cache_get(normalized)
        if cached is not _CACHE_MISS:
            return cached  # type: ignore[return-value]

    result = self._resolver(normalized)
    if inspect.isawaitable(result):
        result = await result

    if self._cache_size > 0:
        self._cache_put(normalized, result)

    return result
class ComplianceHandler
Expand source code
class ComplianceHandler(ADCPHandler[TContext], Generic[TContext]):
    """Handler for compliance test operations.

    Subclass this to implement compliance sandbox testing.
    Only compliance tools will be exposed via MCP.

    Example:
        class MyComplianceAgent(ComplianceHandler):
            async def comply_test_controller(self, params, context=None):
                # Implement test controller
                pass
    """

    _agent_type = "Compliance agents"

Handler for compliance test operations.

Subclass this to implement compliance sandbox testing. Only compliance tools will be exposed via MCP.

Example

class MyComplianceAgent(ComplianceHandler): async def comply_test_controller(self, params, context=None): # Implement test controller pass

Ancestors

Inherited members

class ContentStandardsHandler
Expand source code
class ContentStandardsHandler(ADCPHandler[TContext], Generic[TContext]):
    """Handler for Content Standards protocol.

    Subclass this to implement a Content Standards agent. All Content Standards
    operations must be implemented via the handle_* methods.
    The public methods (create_content_standards, etc.) handle validation and
    error handling automatically.

    Non-Content-Standards operations (get_products, create_media_buy, etc.)
    return 'not supported' via the base class.

    Example:
        class MyContentStandardsHandler(ContentStandardsHandler):
            async def handle_create_content_standards(
                self,
                request: CreateContentStandardsRequest,
                context: TContext | None = None
            ) -> CreateContentStandardsResponse:
                # Your implementation
                return CreateContentStandardsResponse(...)
    """

    _agent_type: str = "Content Standards agents"

    # ========================================================================
    # Content Standards Operations - Override base class with validation
    # ========================================================================

    async def create_content_standards(
        self,
        params: CreateContentStandardsRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> CreateContentStandardsResponse | NotImplementedResponse:
        """Create content standards configuration.

        Validates params and delegates to handle_create_content_standards.
        """
        try:
            request = CreateContentStandardsRequest.model_validate(params)
        except ValidationError as e:
            return NotImplementedResponse(
                supported=False,
                reason=f"Invalid request: {e}",
                error=Error(code="VALIDATION_ERROR", message=str(e)),
            )
        return await self.handle_create_content_standards(request, context)

    async def get_content_standards(
        self,
        params: GetContentStandardsRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> GetContentStandardsResponse | NotImplementedResponse:
        """Get content standards configuration.

        Validates params and delegates to handle_get_content_standards.
        """
        try:
            request = GetContentStandardsRequest.model_validate(params)
        except ValidationError as e:
            return NotImplementedResponse(
                supported=False,
                reason=f"Invalid request: {e}",
                error=Error(code="VALIDATION_ERROR", message=str(e)),
            )
        return await self.handle_get_content_standards(request, context)

    async def list_content_standards(
        self,
        params: ListContentStandardsRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> ListContentStandardsResponse | NotImplementedResponse:
        """List content standards configurations.

        Validates params and delegates to handle_list_content_standards.
        """
        try:
            request = ListContentStandardsRequest.model_validate(params)
        except ValidationError as e:
            return NotImplementedResponse(
                supported=False,
                reason=f"Invalid request: {e}",
                error=Error(code="VALIDATION_ERROR", message=str(e)),
            )
        return await self.handle_list_content_standards(request, context)

    async def update_content_standards(
        self,
        params: UpdateContentStandardsRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> UpdateContentStandardsResponse | NotImplementedResponse:
        """Update content standards configuration.

        Validates params and delegates to handle_update_content_standards.
        """
        try:
            request = UpdateContentStandardsRequest.model_validate(params)
        except ValidationError as e:
            return NotImplementedResponse(
                supported=False,
                reason=f"Invalid request: {e}",
                error=Error(code="VALIDATION_ERROR", message=str(e)),
            )
        return await self.handle_update_content_standards(request, context)

    async def calibrate_content(
        self,
        params: CalibrateContentRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> CalibrateContentResponse | NotImplementedResponse:
        """Calibrate content against standards.

        Validates params and delegates to handle_calibrate_content.
        """
        try:
            request = CalibrateContentRequest.model_validate(params)
        except ValidationError as e:
            return NotImplementedResponse(
                supported=False,
                reason=f"Invalid request: {e}",
                error=Error(code="VALIDATION_ERROR", message=str(e)),
            )
        return await self.handle_calibrate_content(request, context)

    async def validate_content_delivery(
        self,
        params: ValidateContentDeliveryRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> ValidateContentDeliveryResponse | NotImplementedResponse:
        """Validate content delivery against standards.

        Validates params and delegates to handle_validate_content_delivery.
        """
        try:
            request = ValidateContentDeliveryRequest.model_validate(params)
        except ValidationError as e:
            return NotImplementedResponse(
                supported=False,
                reason=f"Invalid request: {e}",
                error=Error(code="VALIDATION_ERROR", message=str(e)),
            )
        return await self.handle_validate_content_delivery(request, context)

    async def get_media_buy_artifacts(
        self,
        params: GetMediaBuyArtifactsRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> GetMediaBuyArtifactsResponse | NotImplementedResponse:
        """Get artifacts associated with a media buy.

        Validates params and delegates to handle_get_media_buy_artifacts.
        """
        try:
            request = GetMediaBuyArtifactsRequest.model_validate(params)
        except ValidationError as e:
            return NotImplementedResponse(
                supported=False,
                reason=f"Invalid request: {e}",
                error=Error(code="VALIDATION_ERROR", message=str(e)),
            )
        return await self.handle_get_media_buy_artifacts(request, context)

    # ========================================================================
    # Abstract handlers - Implement these in subclasses
    # ========================================================================

    @abstractmethod
    async def handle_create_content_standards(
        self,
        request: CreateContentStandardsRequest,
        context: TContext | None = None,
    ) -> CreateContentStandardsResponse:
        """Handle create content standards request."""
        ...

    @abstractmethod
    async def handle_get_content_standards(
        self,
        request: GetContentStandardsRequest,
        context: TContext | None = None,
    ) -> GetContentStandardsResponse:
        """Handle get content standards request."""
        ...

    @abstractmethod
    async def handle_list_content_standards(
        self,
        request: ListContentStandardsRequest,
        context: TContext | None = None,
    ) -> ListContentStandardsResponse:
        """Handle list content standards request."""
        ...

    @abstractmethod
    async def handle_update_content_standards(
        self,
        request: UpdateContentStandardsRequest,
        context: TContext | None = None,
    ) -> UpdateContentStandardsResponse:
        """Handle update content standards request."""
        ...

    @abstractmethod
    async def handle_calibrate_content(
        self,
        request: CalibrateContentRequest,
        context: TContext | None = None,
    ) -> CalibrateContentResponse:
        """Handle calibrate content request."""
        ...

    @abstractmethod
    async def handle_validate_content_delivery(
        self,
        request: ValidateContentDeliveryRequest,
        context: TContext | None = None,
    ) -> ValidateContentDeliveryResponse:
        """Handle validate content delivery request."""
        ...

    @abstractmethod
    async def handle_get_media_buy_artifacts(
        self,
        request: GetMediaBuyArtifactsRequest,
        context: TContext | None = None,
    ) -> GetMediaBuyArtifactsResponse:
        """Handle get media buy artifacts request."""
        ...

Handler for Content Standards protocol.

Subclass this to implement a Content Standards agent. All Content Standards operations must be implemented via the handle_* methods. The public methods (create_content_standards, etc.) handle validation and error handling automatically.

Non-Content-Standards operations (get_products, create_media_buy, etc.) return 'not supported' via the base class.

Example

class MyContentStandardsHandler(ContentStandardsHandler): async def handle_create_content_standards( self, request: CreateContentStandardsRequest, context: TContext | None = None ) -> CreateContentStandardsResponse: # Your implementation return CreateContentStandardsResponse(…)

Ancestors

Methods

async def calibrate_content(self,
params: CalibrateContentRequest | dict[str, Any],
context: TContext | None = None) ‑> adcp.types.generated_poc.content_standards.calibrate_content_response.CalibrateContentResponse1 | adcp.types.generated_poc.content_standards.calibrate_content_response.CalibrateContentResponse2 | NotImplementedResponse
Expand source code
async def calibrate_content(
    self,
    params: CalibrateContentRequest | dict[str, Any],
    context: TContext | None = None,
) -> CalibrateContentResponse | NotImplementedResponse:
    """Calibrate content against standards.

    Validates params and delegates to handle_calibrate_content.
    """
    try:
        request = CalibrateContentRequest.model_validate(params)
    except ValidationError as e:
        return NotImplementedResponse(
            supported=False,
            reason=f"Invalid request: {e}",
            error=Error(code="VALIDATION_ERROR", message=str(e)),
        )
    return await self.handle_calibrate_content(request, context)

Calibrate content against standards.

Validates params and delegates to handle_calibrate_content.

async def create_content_standards(self,
params: CreateContentStandardsRequest | dict[str, Any],
context: TContext | None = None) ‑> adcp.types.generated_poc.content_standards.create_content_standards_response.CreateContentStandardsResponse | NotImplementedResponse
Expand source code
async def create_content_standards(
    self,
    params: CreateContentStandardsRequest | dict[str, Any],
    context: TContext | None = None,
) -> CreateContentStandardsResponse | NotImplementedResponse:
    """Create content standards configuration.

    Validates params and delegates to handle_create_content_standards.
    """
    try:
        request = CreateContentStandardsRequest.model_validate(params)
    except ValidationError as e:
        return NotImplementedResponse(
            supported=False,
            reason=f"Invalid request: {e}",
            error=Error(code="VALIDATION_ERROR", message=str(e)),
        )
    return await self.handle_create_content_standards(request, context)

Create content standards configuration.

Validates params and delegates to handle_create_content_standards.

async def get_content_standards(self,
params: GetContentStandardsRequest | dict[str, Any],
context: TContext | None = None) ‑> adcp.types.generated_poc.content_standards.get_content_standards_response.GetContentStandardsResponse1 | adcp.types.generated_poc.content_standards.get_content_standards_response.GetContentStandardsResponse2 | NotImplementedResponse
Expand source code
async def get_content_standards(
    self,
    params: GetContentStandardsRequest | dict[str, Any],
    context: TContext | None = None,
) -> GetContentStandardsResponse | NotImplementedResponse:
    """Get content standards configuration.

    Validates params and delegates to handle_get_content_standards.
    """
    try:
        request = GetContentStandardsRequest.model_validate(params)
    except ValidationError as e:
        return NotImplementedResponse(
            supported=False,
            reason=f"Invalid request: {e}",
            error=Error(code="VALIDATION_ERROR", message=str(e)),
        )
    return await self.handle_get_content_standards(request, context)

Get content standards configuration.

Validates params and delegates to handle_get_content_standards.

async def get_media_buy_artifacts(self,
params: GetMediaBuyArtifactsRequest | dict[str, Any],
context: TContext | None = None) ‑> adcp.types.generated_poc.content_standards.get_media_buy_artifacts_response.GetMediaBuyArtifactsResponse1 | adcp.types.generated_poc.content_standards.get_media_buy_artifacts_response.GetMediaBuyArtifactsResponse2 | NotImplementedResponse
Expand source code
async def get_media_buy_artifacts(
    self,
    params: GetMediaBuyArtifactsRequest | dict[str, Any],
    context: TContext | None = None,
) -> GetMediaBuyArtifactsResponse | NotImplementedResponse:
    """Get artifacts associated with a media buy.

    Validates params and delegates to handle_get_media_buy_artifacts.
    """
    try:
        request = GetMediaBuyArtifactsRequest.model_validate(params)
    except ValidationError as e:
        return NotImplementedResponse(
            supported=False,
            reason=f"Invalid request: {e}",
            error=Error(code="VALIDATION_ERROR", message=str(e)),
        )
    return await self.handle_get_media_buy_artifacts(request, context)

Get artifacts associated with a media buy.

Validates params and delegates to handle_get_media_buy_artifacts.

async def handle_calibrate_content(self, request: CalibrateContentRequest, context: TContext | None = None) ‑> adcp.types.generated_poc.content_standards.calibrate_content_response.CalibrateContentResponse1 | adcp.types.generated_poc.content_standards.calibrate_content_response.CalibrateContentResponse2
Expand source code
@abstractmethod
async def handle_calibrate_content(
    self,
    request: CalibrateContentRequest,
    context: TContext | None = None,
) -> CalibrateContentResponse:
    """Handle calibrate content request."""
    ...

Handle calibrate content request.

async def handle_create_content_standards(self, request: CreateContentStandardsRequest, context: TContext | None = None) ‑> adcp.types.generated_poc.content_standards.create_content_standards_response.CreateContentStandardsResponse
Expand source code
@abstractmethod
async def handle_create_content_standards(
    self,
    request: CreateContentStandardsRequest,
    context: TContext | None = None,
) -> CreateContentStandardsResponse:
    """Handle create content standards request."""
    ...

Handle create content standards request.

async def handle_get_content_standards(self, request: GetContentStandardsRequest, context: TContext | None = None) ‑> adcp.types.generated_poc.content_standards.get_content_standards_response.GetContentStandardsResponse1 | adcp.types.generated_poc.content_standards.get_content_standards_response.GetContentStandardsResponse2
Expand source code
@abstractmethod
async def handle_get_content_standards(
    self,
    request: GetContentStandardsRequest,
    context: TContext | None = None,
) -> GetContentStandardsResponse:
    """Handle get content standards request."""
    ...

Handle get content standards request.

async def handle_get_media_buy_artifacts(self, request: GetMediaBuyArtifactsRequest, context: TContext | None = None) ‑> adcp.types.generated_poc.content_standards.get_media_buy_artifacts_response.GetMediaBuyArtifactsResponse1 | adcp.types.generated_poc.content_standards.get_media_buy_artifacts_response.GetMediaBuyArtifactsResponse2
Expand source code
@abstractmethod
async def handle_get_media_buy_artifacts(
    self,
    request: GetMediaBuyArtifactsRequest,
    context: TContext | None = None,
) -> GetMediaBuyArtifactsResponse:
    """Handle get media buy artifacts request."""
    ...

Handle get media buy artifacts request.

async def handle_list_content_standards(self, request: ListContentStandardsRequest, context: TContext | None = None) ‑> adcp.types.generated_poc.content_standards.list_content_standards_response.ListContentStandardsResponse
Expand source code
@abstractmethod
async def handle_list_content_standards(
    self,
    request: ListContentStandardsRequest,
    context: TContext | None = None,
) -> ListContentStandardsResponse:
    """Handle list content standards request."""
    ...

Handle list content standards request.

async def handle_update_content_standards(self, request: UpdateContentStandardsRequest, context: TContext | None = None) ‑> adcp.types.generated_poc.content_standards.update_content_standards_response.UpdateContentStandardsResponse
Expand source code
@abstractmethod
async def handle_update_content_standards(
    self,
    request: UpdateContentStandardsRequest,
    context: TContext | None = None,
) -> UpdateContentStandardsResponse:
    """Handle update content standards request."""
    ...

Handle update content standards request.

async def handle_validate_content_delivery(self, request: ValidateContentDeliveryRequest, context: TContext | None = None) ‑> adcp.types.generated_poc.content_standards.validate_content_delivery_response.ValidateContentDeliveryResponse1 | adcp.types.generated_poc.content_standards.validate_content_delivery_response.ValidateContentDeliveryResponse2
Expand source code
@abstractmethod
async def handle_validate_content_delivery(
    self,
    request: ValidateContentDeliveryRequest,
    context: TContext | None = None,
) -> ValidateContentDeliveryResponse:
    """Handle validate content delivery request."""
    ...

Handle validate content delivery request.

async def list_content_standards(self,
params: ListContentStandardsRequest | dict[str, Any],
context: TContext | None = None) ‑> adcp.types.generated_poc.content_standards.list_content_standards_response.ListContentStandardsResponse | NotImplementedResponse
Expand source code
async def list_content_standards(
    self,
    params: ListContentStandardsRequest | dict[str, Any],
    context: TContext | None = None,
) -> ListContentStandardsResponse | NotImplementedResponse:
    """List content standards configurations.

    Validates params and delegates to handle_list_content_standards.
    """
    try:
        request = ListContentStandardsRequest.model_validate(params)
    except ValidationError as e:
        return NotImplementedResponse(
            supported=False,
            reason=f"Invalid request: {e}",
            error=Error(code="VALIDATION_ERROR", message=str(e)),
        )
    return await self.handle_list_content_standards(request, context)

List content standards configurations.

Validates params and delegates to handle_list_content_standards.

async def update_content_standards(self,
params: UpdateContentStandardsRequest | dict[str, Any],
context: TContext | None = None) ‑> adcp.types.generated_poc.content_standards.update_content_standards_response.UpdateContentStandardsResponse | NotImplementedResponse
Expand source code
async def update_content_standards(
    self,
    params: UpdateContentStandardsRequest | dict[str, Any],
    context: TContext | None = None,
) -> UpdateContentStandardsResponse | NotImplementedResponse:
    """Update content standards configuration.

    Validates params and delegates to handle_update_content_standards.
    """
    try:
        request = UpdateContentStandardsRequest.model_validate(params)
    except ValidationError as e:
        return NotImplementedResponse(
            supported=False,
            reason=f"Invalid request: {e}",
            error=Error(code="VALIDATION_ERROR", message=str(e)),
        )
    return await self.handle_update_content_standards(request, context)

Update content standards configuration.

Validates params and delegates to handle_update_content_standards.

async def validate_content_delivery(self,
params: ValidateContentDeliveryRequest | dict[str, Any],
context: TContext | None = None) ‑> adcp.types.generated_poc.content_standards.validate_content_delivery_response.ValidateContentDeliveryResponse1 | adcp.types.generated_poc.content_standards.validate_content_delivery_response.ValidateContentDeliveryResponse2 | NotImplementedResponse
Expand source code
async def validate_content_delivery(
    self,
    params: ValidateContentDeliveryRequest | dict[str, Any],
    context: TContext | None = None,
) -> ValidateContentDeliveryResponse | NotImplementedResponse:
    """Validate content delivery against standards.

    Validates params and delegates to handle_validate_content_delivery.
    """
    try:
        request = ValidateContentDeliveryRequest.model_validate(params)
    except ValidationError as e:
        return NotImplementedResponse(
            supported=False,
            reason=f"Invalid request: {e}",
            error=Error(code="VALIDATION_ERROR", message=str(e)),
        )
    return await self.handle_validate_content_delivery(request, context)

Validate content delivery against standards.

Validates params and delegates to handle_validate_content_delivery.

Inherited members

class GovernanceHandler
Expand source code
class GovernanceHandler(ADCPHandler[TContext], Generic[TContext]):
    """Handler for Governance protocol (Property Lists).

    Subclass this to implement a Governance agent that manages property lists
    for brand safety, compliance scoring, and quality filtering.

    All property list operations must be implemented via the handle_* methods.
    The public methods (create_property_list, etc.) handle validation and
    error handling automatically.

    Non-governance operations (get_products, create_media_buy, etc.)
    return 'not supported' via the base class.

    Example:
        class MyGovernanceHandler(GovernanceHandler):
            async def handle_create_property_list(
                self,
                request: CreatePropertyListRequest,
                context: TContext | None = None
            ) -> CreatePropertyListResponse:
                # Store the list definition
                list_id = generate_id()
                # ...
                return CreatePropertyListResponse(list=PropertyList(...))
    """

    _agent_type: str = "Governance agents"

    # ========================================================================
    # Governance Operations - Override base class with validation
    # ========================================================================

    async def get_creative_features(
        self,
        params: GetCreativeFeaturesRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> GetCreativeFeaturesResponse | NotImplementedResponse:
        """Evaluate governance features for a creative manifest."""
        try:
            request = GetCreativeFeaturesRequest.model_validate(params)
        except ValidationError as e:
            return NotImplementedResponse(
                supported=False,
                reason=f"Invalid request: {e}",
                error=Error(code="VALIDATION_ERROR", message=str(e)),
            )
        return await self.handle_get_creative_features(request, context)

    async def sync_plans(
        self,
        params: SyncPlansRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> SyncPlansResponse | NotImplementedResponse:
        """Sync campaign governance plans to the agent."""
        try:
            request = SyncPlansRequest.model_validate(params)
        except ValidationError as e:
            return NotImplementedResponse(
                supported=False,
                reason=f"Invalid request: {e}",
                error=Error(code="VALIDATION_ERROR", message=str(e)),
            )
        return await self.handle_sync_plans(request, context)

    async def check_governance(
        self,
        params: CheckGovernanceRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> CheckGovernanceResponse | NotImplementedResponse:
        """Check whether a proposed or committed action complies with plan governance."""
        try:
            request = CheckGovernanceRequest.model_validate(params)
        except ValidationError as e:
            return NotImplementedResponse(
                supported=False,
                reason=f"Invalid request: {e}",
                error=Error(code="VALIDATION_ERROR", message=str(e)),
            )
        return await self.handle_check_governance(request, context)

    async def report_plan_outcome(
        self,
        params: ReportPlanOutcomeRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> ReportPlanOutcomeResponse | NotImplementedResponse:
        """Report the outcome of a previously governed action."""
        try:
            request = ReportPlanOutcomeRequest.model_validate(params)
        except ValidationError as e:
            return NotImplementedResponse(
                supported=False,
                reason=f"Invalid request: {e}",
                error=Error(code="VALIDATION_ERROR", message=str(e)),
            )
        return await self.handle_report_plan_outcome(request, context)

    async def get_plan_audit_logs(
        self,
        params: GetPlanAuditLogsRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> GetPlanAuditLogsResponse | NotImplementedResponse:
        """Retrieve governance audit logs for one or more plans."""
        try:
            request = GetPlanAuditLogsRequest.model_validate(params)
        except ValidationError as e:
            return NotImplementedResponse(
                supported=False,
                reason=f"Invalid request: {e}",
                error=Error(code="VALIDATION_ERROR", message=str(e)),
            )
        return await self.handle_get_plan_audit_logs(request, context)

    async def create_property_list(
        self,
        params: CreatePropertyListRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> CreatePropertyListResponse | NotImplementedResponse:
        """Create a property list for governance filtering.

        Validates params and delegates to handle_create_property_list.
        """
        try:
            request = CreatePropertyListRequest.model_validate(params)
        except ValidationError as e:
            return NotImplementedResponse(
                supported=False,
                reason=f"Invalid request: {e}",
                error=Error(code="VALIDATION_ERROR", message=str(e)),
            )
        return await self.handle_create_property_list(request, context)

    async def get_property_list(
        self,
        params: GetPropertyListRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> GetPropertyListResponse | NotImplementedResponse:
        """Get a property list with optional resolution.

        Validates params and delegates to handle_get_property_list.
        """
        try:
            request = GetPropertyListRequest.model_validate(params)
        except ValidationError as e:
            return NotImplementedResponse(
                supported=False,
                reason=f"Invalid request: {e}",
                error=Error(code="VALIDATION_ERROR", message=str(e)),
            )
        return await self.handle_get_property_list(request, context)

    async def list_property_lists(
        self,
        params: ListPropertyListsRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> ListPropertyListsResponse | NotImplementedResponse:
        """List property lists.

        Validates params and delegates to handle_list_property_lists.
        """
        try:
            request = ListPropertyListsRequest.model_validate(params)
        except ValidationError as e:
            return NotImplementedResponse(
                supported=False,
                reason=f"Invalid request: {e}",
                error=Error(code="VALIDATION_ERROR", message=str(e)),
            )
        return await self.handle_list_property_lists(request, context)

    async def update_property_list(
        self,
        params: UpdatePropertyListRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> UpdatePropertyListResponse | NotImplementedResponse:
        """Update a property list.

        Validates params and delegates to handle_update_property_list.
        """
        try:
            request = UpdatePropertyListRequest.model_validate(params)
        except ValidationError as e:
            return NotImplementedResponse(
                supported=False,
                reason=f"Invalid request: {e}",
                error=Error(code="VALIDATION_ERROR", message=str(e)),
            )
        return await self.handle_update_property_list(request, context)

    async def delete_property_list(
        self,
        params: DeletePropertyListRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> DeletePropertyListResponse | NotImplementedResponse:
        """Delete a property list.

        Validates params and delegates to handle_delete_property_list.
        """
        try:
            request = DeletePropertyListRequest.model_validate(params)
        except ValidationError as e:
            return NotImplementedResponse(
                supported=False,
                reason=f"Invalid request: {e}",
                error=Error(code="VALIDATION_ERROR", message=str(e)),
            )
        return await self.handle_delete_property_list(request, context)

    # ========================================================================
    # Abstract handlers - Implement these in subclasses
    # ========================================================================

    @abstractmethod
    async def handle_get_creative_features(
        self,
        request: GetCreativeFeaturesRequest,
        context: TContext | None = None,
    ) -> GetCreativeFeaturesResponse:
        """Handle creative feature evaluation."""
        ...

    @abstractmethod
    async def handle_sync_plans(
        self,
        request: SyncPlansRequest,
        context: TContext | None = None,
    ) -> SyncPlansResponse:
        """Handle campaign governance plan sync."""
        ...

    @abstractmethod
    async def handle_check_governance(
        self,
        request: CheckGovernanceRequest,
        context: TContext | None = None,
    ) -> CheckGovernanceResponse:
        """Handle a governance check request."""
        ...

    @abstractmethod
    async def handle_report_plan_outcome(
        self,
        request: ReportPlanOutcomeRequest,
        context: TContext | None = None,
    ) -> ReportPlanOutcomeResponse:
        """Handle reporting of a governed action outcome."""
        ...

    @abstractmethod
    async def handle_get_plan_audit_logs(
        self,
        request: GetPlanAuditLogsRequest,
        context: TContext | None = None,
    ) -> GetPlanAuditLogsResponse:
        """Handle retrieval of governance audit logs."""
        ...

    @abstractmethod
    async def handle_create_property_list(
        self,
        request: CreatePropertyListRequest,
        context: TContext | None = None,
    ) -> CreatePropertyListResponse:
        """Handle create property list request."""
        ...

    @abstractmethod
    async def handle_get_property_list(
        self,
        request: GetPropertyListRequest,
        context: TContext | None = None,
    ) -> GetPropertyListResponse:
        """Handle get property list request."""
        ...

    @abstractmethod
    async def handle_list_property_lists(
        self,
        request: ListPropertyListsRequest,
        context: TContext | None = None,
    ) -> ListPropertyListsResponse:
        """Handle list property lists request."""
        ...

    @abstractmethod
    async def handle_update_property_list(
        self,
        request: UpdatePropertyListRequest,
        context: TContext | None = None,
    ) -> UpdatePropertyListResponse:
        """Handle update property list request."""
        ...

    @abstractmethod
    async def handle_delete_property_list(
        self,
        request: DeletePropertyListRequest,
        context: TContext | None = None,
    ) -> DeletePropertyListResponse:
        """Handle delete property list request."""
        ...

Handler for Governance protocol (Property Lists).

Subclass this to implement a Governance agent that manages property lists for brand safety, compliance scoring, and quality filtering.

All property list operations must be implemented via the handle_* methods. The public methods (create_property_list, etc.) handle validation and error handling automatically.

Non-governance operations (get_products, create_media_buy, etc.) return 'not supported' via the base class.

Example

class MyGovernanceHandler(GovernanceHandler): async def handle_create_property_list( self, request: CreatePropertyListRequest, context: TContext | None = None ) -> CreatePropertyListResponse: # Store the list definition list_id = generate_id() # … return CreatePropertyListResponse(list=PropertyList(…))

Ancestors

Methods

async def check_governance(self,
params: CheckGovernanceRequest | dict[str, Any],
context: TContext | None = None) ‑> adcp.types.generated_poc.governance.check_governance_response.CheckGovernanceResponse | NotImplementedResponse
Expand source code
async def check_governance(
    self,
    params: CheckGovernanceRequest | dict[str, Any],
    context: TContext | None = None,
) -> CheckGovernanceResponse | NotImplementedResponse:
    """Check whether a proposed or committed action complies with plan governance."""
    try:
        request = CheckGovernanceRequest.model_validate(params)
    except ValidationError as e:
        return NotImplementedResponse(
            supported=False,
            reason=f"Invalid request: {e}",
            error=Error(code="VALIDATION_ERROR", message=str(e)),
        )
    return await self.handle_check_governance(request, context)

Check whether a proposed or committed action complies with plan governance.

async def create_property_list(self,
params: CreatePropertyListRequest | dict[str, Any],
context: TContext | None = None) ‑> adcp.types.generated_poc.property.create_property_list_response.CreatePropertyListResponse | NotImplementedResponse
Expand source code
async def create_property_list(
    self,
    params: CreatePropertyListRequest | dict[str, Any],
    context: TContext | None = None,
) -> CreatePropertyListResponse | NotImplementedResponse:
    """Create a property list for governance filtering.

    Validates params and delegates to handle_create_property_list.
    """
    try:
        request = CreatePropertyListRequest.model_validate(params)
    except ValidationError as e:
        return NotImplementedResponse(
            supported=False,
            reason=f"Invalid request: {e}",
            error=Error(code="VALIDATION_ERROR", message=str(e)),
        )
    return await self.handle_create_property_list(request, context)

Create a property list for governance filtering.

Validates params and delegates to handle_create_property_list.

async def delete_property_list(self,
params: DeletePropertyListRequest | dict[str, Any],
context: TContext | None = None) ‑> adcp.types.generated_poc.property.delete_property_list_response.DeletePropertyListResponse | NotImplementedResponse
Expand source code
async def delete_property_list(
    self,
    params: DeletePropertyListRequest | dict[str, Any],
    context: TContext | None = None,
) -> DeletePropertyListResponse | NotImplementedResponse:
    """Delete a property list.

    Validates params and delegates to handle_delete_property_list.
    """
    try:
        request = DeletePropertyListRequest.model_validate(params)
    except ValidationError as e:
        return NotImplementedResponse(
            supported=False,
            reason=f"Invalid request: {e}",
            error=Error(code="VALIDATION_ERROR", message=str(e)),
        )
    return await self.handle_delete_property_list(request, context)

Delete a property list.

Validates params and delegates to handle_delete_property_list.

async def get_creative_features(self,
params: GetCreativeFeaturesRequest | dict[str, Any],
context: TContext | None = None) ‑> adcp.types.generated_poc.creative.get_creative_features_response.GetCreativeFeaturesResponse1 | adcp.types.generated_poc.creative.get_creative_features_response.GetCreativeFeaturesResponse2 | NotImplementedResponse
Expand source code
async def get_creative_features(
    self,
    params: GetCreativeFeaturesRequest | dict[str, Any],
    context: TContext | None = None,
) -> GetCreativeFeaturesResponse | NotImplementedResponse:
    """Evaluate governance features for a creative manifest."""
    try:
        request = GetCreativeFeaturesRequest.model_validate(params)
    except ValidationError as e:
        return NotImplementedResponse(
            supported=False,
            reason=f"Invalid request: {e}",
            error=Error(code="VALIDATION_ERROR", message=str(e)),
        )
    return await self.handle_get_creative_features(request, context)

Evaluate governance features for a creative manifest.

async def get_plan_audit_logs(self,
params: GetPlanAuditLogsRequest | dict[str, Any],
context: TContext | None = None) ‑> adcp.types.generated_poc.governance.get_plan_audit_logs_response.GetPlanAuditLogsResponse | NotImplementedResponse
Expand source code
async def get_plan_audit_logs(
    self,
    params: GetPlanAuditLogsRequest | dict[str, Any],
    context: TContext | None = None,
) -> GetPlanAuditLogsResponse | NotImplementedResponse:
    """Retrieve governance audit logs for one or more plans."""
    try:
        request = GetPlanAuditLogsRequest.model_validate(params)
    except ValidationError as e:
        return NotImplementedResponse(
            supported=False,
            reason=f"Invalid request: {e}",
            error=Error(code="VALIDATION_ERROR", message=str(e)),
        )
    return await self.handle_get_plan_audit_logs(request, context)

Retrieve governance audit logs for one or more plans.

async def get_property_list(self,
params: GetPropertyListRequest | dict[str, Any],
context: TContext | None = None) ‑> adcp.types.generated_poc.property.get_property_list_response.GetPropertyListResponse | NotImplementedResponse
Expand source code
async def get_property_list(
    self,
    params: GetPropertyListRequest | dict[str, Any],
    context: TContext | None = None,
) -> GetPropertyListResponse | NotImplementedResponse:
    """Get a property list with optional resolution.

    Validates params and delegates to handle_get_property_list.
    """
    try:
        request = GetPropertyListRequest.model_validate(params)
    except ValidationError as e:
        return NotImplementedResponse(
            supported=False,
            reason=f"Invalid request: {e}",
            error=Error(code="VALIDATION_ERROR", message=str(e)),
        )
    return await self.handle_get_property_list(request, context)

Get a property list with optional resolution.

Validates params and delegates to handle_get_property_list.

async def handle_check_governance(self, request: CheckGovernanceRequest, context: TContext | None = None) ‑> adcp.types.generated_poc.governance.check_governance_response.CheckGovernanceResponse
Expand source code
@abstractmethod
async def handle_check_governance(
    self,
    request: CheckGovernanceRequest,
    context: TContext | None = None,
) -> CheckGovernanceResponse:
    """Handle a governance check request."""
    ...

Handle a governance check request.

async def handle_create_property_list(self, request: CreatePropertyListRequest, context: TContext | None = None) ‑> adcp.types.generated_poc.property.create_property_list_response.CreatePropertyListResponse
Expand source code
@abstractmethod
async def handle_create_property_list(
    self,
    request: CreatePropertyListRequest,
    context: TContext | None = None,
) -> CreatePropertyListResponse:
    """Handle create property list request."""
    ...

Handle create property list request.

async def handle_delete_property_list(self, request: DeletePropertyListRequest, context: TContext | None = None) ‑> adcp.types.generated_poc.property.delete_property_list_response.DeletePropertyListResponse
Expand source code
@abstractmethod
async def handle_delete_property_list(
    self,
    request: DeletePropertyListRequest,
    context: TContext | None = None,
) -> DeletePropertyListResponse:
    """Handle delete property list request."""
    ...

Handle delete property list request.

async def handle_get_creative_features(self, request: GetCreativeFeaturesRequest, context: TContext | None = None) ‑> adcp.types.generated_poc.creative.get_creative_features_response.GetCreativeFeaturesResponse1 | adcp.types.generated_poc.creative.get_creative_features_response.GetCreativeFeaturesResponse2
Expand source code
@abstractmethod
async def handle_get_creative_features(
    self,
    request: GetCreativeFeaturesRequest,
    context: TContext | None = None,
) -> GetCreativeFeaturesResponse:
    """Handle creative feature evaluation."""
    ...

Handle creative feature evaluation.

async def handle_get_plan_audit_logs(self, request: GetPlanAuditLogsRequest, context: TContext | None = None) ‑> adcp.types.generated_poc.governance.get_plan_audit_logs_response.GetPlanAuditLogsResponse
Expand source code
@abstractmethod
async def handle_get_plan_audit_logs(
    self,
    request: GetPlanAuditLogsRequest,
    context: TContext | None = None,
) -> GetPlanAuditLogsResponse:
    """Handle retrieval of governance audit logs."""
    ...

Handle retrieval of governance audit logs.

async def handle_get_property_list(self, request: GetPropertyListRequest, context: TContext | None = None) ‑> adcp.types.generated_poc.property.get_property_list_response.GetPropertyListResponse
Expand source code
@abstractmethod
async def handle_get_property_list(
    self,
    request: GetPropertyListRequest,
    context: TContext | None = None,
) -> GetPropertyListResponse:
    """Handle get property list request."""
    ...

Handle get property list request.

async def handle_list_property_lists(self, request: ListPropertyListsRequest, context: TContext | None = None) ‑> adcp.types.generated_poc.property.list_property_lists_response.ListPropertyListsResponse
Expand source code
@abstractmethod
async def handle_list_property_lists(
    self,
    request: ListPropertyListsRequest,
    context: TContext | None = None,
) -> ListPropertyListsResponse:
    """Handle list property lists request."""
    ...

Handle list property lists request.

async def handle_report_plan_outcome(self, request: ReportPlanOutcomeRequest, context: TContext | None = None) ‑> adcp.types.generated_poc.governance.report_plan_outcome_response.ReportPlanOutcomeResponse
Expand source code
@abstractmethod
async def handle_report_plan_outcome(
    self,
    request: ReportPlanOutcomeRequest,
    context: TContext | None = None,
) -> ReportPlanOutcomeResponse:
    """Handle reporting of a governed action outcome."""
    ...

Handle reporting of a governed action outcome.

async def handle_sync_plans(self, request: SyncPlansRequest, context: TContext | None = None) ‑> adcp.types.generated_poc.governance.sync_plans_response.SyncPlansResponse
Expand source code
@abstractmethod
async def handle_sync_plans(
    self,
    request: SyncPlansRequest,
    context: TContext | None = None,
) -> SyncPlansResponse:
    """Handle campaign governance plan sync."""
    ...

Handle campaign governance plan sync.

async def handle_update_property_list(self, request: UpdatePropertyListRequest, context: TContext | None = None) ‑> adcp.types.generated_poc.property.update_property_list_response.UpdatePropertyListResponse
Expand source code
@abstractmethod
async def handle_update_property_list(
    self,
    request: UpdatePropertyListRequest,
    context: TContext | None = None,
) -> UpdatePropertyListResponse:
    """Handle update property list request."""
    ...

Handle update property list request.

async def list_property_lists(self,
params: ListPropertyListsRequest | dict[str, Any],
context: TContext | None = None) ‑> adcp.types.generated_poc.property.list_property_lists_response.ListPropertyListsResponse | NotImplementedResponse
Expand source code
async def list_property_lists(
    self,
    params: ListPropertyListsRequest | dict[str, Any],
    context: TContext | None = None,
) -> ListPropertyListsResponse | NotImplementedResponse:
    """List property lists.

    Validates params and delegates to handle_list_property_lists.
    """
    try:
        request = ListPropertyListsRequest.model_validate(params)
    except ValidationError as e:
        return NotImplementedResponse(
            supported=False,
            reason=f"Invalid request: {e}",
            error=Error(code="VALIDATION_ERROR", message=str(e)),
        )
    return await self.handle_list_property_lists(request, context)

List property lists.

Validates params and delegates to handle_list_property_lists.

async def report_plan_outcome(self,
params: ReportPlanOutcomeRequest | dict[str, Any],
context: TContext | None = None) ‑> adcp.types.generated_poc.governance.report_plan_outcome_response.ReportPlanOutcomeResponse | NotImplementedResponse
Expand source code
async def report_plan_outcome(
    self,
    params: ReportPlanOutcomeRequest | dict[str, Any],
    context: TContext | None = None,
) -> ReportPlanOutcomeResponse | NotImplementedResponse:
    """Report the outcome of a previously governed action."""
    try:
        request = ReportPlanOutcomeRequest.model_validate(params)
    except ValidationError as e:
        return NotImplementedResponse(
            supported=False,
            reason=f"Invalid request: {e}",
            error=Error(code="VALIDATION_ERROR", message=str(e)),
        )
    return await self.handle_report_plan_outcome(request, context)

Report the outcome of a previously governed action.

async def sync_plans(self,
params: SyncPlansRequest | dict[str, Any],
context: TContext | None = None) ‑> adcp.types.generated_poc.governance.sync_plans_response.SyncPlansResponse | NotImplementedResponse
Expand source code
async def sync_plans(
    self,
    params: SyncPlansRequest | dict[str, Any],
    context: TContext | None = None,
) -> SyncPlansResponse | NotImplementedResponse:
    """Sync campaign governance plans to the agent."""
    try:
        request = SyncPlansRequest.model_validate(params)
    except ValidationError as e:
        return NotImplementedResponse(
            supported=False,
            reason=f"Invalid request: {e}",
            error=Error(code="VALIDATION_ERROR", message=str(e)),
        )
    return await self.handle_sync_plans(request, context)

Sync campaign governance plans to the agent.

async def update_property_list(self,
params: UpdatePropertyListRequest | dict[str, Any],
context: TContext | None = None) ‑> adcp.types.generated_poc.property.update_property_list_response.UpdatePropertyListResponse | NotImplementedResponse
Expand source code
async def update_property_list(
    self,
    params: UpdatePropertyListRequest | dict[str, Any],
    context: TContext | None = None,
) -> UpdatePropertyListResponse | NotImplementedResponse:
    """Update a property list.

    Validates params and delegates to handle_update_property_list.
    """
    try:
        request = UpdatePropertyListRequest.model_validate(params)
    except ValidationError as e:
        return NotImplementedResponse(
            supported=False,
            reason=f"Invalid request: {e}",
            error=Error(code="VALIDATION_ERROR", message=str(e)),
        )
    return await self.handle_update_property_list(request, context)

Update a property list.

Validates params and delegates to handle_update_property_list.

Inherited members

class IdempotencyStore (backend: IdempotencyBackend,
ttl_seconds: int = 86400,
hash_fn: Callable[[dict[str, Any]], str] = <function canonical_json_sha256>,
*,
clock: Callable[[], float] = <built-in function time>)
Expand source code
class IdempotencyStore:
    """Coordinator that binds canonical hashing to a storage backend.

    :param backend: A concrete :class:`IdempotencyBackend`.
    :param ttl_seconds: How long cached responses remain replayable. Must be
        within the spec's ``[3600, 604800]`` range (1h to 7d). 86400 (24h) is
        the recommended floor and matches the compliance storyboard.
    :param hash_fn: Optional override for the canonical hash function. Defaults
        to :func:`canonical_json_sha256`. Exposed for tests and for anyone who
        wants to experiment with alternative equivalence rules — though note
        the spec mandates RFC 8785 JCS for interop.
    """

    def __init__(
        self,
        backend: IdempotencyBackend,
        ttl_seconds: int = 86400,
        hash_fn: Callable[[dict[str, Any]], str] = canonical_json_sha256,
        *,
        clock: Callable[[], float] = time.time,
    ) -> None:
        if not _MIN_TTL_SECONDS <= ttl_seconds <= _MAX_TTL_SECONDS:
            raise ValueError(
                f"ttl_seconds must be in [{_MIN_TTL_SECONDS}, {_MAX_TTL_SECONDS}] "
                f"per AdCP spec (capabilities.idempotency.replay_ttl_seconds), "
                f"got {ttl_seconds}"
            )
        self.backend = backend
        self.ttl_seconds = ttl_seconds
        self._hash_fn = hash_fn
        self._clock = clock

    def capability(self) -> dict[str, Any]:
        """Return the capabilities fragment declaring this store's replay window.

        Embed under ``capabilities.adcp.idempotency`` on the seller's
        ``get_adcp_capabilities`` response. Buyers read this to reason about
        retry-safe windows (AdCP #2315)::

            caps.adcp.idempotency = idempotency.capability()
            # → {"supported": True, "replay_ttl_seconds": 86400}

        ``supported`` became REQUIRED in AdCP 3.0 GA — agents emitting only
        ``replay_ttl_seconds`` fail strict schema validation on the new
        capabilities response.
        """
        return {"supported": True, "replay_ttl_seconds": self.ttl_seconds}

    def wrap(self, handler: HandlerFn) -> HandlerFn:
        """Decorator that adds idempotency semantics to an AdCP handler method.

        Supports three calling conventions the framework dispatches with:

        1. **Positional** ``handler(self, params, context)`` — the
           default for non-projected tools (``get_products``,
           ``create_media_buy``, etc.).
        2. **Keyword** ``handler(self, params=..., context=...)`` —
           same shape, just kwargs.
        3. **Arg-projected** ``handler(self, **arg_projector_kwargs, ctx=...)``
           where ``params`` is split into per-field kwargs by the
           framework dispatcher (e.g. ``update_media_buy`` is called
           as ``handler(self, media_buy_id=..., patch=..., ctx=...)``).
           In this mode the wrap searches the kwargs for a Pydantic
           model (``patch`` for update_media_buy) to extract the
           idempotency key and hash payload from. Adopters whose
           projection contains no Pydantic model (e.g. a method
           projecting only a list of ids) get fall-through behavior:
           no key found → handler runs without dedup.

        ``params`` is normalized to a dict before hashing; the return
        value is coerced to a dict for caching (via ``model_dump`` if
        Pydantic). The decorator always returns the handler's original
        object on a cache miss and a best-effort Pydantic
        re-validation on a hit (when the handler's declared return
        type exposes ``model_validate``). Callers that return raw
        dicts get dicts back.
        """

        @wraps(handler)
        async def _wrapped(*args: Any, **kwargs: Any) -> Any:
            handler_self, hash_source, context = _resolve_call_args(args, kwargs)

            scope_key, idempotency_key, params_dict = self._prepare(hash_source, context)
            if scope_key is None or idempotency_key is None:
                # No key → spec says the server MUST reject with INVALID_REQUEST.
                # We let the handler run so validation layers above us (Pydantic,
                # FastAPI, etc.) can reject with a typed error; the middleware's
                # job is only to dedup when a key IS present.
                #
                # Forward the call exactly as received so all three calling
                # conventions (positional / keyword / arg-projected) reach
                # the inner handler unchanged. The wrap is signature-
                # transparent on the no-key path.
                return await handler(*args, **kwargs)

            payload_hash = self._hash_fn(params_dict)

            cached = await self.backend.get(scope_key, idempotency_key)
            if cached is not None:
                if cached.payload_hash == payload_hash:
                    logger.debug(
                        "idempotency replay: scope=%s key_prefix=%s",
                        _scope_log_id(scope_key),
                        idempotency_key[:8],
                    )
                    # AdCP L1/security idempotency rule 4: the replay
                    # envelope MUST carry ``replayed: true`` so buyer
                    # agents can suppress side effects (notifications,
                    # webhook dispatch, memory writes) on retry. The
                    # store owns this — sellers can't inject at the
                    # right point (cache lookup happens here, wire
                    # serialization happens later). The injection
                    # lands on the cloned dict, not ``cached.response``,
                    # so multiple replays of the same key all carry
                    # exactly one ``replayed: true`` without compounding.
                    replay = _clone_response(cached.response)
                    replay["replayed"] = True
                    return replay
                # Same key, different payload — spec-defined conflict.
                raise IdempotencyConflictError(
                    operation=getattr(handler, "__name__", "handler"),
                    errors=[
                        {
                            "code": "IDEMPOTENCY_CONFLICT",
                            "message": (
                                "idempotency_key reused with a different payload "
                                "(canonical hash mismatch)"
                            ),
                        }
                    ],
                )

            response = await handler(*args, **kwargs)
            # Deep-copy when caching so post-return mutation of the caller's
            # copy can't poison future replays. `_clone_response` also deep-
            # copies on the hit path, giving independent objects per replay.
            response_dict = copy.deepcopy(_to_dict(response))
            entry = CachedResponse(
                payload_hash=payload_hash,
                response=response_dict,
                expires_at_epoch=self._clock() + self.ttl_seconds,
            )
            # Commit cache AFTER handler returns. Atomicity with the handler's
            # side effects depends on the backend: MemoryBackend is best-effort
            # (no transactional relationship to external resources); PgBackend
            # (follow-up) will commit in the same transaction when the handler
            # uses the same engine. On put failure we log loudly and return
            # the handler's response — swallowing the exception would be wrong
            # (operators need the signal that caching is broken), and raising
            # would look to the caller like the handler failed, triggering a
            # retry that re-executes side effects. Best compromise: warn
            # operators, return the result, and accept that the next retry
            # with this key will re-execute.
            try:
                await self.backend.put(scope_key, idempotency_key, entry)
            except Exception:
                logger.warning(
                    "Idempotency cache put failed for scope=%s key_prefix=%s — "
                    "handler completed but a subsequent retry with this key will "
                    "re-execute rather than replay. This indicates an operational "
                    "issue with the idempotency backend.",
                    _scope_log_id(scope_key),
                    idempotency_key[:8],
                    exc_info=True,
                )
            return response

        # Register the wrapper for the boot-time validator at
        # adcp.decisioning.validate_idempotency. WeakSet membership —
        # not a public attribute — so adopters can't spoof "wrapped"
        # by stamping an attr on a plain function. The wrapper is
        # registered, not the original handler: re-decorating a forked
        # copy of `handler` would otherwise falsely flag both.
        #
        # Contract for future maintainers: ``is_wrapped()`` checks
        # WeakSet membership of the closure object directly. Do NOT
        # change it to ``inspect.unwrap()``-then-check — the
        # ``@functools.wraps(handler)`` decorator above sets
        # ``_wrapped.__wrapped__ = handler``, so ``inspect.unwrap``
        # would return the original handler (not in the WeakSet) and
        # the validator would silently regress.
        _WRAPPED_FUNCTIONS.add(_wrapped)
        return _wrapped

    def _prepare(self, params: Any, context: Any) -> tuple[str | None, str | None, dict[str, Any]]:
        """Normalize inputs and extract the (scope_key, key, params_dict) tuple.

        ``scope_key`` composes ``tenant_id`` (when present) with
        ``caller_identity`` so cache entries are isolated across tenants even
        if the seller's principal IDs are only unique within each tenant.

        Returns ``(None, None, params_dict)`` when idempotency doesn't apply
        (no caller identity or no key supplied). The caller falls through to
        the plain handler in that case — validation of missing-key lives in
        the request schema, not here.
        """
        params_dict = _to_dict(params)
        idempotency_key = params_dict.get("idempotency_key")
        if not isinstance(idempotency_key, str) or not idempotency_key:
            return None, None, params_dict
        scope_key = _extract_scope_key(context)
        if scope_key is None:
            # No caller identity: we can't safely scope the key. Spec requires
            # per-principal scope; anything else is a cross-principal replay
            # attack surface. Fall through to the handler (which will process
            # the request normally — no dedup, but no security regression).
            self._warn_missing_principal_once()
            return None, None, params_dict
        return scope_key, idempotency_key, params_dict

    _missing_principal_warned: bool = False

    def _warn_missing_principal_once(self) -> None:
        """Emit a one-time warning when the middleware sees a key but no principal.

        Silent fall-through is the worst DX: the seller drops in
        ``@idempotency.wrap``, ships, and doesn't discover until incident
        review that no dedup ever happened. Fire once per store instance so
        operators see the signal without filling logs on every request.
        """
        if self._missing_principal_warned:
            return
        self._missing_principal_warned = True
        warnings.warn(
            "IdempotencyStore received a request with idempotency_key but no "
            "caller_identity on ToolContext — dedup is SKIPPED. This usually "
            "means your transport isn't populating the authenticated principal. "
            "A2A: wire an a2a-sdk auth middleware that sets ServerCallContext.user; "
            "MCP: populate ToolContext.caller_identity from your FastMCP auth "
            "middleware (see adcp.server.idempotency README). "
            "This warning fires once per IdempotencyStore instance.",
            UserWarning,
            stacklevel=3,
        )

Coordinator that binds canonical hashing to a storage backend.

:param backend: A concrete :class:IdempotencyBackend. :param ttl_seconds: How long cached responses remain replayable. Must be within the spec's [3600, 604800] range (1h to 7d). 86400 (24h) is the recommended floor and matches the compliance storyboard. :param hash_fn: Optional override for the canonical hash function. Defaults to :func:canonical_json_sha256. Exposed for tests and for anyone who wants to experiment with alternative equivalence rules — though note the spec mandates RFC 8785 JCS for interop.

Methods

def capability(self) ‑> dict[str, typing.Any]
Expand source code
def capability(self) -> dict[str, Any]:
    """Return the capabilities fragment declaring this store's replay window.

    Embed under ``capabilities.adcp.idempotency`` on the seller's
    ``get_adcp_capabilities`` response. Buyers read this to reason about
    retry-safe windows (AdCP #2315)::

        caps.adcp.idempotency = idempotency.capability()
        # → {"supported": True, "replay_ttl_seconds": 86400}

    ``supported`` became REQUIRED in AdCP 3.0 GA — agents emitting only
    ``replay_ttl_seconds`` fail strict schema validation on the new
    capabilities response.
    """
    return {"supported": True, "replay_ttl_seconds": self.ttl_seconds}

Return the capabilities fragment declaring this store's replay window.

Embed under capabilities.adcp.idempotency on the seller's get_adcp_capabilities response. Buyers read this to reason about retry-safe windows (AdCP #2315)::

caps.adcp.idempotency = idempotency.capability()
# → {"supported": True, "replay_ttl_seconds": 86400}

supported became REQUIRED in AdCP 3.0 GA — agents emitting only replay_ttl_seconds fail strict schema validation on the new capabilities response.

def wrap(self, handler: HandlerFn) ‑> Callable[..., Awaitable[typing.Any]]
Expand source code
def wrap(self, handler: HandlerFn) -> HandlerFn:
    """Decorator that adds idempotency semantics to an AdCP handler method.

    Supports three calling conventions the framework dispatches with:

    1. **Positional** ``handler(self, params, context)`` — the
       default for non-projected tools (``get_products``,
       ``create_media_buy``, etc.).
    2. **Keyword** ``handler(self, params=..., context=...)`` —
       same shape, just kwargs.
    3. **Arg-projected** ``handler(self, **arg_projector_kwargs, ctx=...)``
       where ``params`` is split into per-field kwargs by the
       framework dispatcher (e.g. ``update_media_buy`` is called
       as ``handler(self, media_buy_id=..., patch=..., ctx=...)``).
       In this mode the wrap searches the kwargs for a Pydantic
       model (``patch`` for update_media_buy) to extract the
       idempotency key and hash payload from. Adopters whose
       projection contains no Pydantic model (e.g. a method
       projecting only a list of ids) get fall-through behavior:
       no key found → handler runs without dedup.

    ``params`` is normalized to a dict before hashing; the return
    value is coerced to a dict for caching (via ``model_dump`` if
    Pydantic). The decorator always returns the handler's original
    object on a cache miss and a best-effort Pydantic
    re-validation on a hit (when the handler's declared return
    type exposes ``model_validate``). Callers that return raw
    dicts get dicts back.
    """

    @wraps(handler)
    async def _wrapped(*args: Any, **kwargs: Any) -> Any:
        handler_self, hash_source, context = _resolve_call_args(args, kwargs)

        scope_key, idempotency_key, params_dict = self._prepare(hash_source, context)
        if scope_key is None or idempotency_key is None:
            # No key → spec says the server MUST reject with INVALID_REQUEST.
            # We let the handler run so validation layers above us (Pydantic,
            # FastAPI, etc.) can reject with a typed error; the middleware's
            # job is only to dedup when a key IS present.
            #
            # Forward the call exactly as received so all three calling
            # conventions (positional / keyword / arg-projected) reach
            # the inner handler unchanged. The wrap is signature-
            # transparent on the no-key path.
            return await handler(*args, **kwargs)

        payload_hash = self._hash_fn(params_dict)

        cached = await self.backend.get(scope_key, idempotency_key)
        if cached is not None:
            if cached.payload_hash == payload_hash:
                logger.debug(
                    "idempotency replay: scope=%s key_prefix=%s",
                    _scope_log_id(scope_key),
                    idempotency_key[:8],
                )
                # AdCP L1/security idempotency rule 4: the replay
                # envelope MUST carry ``replayed: true`` so buyer
                # agents can suppress side effects (notifications,
                # webhook dispatch, memory writes) on retry. The
                # store owns this — sellers can't inject at the
                # right point (cache lookup happens here, wire
                # serialization happens later). The injection
                # lands on the cloned dict, not ``cached.response``,
                # so multiple replays of the same key all carry
                # exactly one ``replayed: true`` without compounding.
                replay = _clone_response(cached.response)
                replay["replayed"] = True
                return replay
            # Same key, different payload — spec-defined conflict.
            raise IdempotencyConflictError(
                operation=getattr(handler, "__name__", "handler"),
                errors=[
                    {
                        "code": "IDEMPOTENCY_CONFLICT",
                        "message": (
                            "idempotency_key reused with a different payload "
                            "(canonical hash mismatch)"
                        ),
                    }
                ],
            )

        response = await handler(*args, **kwargs)
        # Deep-copy when caching so post-return mutation of the caller's
        # copy can't poison future replays. `_clone_response` also deep-
        # copies on the hit path, giving independent objects per replay.
        response_dict = copy.deepcopy(_to_dict(response))
        entry = CachedResponse(
            payload_hash=payload_hash,
            response=response_dict,
            expires_at_epoch=self._clock() + self.ttl_seconds,
        )
        # Commit cache AFTER handler returns. Atomicity with the handler's
        # side effects depends on the backend: MemoryBackend is best-effort
        # (no transactional relationship to external resources); PgBackend
        # (follow-up) will commit in the same transaction when the handler
        # uses the same engine. On put failure we log loudly and return
        # the handler's response — swallowing the exception would be wrong
        # (operators need the signal that caching is broken), and raising
        # would look to the caller like the handler failed, triggering a
        # retry that re-executes side effects. Best compromise: warn
        # operators, return the result, and accept that the next retry
        # with this key will re-execute.
        try:
            await self.backend.put(scope_key, idempotency_key, entry)
        except Exception:
            logger.warning(
                "Idempotency cache put failed for scope=%s key_prefix=%s — "
                "handler completed but a subsequent retry with this key will "
                "re-execute rather than replay. This indicates an operational "
                "issue with the idempotency backend.",
                _scope_log_id(scope_key),
                idempotency_key[:8],
                exc_info=True,
            )
        return response

    # Register the wrapper for the boot-time validator at
    # adcp.decisioning.validate_idempotency. WeakSet membership —
    # not a public attribute — so adopters can't spoof "wrapped"
    # by stamping an attr on a plain function. The wrapper is
    # registered, not the original handler: re-decorating a forked
    # copy of `handler` would otherwise falsely flag both.
    #
    # Contract for future maintainers: ``is_wrapped()`` checks
    # WeakSet membership of the closure object directly. Do NOT
    # change it to ``inspect.unwrap()``-then-check — the
    # ``@functools.wraps(handler)`` decorator above sets
    # ``_wrapped.__wrapped__ = handler``, so ``inspect.unwrap``
    # would return the original handler (not in the WeakSet) and
    # the validator would silently regress.
    _WRAPPED_FUNCTIONS.add(_wrapped)
    return _wrapped

Decorator that adds idempotency semantics to an AdCP handler method.

Supports three calling conventions the framework dispatches with:

  1. Positional handler(self, params, context) — the default for non-projected tools (get_products, create_media_buy, etc.).
  2. Keyword handler(self, params=..., context=...) — same shape, just kwargs.
  3. Arg-projected handler(self, **arg_projector_kwargs, ctx=...) where params is split into per-field kwargs by the framework dispatcher (e.g. update_media_buy is called as handler(self, media_buy_id=..., patch=..., ctx=...)). In this mode the wrap searches the kwargs for a Pydantic model (patch for update_media_buy) to extract the idempotency key and hash payload from. Adopters whose projection contains no Pydantic model (e.g. a method projecting only a list of ids) get fall-through behavior: no key found → handler runs without dedup.

params is normalized to a dict before hashing; the return value is coerced to a dict for caching (via model_dump if Pydantic). The decorator always returns the handler's original object on a cache miss and a best-effort Pydantic re-validation on a hit (when the handler's declared return type exposes model_validate). Callers that return raw dicts get dicts back.

class InMemorySubdomainTenantRouter (tenants: Mapping[str, Tenant])
Expand source code
class InMemorySubdomainTenantRouter:
    """Reference :class:`SubdomainTenantRouter` for dev / test.

    Backed by a static ``host → Tenant`` dict. Lookup is exact
    match on the lower-cased host (with the port suffix stripped).
    Production adopters swap to a SQL-backed impl that hits their
    tenant table.
    """

    def __init__(self, tenants: Mapping[str, Tenant]) -> None:
        # Normalize keys to lower-cased + port-stripped at construction
        # so resolve() can be a single dict lookup. Adopters who pass
        # mixed case (``Acme.Example.com``) get the obvious behavior.
        self._tenants: dict[str, Tenant] = {
            _normalize_host(host): tenant for host, tenant in tenants.items()
        }

    async def resolve(self, host: str) -> Tenant | None:
        return self._tenants.get(_normalize_host(host))

Reference :class:SubdomainTenantRouter for dev / test.

Backed by a static host → Tenant dict. Lookup is exact match on the lower-cased host (with the port suffix stripped). Production adopters swap to a SQL-backed impl that hits their tenant table.

Methods

async def resolve(self, host: str) ‑> Tenant | None
Expand source code
async def resolve(self, host: str) -> Tenant | None:
    return self._tenants.get(_normalize_host(host))
class MCPSessionStats (active_sessions: int,
max_active_sessions: int | None,
total_sessions_created: int,
sessions_created_last_60s: int,
stateless: bool,
session_idle_timeout: float | None,
session_age_seconds: tuple[float, ...],
session_idle_seconds: tuple[float, ...])
Expand source code
@dataclass(frozen=True)
class MCPSessionStats:
    """Snapshot of a Streamable HTTP session manager.

    Numeric age and idle values are seconds from the manager's local
    monotonic clock. They are intended for metrics/debug visibility, not
    wall-clock audit records.
    """

    active_sessions: int
    max_active_sessions: int | None
    total_sessions_created: int
    sessions_created_last_60s: int
    stateless: bool
    session_idle_timeout: float | None
    session_age_seconds: tuple[float, ...]
    session_idle_seconds: tuple[float, ...]

    def as_dict(self) -> dict[str, Any]:
        """Return a JSON-serializable representation."""
        return {
            "active_sessions": self.active_sessions,
            "max_active_sessions": self.max_active_sessions,
            "total_sessions_created": self.total_sessions_created,
            "sessions_created_last_60s": self.sessions_created_last_60s,
            "stateless": self.stateless,
            "session_idle_timeout": self.session_idle_timeout,
            "session_age_seconds": list(self.session_age_seconds),
            "session_idle_seconds": list(self.session_idle_seconds),
        }

Snapshot of a Streamable HTTP session manager.

Numeric age and idle values are seconds from the manager's local monotonic clock. They are intended for metrics/debug visibility, not wall-clock audit records.

Instance variables

var active_sessions : int
var max_active_sessions : int | None
var session_age_seconds : tuple[float, ...]
var session_idle_seconds : tuple[float, ...]
var session_idle_timeout : float | None
var sessions_created_last_60s : int
var stateless : bool
var total_sessions_created : int

Methods

def as_dict(self) ‑> dict[str, typing.Any]
Expand source code
def as_dict(self) -> dict[str, Any]:
    """Return a JSON-serializable representation."""
    return {
        "active_sessions": self.active_sessions,
        "max_active_sessions": self.max_active_sessions,
        "total_sessions_created": self.total_sessions_created,
        "sessions_created_last_60s": self.sessions_created_last_60s,
        "stateless": self.stateless,
        "session_idle_timeout": self.session_idle_timeout,
        "session_age_seconds": list(self.session_age_seconds),
        "session_idle_seconds": list(self.session_idle_seconds),
    }

Return a JSON-serializable representation.

class MCPToolSet (handler: ADCPHandler[Any],
*,
advertise_all: bool = False,
validation: ValidationHookConfig | None = None,
pre_validation_hooks: PreValidationHooks | None = None,
response_enhancer: ResponseEnhancer | None = None)
Expand source code
class MCPToolSet:
    """Collection of MCP tools from an ADCP handler.

    Provides tool definitions and handlers for registering with an MCP server.
    """

    def __init__(
        self,
        handler: ADCPHandler[Any],
        *,
        advertise_all: bool = False,
        validation: ValidationHookConfig | None = None,
        pre_validation_hooks: PreValidationHooks | None = None,
        response_enhancer: ResponseEnhancer | None = None,
    ):
        """Create tool set from handler.

        Args:
            handler: ADCP handler instance.
            advertise_all: When True, advertise every tool the handler
                type supports — even those whose method is still the
                SDK's ``not_supported`` default. See
                :func:`get_tools_for_handler` for the default behavior
                (override-filtered advertisement).
            validation: Opt-in schema validation config applied to every
                tool caller. See :func:`create_tool_caller`.
            pre_validation_hooks: Optional dict mapping tool name to a
                ``(tool_name, args) -> args`` callable or ordered sequence.
                Applied before schema + Pydantic validation. See
                :func:`create_tool_caller`.
            response_enhancer: Optional server-wide :data:`ResponseEnhancer`
                applied to every successful response. See
                :func:`create_tool_caller`.
        """
        self.handler = handler
        self._filtered_definitions = get_tools_for_handler(handler, advertise_all=advertise_all)
        self._tools: dict[str, Callable[..., Any]] = {}

        # Create tool callers only for filtered tools
        for tool_def in self._filtered_definitions:
            name = tool_def["name"]
            hook = (pre_validation_hooks or {}).get(name)
            self._tools[name] = create_tool_caller(
                handler,
                name,
                validation=validation,
                pre_validation_hook=hook,
                response_enhancer=response_enhancer,
            )

    @property
    def tool_definitions(self) -> list[dict[str, Any]]:
        """Get MCP tool definitions filtered by handler type."""
        return list(self._filtered_definitions)

    async def call_tool(self, name: str, params: dict[str, Any]) -> Any:
        """Call a tool by name.

        Args:
            name: Tool name
            params: Tool parameters

        Returns:
            Tool result

        Raises:
            KeyError: If tool not found
        """
        if name not in self._tools:
            raise KeyError(f"Unknown tool: {name}")
        return await self._tools[name](params)

    def get_tool_names(self) -> list[str]:
        """Get list of available tool names."""
        return list(self._tools.keys())

Collection of MCP tools from an ADCP handler.

Provides tool definitions and handlers for registering with an MCP server.

Create tool set from handler.

Args

handler
ADCP handler instance.
advertise_all
When True, advertise every tool the handler type supports — even those whose method is still the SDK's not_supported() default. See :func:get_tools_for_handler() for the default behavior (override-filtered advertisement).
validation
Opt-in schema validation config applied to every tool caller. See :func:create_tool_caller.
pre_validation_hooks
Optional dict mapping tool name to a (tool_name, args) -> args callable or ordered sequence. Applied before schema + Pydantic validation. See :func:create_tool_caller.
response_enhancer
Optional server-wide :data:ResponseEnhancer applied to every successful response. See :func:create_tool_caller.

Instance variables

prop tool_definitions : list[dict[str, Any]]
Expand source code
@property
def tool_definitions(self) -> list[dict[str, Any]]:
    """Get MCP tool definitions filtered by handler type."""
    return list(self._filtered_definitions)

Get MCP tool definitions filtered by handler type.

Methods

async def call_tool(self, name: str, params: dict[str, Any]) ‑> Any
Expand source code
async def call_tool(self, name: str, params: dict[str, Any]) -> Any:
    """Call a tool by name.

    Args:
        name: Tool name
        params: Tool parameters

    Returns:
        Tool result

    Raises:
        KeyError: If tool not found
    """
    if name not in self._tools:
        raise KeyError(f"Unknown tool: {name}")
    return await self._tools[name](params)

Call a tool by name.

Args

name
Tool name
params
Tool parameters

Returns

Tool result

Raises

KeyError
If tool not found
def get_tool_names(self) ‑> list[str]
Expand source code
def get_tool_names(self) -> list[str]:
    """Get list of available tool names."""
    return list(self._tools.keys())

Get list of available tool names.

class MemoryBackend (*, clock: Callable[[], float] = <built-in function time>)
Expand source code
class MemoryBackend(IdempotencyBackend):
    """In-process dict-backed store.

    Suitable for tests, single-process reference implementations, and local
    development. **Not suitable for multi-process deployments** — each worker
    has its own cache, so a retry that lands on a different worker is treated
    as a fresh request.

    Thread safety: the backend uses an :class:`asyncio.Lock` to serialize
    mutations of the shared dict. Reads go through the lock too; for a pure
    in-process backend this is cheap and prevents torn reads across concurrent
    ``get``/``put`` interleaving.

    :param clock: Callable returning the current epoch seconds. Override for
        tests that need to advance time deterministically without monkeypatching
        :mod:`time`. Defaults to :func:`time.time`.
    """

    def __init__(self, *, clock: Callable[[], float] = time.time) -> None:
        self._store: dict[tuple[str, str], CachedResponse] = {}
        self._lock = asyncio.Lock()
        self._clock = clock

    async def get(self, scope_key: str, key: str) -> CachedResponse | None:
        async with self._lock:
            entry = self._store.get((scope_key, key))
            if entry is None:
                return None
            if entry.expires_at_epoch <= self._clock():
                # Lazy expiry — drop the stale entry so the next request
                # treats the slot as fresh and races to repopulate.
                del self._store[(scope_key, key)]
                return None
            return entry

    async def put(
        self,
        scope_key: str,
        key: str,
        entry: CachedResponse,
    ) -> None:
        async with self._lock:
            self._store[(scope_key, key)] = entry

    async def delete_expired(self, now_epoch: float | None = None) -> int:
        cutoff = now_epoch if now_epoch is not None else self._clock()
        async with self._lock:
            stale = [k for k, v in self._store.items() if v.expires_at_epoch <= cutoff]
            for k in stale:
                del self._store[k]
            return len(stale)

    async def clear(self) -> None:
        """Remove all cached entries.

        Test-suite hook — handy for resetting state between fixtures when a
        single :class:`MemoryBackend` is shared across multiple tests.
        """
        async with self._lock:
            self._store.clear()

    async def _size(self) -> int:
        """Test-only: return the current entry count."""
        async with self._lock:
            return len(self._store)

In-process dict-backed store.

Suitable for tests, single-process reference implementations, and local development. Not suitable for multi-process deployments — each worker has its own cache, so a retry that lands on a different worker is treated as a fresh request.

Thread safety: the backend uses an :class:asyncio.Lock to serialize mutations of the shared dict. Reads go through the lock too; for a pure in-process backend this is cheap and prevents torn reads across concurrent get/put interleaving.

:param clock: Callable returning the current epoch seconds. Override for tests that need to advance time deterministically without monkeypatching :mod:time. Defaults to :func:time.time.

Ancestors

Methods

async def clear(self) ‑> None
Expand source code
async def clear(self) -> None:
    """Remove all cached entries.

    Test-suite hook — handy for resetting state between fixtures when a
    single :class:`MemoryBackend` is shared across multiple tests.
    """
    async with self._lock:
        self._store.clear()

Remove all cached entries.

Test-suite hook — handy for resetting state between fixtures when a single :class:MemoryBackend is shared across multiple tests.

Inherited members

class NotImplementedResponse (**data: Any)
Expand source code
class NotImplementedResponse(BaseModel):
    """Standard response for operations not supported by this handler."""

    supported: bool = False
    reason: str = "This operation is not supported by this agent"
    error: Error | None = None

Standard response for operations not supported by this handler.

Create a new model by parsing and validating input data from keyword arguments.

Raises [ValidationError][pydantic_core.ValidationError] if the input data cannot be validated to form a valid model.

self is explicitly positional-only to allow self as a field name.

Ancestors

  • pydantic.main.BaseModel

Class variables

var error : adcp.types.generated_poc.core.error.Error | None
var model_config
var reason : str
var supported : bool
class PreValidationHookError (*, index: int, hook_name: str, message: str)
Expand source code
class PreValidationHookError(Exception):
    """A single hook in an ordered pre-validation chain failed.

    Carries the zero-based ``index`` of the failing hook within its chain and
    the resolved ``hook_name`` so the dispatcher can surface both in the
    ``INVALID_REQUEST`` message — naming the exact callable instead of a
    generic "pre_validation_hook raised ...".
    """

    def __init__(self, *, index: int, hook_name: str, message: str) -> None:
        self.index = index
        self.hook_name = hook_name
        super().__init__(f"pre_validation_hook[{index}] {hook_name} {message}")

A single hook in an ordered pre-validation chain failed.

Carries the zero-based index of the failing hook within its chain and the resolved hook_name so the dispatcher can surface both in the INVALID_REQUEST message — naming the exact callable instead of a generic "pre_validation_hook raised …".

Ancestors

  • builtins.Exception
  • builtins.BaseException
class Principal (caller_identity: str,
tenant_id: str | None = None,
metadata: dict[str, Any] = <factory>)
Expand source code
@dataclass(frozen=True)
class Principal:
    """An authenticated principal — the result of token validation.

    Returned by a :data:`TokenValidator` on success. Used to populate
    the transport-layer ``ContextVar``s that :func:`auth_context_factory`
    reads when building per-call :class:`~adcp.server.ToolContext`.

    :param caller_identity: Stable, globally-unique principal id within
        the tenant. See the
        :class:`~adcp.server.ToolContext.caller_identity` docstring for
        the stability contract and the failure mode when this is
        reused across logical principals.
    :param tenant_id: Tenant the principal belongs to. Populate unless
        your principal ids are globally unique across tenants — the
        server-side idempotency store scopes cache keys on
        ``(tenant_id, caller_identity)``. See
        :doc:`/multi-tenant-contract` for the full invariants.
    :param metadata: Optional extra fields the context_factory should
        propagate into :class:`~adcp.server.ToolContext.metadata`.
    """

    caller_identity: str
    tenant_id: str | None = None
    metadata: dict[str, Any] = field(default_factory=dict)

An authenticated principal — the result of token validation.

Returned by a :data:TokenValidator on success. Used to populate the transport-layer ContextVars that :func:auth_context_factory() reads when building per-call :class:~adcp.server.ToolContext.

:param caller_identity: Stable, globally-unique principal id within the tenant. See the :class:~adcp.server.ToolContext.caller_identity docstring for the stability contract and the failure mode when this is reused across logical principals. :param tenant_id: Tenant the principal belongs to. Populate unless your principal ids are globally unique across tenants — the server-side idempotency store scopes cache keys on (tenant_id, caller_identity). See :doc:/multi-tenant-contract for the full invariants. :param metadata: Optional extra fields the context_factory should propagate into :class:~adcp.server.ToolContext.metadata.

Instance variables

var caller_identity : str
var metadata : dict[str, typing.Any]
var tenant_id : str | None
class ProposalBuilder (name: str, proposal_id: str | None = None)
Expand source code
class ProposalBuilder:
    """Builder for ADCP Proposals.

    Helps construct valid proposals for get_products responses. Proposals
    represent recommended media plans with budget allocations.

    Example:
        proposal = (
            ProposalBuilder("Q1 Brand Campaign")
            .with_description("Balanced awareness campaign")
            .add_allocation("product-1", 60)
                .with_rationale("High-impact display")
            .add_allocation("product-2", 40)
                .with_rationale("Contextual targeting")
            .with_budget_guidance(min=10000, recommended=25000, max=50000)
            .build()
        )
    """

    def __init__(self, name: str, proposal_id: str | None = None):
        """Create a new proposal builder.

        Args:
            name: Human-readable name for the proposal
            proposal_id: Unique ID (auto-generated if not provided)
        """
        self._name = name
        self._proposal_id = proposal_id or f"proposal-{uuid4().hex[:8]}"
        self._description: str | None = None
        self._brief_alignment: str | None = None
        self._expires_at: datetime | None = None
        self._allocations: list[dict[str, Any]] = []
        self._budget_guidance: dict[str, Any] | None = None
        self._current_allocation: AllocationBuilder | None = None
        self._ext: dict[str, Any] | None = None

    def with_description(self, description: str) -> ProposalBuilder:
        """Add description explaining the proposal strategy.

        Args:
            description: What the proposal achieves
        """
        self._finalize_allocation()
        self._description = description
        return self

    def with_brief_alignment(self, alignment: str) -> ProposalBuilder:
        """Explain how proposal aligns with campaign brief.

        Args:
            alignment: Alignment explanation
        """
        self._finalize_allocation()
        self._brief_alignment = alignment
        return self

    def expires_in(self, days: int = 7) -> ProposalBuilder:
        """Set expiration relative to now.

        Args:
            days: Number of days until expiration
        """
        self._finalize_allocation()
        self._expires_at = datetime.now(timezone.utc) + timedelta(days=days)
        return self

    def expires_at(self, expires: datetime) -> ProposalBuilder:
        """Set absolute expiration time.

        Args:
            expires: When the proposal expires
        """
        self._finalize_allocation()
        self._expires_at = expires
        return self

    def add_allocation(
        self,
        product_id: str,
        allocation_percentage: float,
    ) -> ProposalBuilder:
        """Add a product allocation.

        After calling this, chain allocation methods (with_rationale, etc.)
        before adding another allocation or calling build().

        Args:
            product_id: ID of the product
            allocation_percentage: Percentage of budget (0-100)

        Returns:
            Self for method chaining
        """
        self._finalize_allocation()
        self._current_allocation = AllocationBuilder(product_id, allocation_percentage)
        return self

    def with_pricing_option(self, pricing_option_id: str) -> ProposalBuilder:
        """Set pricing option for current allocation."""
        if self._current_allocation:
            self._current_allocation.with_pricing_option(pricing_option_id)
        return self

    def with_rationale(self, rationale: str) -> ProposalBuilder:
        """Add rationale for current allocation."""
        if self._current_allocation:
            self._current_allocation.with_rationale(rationale)
        return self

    def with_sequence(self, sequence: int) -> ProposalBuilder:
        """Set sequence for current allocation."""
        if self._current_allocation:
            self._current_allocation.with_sequence(sequence)
        return self

    def with_tags(self, tags: list[str]) -> ProposalBuilder:
        """Add tags for current allocation."""
        if self._current_allocation:
            self._current_allocation.with_tags(tags)
        return self

    def with_budget_guidance(
        self,
        *,
        min: float | None = None,
        recommended: float | None = None,
        max: float | None = None,
        currency: str = "USD",
    ) -> ProposalBuilder:
        """Add budget guidance for the proposal.

        Args:
            min: Minimum recommended budget
            recommended: Optimal budget
            max: Maximum before diminishing returns
            currency: ISO 4217 currency code
        """
        self._finalize_allocation()
        self._budget_guidance = {
            "currency": currency,
        }
        if min is not None:
            self._budget_guidance["min"] = min
        if recommended is not None:
            self._budget_guidance["recommended"] = recommended
        if max is not None:
            self._budget_guidance["max"] = max
        return self

    def with_extension(self, ext: dict[str, Any]) -> ProposalBuilder:
        """Add extension data.

        Args:
            ext: Extension object
        """
        self._finalize_allocation()
        self._ext = ext
        return self

    def _finalize_allocation(self) -> None:
        """Finalize current allocation and add to list."""
        if self._current_allocation:
            self._allocations.append(self._current_allocation.build())
            self._current_allocation = None

    def build(self) -> dict[str, Any]:
        """Build the proposal dict.

        Returns:
            Proposal as a dict ready for use in get_products response

        Raises:
            ValueError: If allocations don't sum to 100
        """
        self._finalize_allocation()

        if not self._allocations:
            raise ValueError("Proposal must have at least one allocation")

        total = sum(a["allocation_percentage"] for a in self._allocations)
        if abs(total - 100.0) > 0.01:
            raise ValueError(f"Allocation percentages must sum to 100, got {total}")

        proposal: dict[str, Any] = {
            "proposal_id": self._proposal_id,
            "name": self._name,
            "allocations": self._allocations,
        }

        if self._description:
            proposal["description"] = self._description
        if self._brief_alignment:
            proposal["brief_alignment"] = self._brief_alignment
        if self._expires_at:
            proposal["expires_at"] = self._expires_at.isoformat().replace("+00:00", "Z")
        if self._budget_guidance:
            proposal["total_budget_guidance"] = self._budget_guidance
        if self._ext:
            proposal["ext"] = self._ext

        return proposal

    def validate(self) -> list[str]:
        """Validate the proposal without building.

        Returns:
            List of validation errors (empty if valid)
        """
        errors: list[str] = []

        if self._current_allocation:
            allocations = self._allocations + [self._current_allocation.build()]
        else:
            allocations = self._allocations

        if not allocations:
            errors.append("Proposal must have at least one allocation")
        else:
            total = sum(a["allocation_percentage"] for a in allocations)
            if abs(total - 100.0) > 0.01:
                errors.append(f"Allocation percentages must sum to 100, got {total}")

        return errors

Builder for ADCP Proposals.

Helps construct valid proposals for get_products responses. Proposals represent recommended media plans with budget allocations.

Example

proposal = ( ProposalBuilder("Q1 Brand Campaign") .with_description("Balanced awareness campaign") .add_allocation("product-1", 60) .with_rationale("High-impact display") .add_allocation("product-2", 40) .with_rationale("Contextual targeting") .with_budget_guidance(min=10000, recommended=25000, max=50000) .build() )

Create a new proposal builder.

Args

name
Human-readable name for the proposal
proposal_id
Unique ID (auto-generated if not provided)

Methods

def add_allocation(self, product_id: str, allocation_percentage: float) ‑> ProposalBuilder
Expand source code
def add_allocation(
    self,
    product_id: str,
    allocation_percentage: float,
) -> ProposalBuilder:
    """Add a product allocation.

    After calling this, chain allocation methods (with_rationale, etc.)
    before adding another allocation or calling build().

    Args:
        product_id: ID of the product
        allocation_percentage: Percentage of budget (0-100)

    Returns:
        Self for method chaining
    """
    self._finalize_allocation()
    self._current_allocation = AllocationBuilder(product_id, allocation_percentage)
    return self

Add a product allocation.

After calling this, chain allocation methods (with_rationale, etc.) before adding another allocation or calling build().

Args

product_id
ID of the product
allocation_percentage
Percentage of budget (0-100)

Returns

Self for method chaining

def build(self) ‑> dict[str, typing.Any]
Expand source code
def build(self) -> dict[str, Any]:
    """Build the proposal dict.

    Returns:
        Proposal as a dict ready for use in get_products response

    Raises:
        ValueError: If allocations don't sum to 100
    """
    self._finalize_allocation()

    if not self._allocations:
        raise ValueError("Proposal must have at least one allocation")

    total = sum(a["allocation_percentage"] for a in self._allocations)
    if abs(total - 100.0) > 0.01:
        raise ValueError(f"Allocation percentages must sum to 100, got {total}")

    proposal: dict[str, Any] = {
        "proposal_id": self._proposal_id,
        "name": self._name,
        "allocations": self._allocations,
    }

    if self._description:
        proposal["description"] = self._description
    if self._brief_alignment:
        proposal["brief_alignment"] = self._brief_alignment
    if self._expires_at:
        proposal["expires_at"] = self._expires_at.isoformat().replace("+00:00", "Z")
    if self._budget_guidance:
        proposal["total_budget_guidance"] = self._budget_guidance
    if self._ext:
        proposal["ext"] = self._ext

    return proposal

Build the proposal dict.

Returns

Proposal as a dict ready for use in get_products response

Raises

ValueError
If allocations don't sum to 100
def expires_at(self, expires: datetime) ‑> ProposalBuilder
Expand source code
def expires_at(self, expires: datetime) -> ProposalBuilder:
    """Set absolute expiration time.

    Args:
        expires: When the proposal expires
    """
    self._finalize_allocation()
    self._expires_at = expires
    return self

Set absolute expiration time.

Args

expires
When the proposal expires
def expires_in(self, days: int = 7) ‑> ProposalBuilder
Expand source code
def expires_in(self, days: int = 7) -> ProposalBuilder:
    """Set expiration relative to now.

    Args:
        days: Number of days until expiration
    """
    self._finalize_allocation()
    self._expires_at = datetime.now(timezone.utc) + timedelta(days=days)
    return self

Set expiration relative to now.

Args

days
Number of days until expiration
def validate(self) ‑> list[str]
Expand source code
def validate(self) -> list[str]:
    """Validate the proposal without building.

    Returns:
        List of validation errors (empty if valid)
    """
    errors: list[str] = []

    if self._current_allocation:
        allocations = self._allocations + [self._current_allocation.build()]
    else:
        allocations = self._allocations

    if not allocations:
        errors.append("Proposal must have at least one allocation")
    else:
        total = sum(a["allocation_percentage"] for a in allocations)
        if abs(total - 100.0) > 0.01:
            errors.append(f"Allocation percentages must sum to 100, got {total}")

    return errors

Validate the proposal without building.

Returns

List of validation errors (empty if valid)

def with_brief_alignment(self, alignment: str) ‑> ProposalBuilder
Expand source code
def with_brief_alignment(self, alignment: str) -> ProposalBuilder:
    """Explain how proposal aligns with campaign brief.

    Args:
        alignment: Alignment explanation
    """
    self._finalize_allocation()
    self._brief_alignment = alignment
    return self

Explain how proposal aligns with campaign brief.

Args

alignment
Alignment explanation
def with_budget_guidance(self,
*,
min: float | None = None,
recommended: float | None = None,
max: float | None = None,
currency: str = 'USD') ‑> ProposalBuilder
Expand source code
def with_budget_guidance(
    self,
    *,
    min: float | None = None,
    recommended: float | None = None,
    max: float | None = None,
    currency: str = "USD",
) -> ProposalBuilder:
    """Add budget guidance for the proposal.

    Args:
        min: Minimum recommended budget
        recommended: Optimal budget
        max: Maximum before diminishing returns
        currency: ISO 4217 currency code
    """
    self._finalize_allocation()
    self._budget_guidance = {
        "currency": currency,
    }
    if min is not None:
        self._budget_guidance["min"] = min
    if recommended is not None:
        self._budget_guidance["recommended"] = recommended
    if max is not None:
        self._budget_guidance["max"] = max
    return self

Add budget guidance for the proposal.

Args

min
Minimum recommended budget
recommended
Optimal budget
max
Maximum before diminishing returns
currency
ISO 4217 currency code
def with_description(self, description: str) ‑> ProposalBuilder
Expand source code
def with_description(self, description: str) -> ProposalBuilder:
    """Add description explaining the proposal strategy.

    Args:
        description: What the proposal achieves
    """
    self._finalize_allocation()
    self._description = description
    return self

Add description explaining the proposal strategy.

Args

description
What the proposal achieves
def with_extension(self, ext: dict[str, Any]) ‑> ProposalBuilder
Expand source code
def with_extension(self, ext: dict[str, Any]) -> ProposalBuilder:
    """Add extension data.

    Args:
        ext: Extension object
    """
    self._finalize_allocation()
    self._ext = ext
    return self

Add extension data.

Args

ext
Extension object
def with_pricing_option(self, pricing_option_id: str) ‑> ProposalBuilder
Expand source code
def with_pricing_option(self, pricing_option_id: str) -> ProposalBuilder:
    """Set pricing option for current allocation."""
    if self._current_allocation:
        self._current_allocation.with_pricing_option(pricing_option_id)
    return self

Set pricing option for current allocation.

def with_rationale(self, rationale: str) ‑> ProposalBuilder
Expand source code
def with_rationale(self, rationale: str) -> ProposalBuilder:
    """Add rationale for current allocation."""
    if self._current_allocation:
        self._current_allocation.with_rationale(rationale)
    return self

Add rationale for current allocation.

def with_sequence(self, sequence: int) ‑> ProposalBuilder
Expand source code
def with_sequence(self, sequence: int) -> ProposalBuilder:
    """Set sequence for current allocation."""
    if self._current_allocation:
        self._current_allocation.with_sequence(sequence)
    return self

Set sequence for current allocation.

def with_tags(self, tags: list[str]) ‑> ProposalBuilder
Expand source code
def with_tags(self, tags: list[str]) -> ProposalBuilder:
    """Add tags for current allocation."""
    if self._current_allocation:
        self._current_allocation.with_tags(tags)
    return self

Add tags for current allocation.

class ProposalNotSupported (**data: Any)
Expand source code
class ProposalNotSupported(BaseModel):
    """Response indicating proposal generation is not supported.

    Use this when your agent supports get_products but not proposal generation.
    """

    proposals_supported: bool = False
    reason: str = "This agent does not generate proposals"
    error: Error | None = None

Response indicating proposal generation is not supported.

Use this when your agent supports get_products but not proposal generation.

Create a new model by parsing and validating input data from keyword arguments.

Raises [ValidationError][pydantic_core.ValidationError] if the input data cannot be validated to form a valid model.

self is explicitly positional-only to allow self as a field name.

Ancestors

  • pydantic.main.BaseModel

Class variables

var error : adcp.types.generated_poc.core.error.Error | None
var model_config
var proposals_supported : bool
var reason : str
class RequestMetadata (tool_name: str,
transport: "Literal['mcp', 'a2a']",
request_id: str | None = None,
request_context: Any = None)
Expand source code
@dataclass(frozen=True)
class RequestMetadata:
    """Per-request metadata passed to :class:`ContextFactory`.

    Populated by the SDK before invoking the factory. Stable across the
    MCP and A2A transports — factories written against this shape work
    on both sides. Additional fields may be added in minor releases;
    factories should keep accepting ``RequestMetadata`` and pluck the
    fields they need by name, not by positional unpacking.

    :param tool_name: The AdCP operation being invoked (e.g.
        ``"get_products"``, ``"create_media_buy"``). Useful for
        tool-level audit logging and feature flagging.
    :param transport: ``"mcp"`` or ``"a2a"`` — the wire protocol
        currently dispatching this call. Agents that expose both can
        use this to branch on transport-specific behavior.
    :param request_id: The transport-assigned request id when one
        exists (A2A populates this from the task id; MCP leaves it
        ``None`` at the SDK layer today).
    :param request_context: The originating Starlette ``Request`` for
        HTTP-borne calls (MCP streamable-http, A2A). ``None`` for
        stdio MCP and any path that doesn't have a Request to thread.
        Use ``request_context.state`` to read per-request state set
        by ASGI middleware — this works in both stateless and stateful
        MCP modes, where the older :mod:`contextvars` pattern only
        works in stateless (the stateful session task is a separate
        async task and does not see middleware-set ContextVars).
        Typed as ``Any`` to keep this dataclass dependency-light;
        adopters can ``cast(Request, meta.request_context)``.
    """

    tool_name: str
    transport: Literal["mcp", "a2a"]
    request_id: str | None = None
    request_context: Any = None

Per-request metadata passed to :class:ContextFactory.

Populated by the SDK before invoking the factory. Stable across the MCP and A2A transports — factories written against this shape work on both sides. Additional fields may be added in minor releases; factories should keep accepting RequestMetadata and pluck the fields they need by name, not by positional unpacking.

:param tool_name: The AdCP operation being invoked (e.g. "get_products", "create_media_buy"). Useful for tool-level audit logging and feature flagging. :param transport: "mcp" or "a2a" — the wire protocol currently dispatching this call. Agents that expose both can use this to branch on transport-specific behavior. :param request_id: The transport-assigned request id when one exists (A2A populates this from the task id; MCP leaves it None at the SDK layer today). :param request_context: The originating Starlette Request for HTTP-borne calls (MCP streamable-http, A2A). None for stdio MCP and any path that doesn't have a Request to thread. Use request_context.state to read per-request state set by ASGI middleware — this works in both stateless and stateful MCP modes, where the older :mod:contextvars pattern only works in stateless (the stateful session task is a separate async task and does not see middleware-set ContextVars). Typed as Any to keep this dataclass dependency-light; adopters can cast(Request, meta.request_context).

Instance variables

var request_context : Any
var request_id : str | None
var tool_name : str
var transport : Literal['mcp', 'a2a']
class ServeConfig (name: str = 'adcp-agent',
port: int | None = None,
host: str | None = None,
transport: str = 'streamable-http',
instructions: str | None = None,
streaming_responses: bool = False,
stateless_http: bool = False,
session_idle_timeout: float | None = 1800.0,
max_active_sessions: int | None = None,
task_store: TaskStore | None = None,
push_config_store: PushNotificationConfigStore | None = None,
message_parser: MessageParser | None = None,
public_url: str | PublicUrlResolver | None = None,
test_controller: TestControllerStore | None = None,
context_factory: ContextFactory | None = None,
middleware: Sequence[SkillMiddleware] | None = None,
asgi_middleware: Sequence[tuple[type, dict[str, Any]]] | None = None,
advertise_all: bool = False,
max_request_size: int | None = None,
validation: ValidationHookConfig | None = None,
pre_validation_hooks: PreValidationHooks | None = None,
response_enhancer: ResponseEnhancer | None = None,
base_url: str | None = None,
specialisms: list[str] | None = None,
description: str | None = None,
on_startup: Sequence[LifespanHook] | None = None,
on_shutdown: Sequence[LifespanHook] | None = None,
enable_debug_endpoints: bool = False,
debug_traffic_source: Callable[[], dict[str, int]] | None = None,
session_count_source: Callable[[], dict[str, Any]] | None = None,
debug_validate_request: Callable[[dict[str, str]], bool] | None = None,
debug_public: bool = False)
Expand source code
@dataclass(frozen=True)
class ServeConfig:
    """Configuration bundle for :func:`serve`.

    Consolidates the 22 keyword arguments of :func:`serve` into a single
    named, IDE-friendly object.  Use either the bundled form or individual
    kwargs — not both::

        # Bundled (cleaner IDE signature, easy to share / reuse)
        serve(MyAgent(), config=ServeConfig(name="my-agent", transport="a2a"))

        # Individual kwargs (backwards-compatible, unchanged)
        serve(MyAgent(), name="my-agent", transport="a2a")

    When *config* is supplied, all field values come from it; any individual
    kwargs passed alongside are ignored.  To vary a single field from a
    shared base config use :func:`dataclasses.replace`::

        base = ServeConfig(name="my-agent", validation=strict)
        serve(handler, config=dataclasses.replace(base, transport="a2a"))

    **Transport-specific fields** — fields marked *(A2A only)* or
    *(MCP only)* are silently ignored by the other transport.  Setting
    cross-transport fields triggers a ``UserWarning`` at boot.
    """

    # --- Identity / networking ---
    name: str = "adcp-agent"
    port: int | None = None
    host: str | None = None
    transport: str = "streamable-http"

    # --- MCP only ---
    instructions: str | None = None
    streaming_responses: bool = False
    stateless_http: bool = False
    session_idle_timeout: float | None = 1800.0
    max_active_sessions: int | None = None

    # --- A2A / both ---
    task_store: TaskStore | None = None
    push_config_store: PushNotificationConfigStore | None = None
    message_parser: MessageParser | None = None
    public_url: str | PublicUrlResolver | None = None

    # --- Shared infrastructure ---
    test_controller: TestControllerStore | None = None
    context_factory: ContextFactory | None = None
    middleware: Sequence[SkillMiddleware] | None = None
    asgi_middleware: Sequence[tuple[type, dict[str, Any]]] | None = None
    advertise_all: bool = False
    max_request_size: int | None = None
    validation: ValidationHookConfig | None = None
    pre_validation_hooks: PreValidationHooks | None = None
    response_enhancer: ResponseEnhancer | None = None

    # --- Discovery manifest ---
    base_url: str | None = None
    specialisms: list[str] | None = None
    description: str | None = None

    # --- Lifespan hooks (transport="both" only today) ---
    on_startup: Sequence[LifespanHook] | None = None
    on_shutdown: Sequence[LifespanHook] | None = None

    # --- Debug endpoints ---
    enable_debug_endpoints: bool = False
    debug_traffic_source: Callable[[], dict[str, int]] | None = None
    session_count_source: Callable[[], dict[str, Any]] | None = None
    debug_validate_request: Callable[[dict[str, str]], bool] | None = None
    debug_public: bool = False

    def __post_init__(self) -> None:
        _a2a_only = ("task_store", "push_config_store", "message_parser", "public_url")
        # ``session_idle_timeout`` (default 1800.0) is excluded from
        # the warning list: the ``not in (None, False)`` heuristic
        # treats any non-falsy default as "set" and would fire
        # spuriously under transport='a2a'. ``stateless_http`` (default
        # False) and ``streaming_responses`` (default False) work
        # cleanly with the heuristic.
        _mcp_only = (
            "instructions",
            "streaming_responses",
            "stateless_http",
            "max_active_sessions",
        )
        if self.transport == "a2a":
            mcp_set = sorted(f for f in _mcp_only if getattr(self, f) not in (None, False))
            if mcp_set:
                warnings.warn(
                    f"ServeConfig sets MCP-only fields {mcp_set} but "
                    f"transport='a2a'. These fields will be ignored.",
                    UserWarning,
                    stacklevel=3,
                )
        elif self.transport not in ("both", "streamable-http", "sse", "stdio"):
            pass  # unknown transport — let serve() raise a clear error
        elif self.transport not in ("a2a", "both"):
            a2a_set = sorted(f for f in _a2a_only if getattr(self, f) is not None)
            if a2a_set:
                warnings.warn(
                    f"ServeConfig sets A2A-only fields {a2a_set} but "
                    f"transport={self.transport!r}. These fields will be ignored.",
                    UserWarning,
                    stacklevel=3,
                )

Configuration bundle for :func:serve().

Consolidates the 22 keyword arguments of :func:serve() into a single named, IDE-friendly object. Use either the bundled form or individual kwargs — not both::

# Bundled (cleaner IDE signature, easy to share / reuse)
serve(MyAgent(), config=ServeConfig(name="my-agent", transport="a2a"))

# Individual kwargs (backwards-compatible, unchanged)
serve(MyAgent(), name="my-agent", transport="a2a")

When config is supplied, all field values come from it; any individual kwargs passed alongside are ignored. To vary a single field from a shared base config use :func:dataclasses.replace::

base = ServeConfig(name="my-agent", validation=strict)
serve(handler, config=dataclasses.replace(base, transport="a2a"))

Transport-specific fields — fields marked (A2A only) or (MCP only) are silently ignored by the other transport. Setting cross-transport fields triggers a UserWarning at boot.

Instance variables

var advertise_all : bool
var asgi_middleware : Sequence[tuple[type, dict[str, Any]]] | None
var base_url : str | None
var context_factory : ContextFactory | None
var debug_public : bool
var debug_traffic_source : Callable[[], dict[str, int]] | None
var debug_validate_request : Callable[[dict[str, str]], bool] | None
var description : str | None
var enable_debug_endpoints : bool
var host : str | None
var instructions : str | None
var max_active_sessions : int | None
var max_request_size : int | None
var message_parser : MessageParser | None
var middleware : Sequence[SkillMiddleware] | None
var name : str
var on_shutdown : Sequence[LifespanHook] | None
var on_startup : Sequence[LifespanHook] | None
var port : int | None
var pre_validation_hooks : PreValidationHooks | None
var public_url : str | PublicUrlResolver | None
var push_config_store : PushNotificationConfigStore | None
var response_enhancer : ResponseEnhancer | None
var session_count_source : Callable[[], dict[str, Any]] | None
var session_idle_timeout : float | None
var specialisms : list[str] | None
var stateless_http : bool
var streaming_responses : bool
var task_store : TaskStore | None
var test_controllerTestControllerStore | None
var transport : str
var validation : ValidationHookConfig | None
class SponsoredIntelligenceHandler
Expand source code
class SponsoredIntelligenceHandler(ADCPHandler[TContext], Generic[TContext]):
    """Handler for Sponsored Intelligence protocol.

    Subclass this to implement a Sponsored Intelligence agent. All SI
    operations must be implemented via the handle_* methods.
    The public methods (si_get_offering, etc.) handle validation and
    error handling automatically.

    Non-SI operations (get_products, create_media_buy, content standards, etc.)
    return 'not supported' via the base class.

    Example:
        class MySIHandler(SponsoredIntelligenceHandler):
            async def handle_si_get_offering(
                self,
                request: SiGetOfferingRequest,
                context: TContext | None = None
            ) -> SiGetOfferingResponse:
                # Your implementation
                return SiGetOfferingResponse(...)
    """

    _agent_type: str = "Sponsored Intelligence agents"

    # ========================================================================
    # Sponsored Intelligence Operations - Override base class with validation
    # ========================================================================

    async def si_get_offering(
        self,
        params: SiGetOfferingRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> SiGetOfferingResponse | NotImplementedResponse:
        """Get sponsored intelligence offering.

        Validates params and delegates to handle_si_get_offering.
        """
        try:
            request = SiGetOfferingRequest.model_validate(params)
        except ValidationError as e:
            return NotImplementedResponse(
                supported=False,
                reason=f"Invalid request: {e}",
                error=Error(code="VALIDATION_ERROR", message=str(e)),
            )
        return await self.handle_si_get_offering(request, context)

    async def si_initiate_session(
        self,
        params: SiInitiateSessionRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> SiInitiateSessionResponse | NotImplementedResponse:
        """Initiate sponsored intelligence session.

        Validates params and delegates to handle_si_initiate_session.
        """
        try:
            request = SiInitiateSessionRequest.model_validate(params)
        except ValidationError as e:
            return NotImplementedResponse(
                supported=False,
                reason=f"Invalid request: {e}",
                error=Error(code="VALIDATION_ERROR", message=str(e)),
            )
        return await self.handle_si_initiate_session(request, context)

    async def si_send_message(
        self,
        params: SiSendMessageRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> SiSendMessageResponse | NotImplementedResponse:
        """Send message in sponsored intelligence session.

        Validates params and delegates to handle_si_send_message.
        """
        try:
            request = SiSendMessageRequest.model_validate(params)
        except ValidationError as e:
            return NotImplementedResponse(
                supported=False,
                reason=f"Invalid request: {e}",
                error=Error(code="VALIDATION_ERROR", message=str(e)),
            )
        return await self.handle_si_send_message(request, context)

    async def si_terminate_session(
        self,
        params: SiTerminateSessionRequest | dict[str, Any],
        context: TContext | None = None,
    ) -> SiTerminateSessionResponse | NotImplementedResponse:
        """Terminate sponsored intelligence session.

        Validates params and delegates to handle_si_terminate_session.
        """
        try:
            request = SiTerminateSessionRequest.model_validate(params)
        except ValidationError as e:
            return NotImplementedResponse(
                supported=False,
                reason=f"Invalid request: {e}",
                error=Error(code="VALIDATION_ERROR", message=str(e)),
            )
        return await self.handle_si_terminate_session(request, context)

    # ========================================================================
    # Abstract handlers - Implement these in subclasses
    # ========================================================================

    @abstractmethod
    async def handle_si_get_offering(
        self,
        request: SiGetOfferingRequest,
        context: TContext | None = None,
    ) -> SiGetOfferingResponse:
        """Handle get offering request."""
        ...

    @abstractmethod
    async def handle_si_initiate_session(
        self,
        request: SiInitiateSessionRequest,
        context: TContext | None = None,
    ) -> SiInitiateSessionResponse:
        """Handle initiate session request."""
        ...

    @abstractmethod
    async def handle_si_send_message(
        self,
        request: SiSendMessageRequest,
        context: TContext | None = None,
    ) -> SiSendMessageResponse:
        """Handle send message request."""
        ...

    @abstractmethod
    async def handle_si_terminate_session(
        self,
        request: SiTerminateSessionRequest,
        context: TContext | None = None,
    ) -> SiTerminateSessionResponse:
        """Handle terminate session request."""
        ...

Handler for Sponsored Intelligence protocol.

Subclass this to implement a Sponsored Intelligence agent. All SI operations must be implemented via the handle_* methods. The public methods (si_get_offering, etc.) handle validation and error handling automatically.

Non-SI operations (get_products, create_media_buy, content standards, etc.) return 'not supported' via the base class.

Example

class MySIHandler(SponsoredIntelligenceHandler): async def handle_si_get_offering( self, request: SiGetOfferingRequest, context: TContext | None = None ) -> SiGetOfferingResponse: # Your implementation return SiGetOfferingResponse(…)

Ancestors

Methods

async def handle_si_get_offering(self, request: SiGetOfferingRequest, context: TContext | None = None) ‑> adcp.types.generated_poc.sponsored_intelligence.si_get_offering_response.SiGetOfferingResponse
Expand source code
@abstractmethod
async def handle_si_get_offering(
    self,
    request: SiGetOfferingRequest,
    context: TContext | None = None,
) -> SiGetOfferingResponse:
    """Handle get offering request."""
    ...

Handle get offering request.

async def handle_si_initiate_session(self, request: SiInitiateSessionRequest, context: TContext | None = None) ‑> adcp.types.generated_poc.sponsored_intelligence.si_initiate_session_response.SiInitiateSessionResponse
Expand source code
@abstractmethod
async def handle_si_initiate_session(
    self,
    request: SiInitiateSessionRequest,
    context: TContext | None = None,
) -> SiInitiateSessionResponse:
    """Handle initiate session request."""
    ...

Handle initiate session request.

async def handle_si_send_message(self, request: SiSendMessageRequest, context: TContext | None = None) ‑> adcp.types.generated_poc.sponsored_intelligence.si_send_message_response.SiSendMessageResponse
Expand source code
@abstractmethod
async def handle_si_send_message(
    self,
    request: SiSendMessageRequest,
    context: TContext | None = None,
) -> SiSendMessageResponse:
    """Handle send message request."""
    ...

Handle send message request.

async def handle_si_terminate_session(self, request: SiTerminateSessionRequest, context: TContext | None = None) ‑> adcp.types.generated_poc.sponsored_intelligence.si_terminate_session_response.SiTerminateSessionResponse
Expand source code
@abstractmethod
async def handle_si_terminate_session(
    self,
    request: SiTerminateSessionRequest,
    context: TContext | None = None,
) -> SiTerminateSessionResponse:
    """Handle terminate session request."""
    ...

Handle terminate session request.

async def si_get_offering(self,
params: SiGetOfferingRequest | dict[str, Any],
context: TContext | None = None) ‑> adcp.types.generated_poc.sponsored_intelligence.si_get_offering_response.SiGetOfferingResponse | NotImplementedResponse
Expand source code
async def si_get_offering(
    self,
    params: SiGetOfferingRequest | dict[str, Any],
    context: TContext | None = None,
) -> SiGetOfferingResponse | NotImplementedResponse:
    """Get sponsored intelligence offering.

    Validates params and delegates to handle_si_get_offering.
    """
    try:
        request = SiGetOfferingRequest.model_validate(params)
    except ValidationError as e:
        return NotImplementedResponse(
            supported=False,
            reason=f"Invalid request: {e}",
            error=Error(code="VALIDATION_ERROR", message=str(e)),
        )
    return await self.handle_si_get_offering(request, context)

Get sponsored intelligence offering.

Validates params and delegates to handle_si_get_offering.

async def si_initiate_session(self,
params: SiInitiateSessionRequest | dict[str, Any],
context: TContext | None = None) ‑> adcp.types.generated_poc.sponsored_intelligence.si_initiate_session_response.SiInitiateSessionResponse | NotImplementedResponse
Expand source code
async def si_initiate_session(
    self,
    params: SiInitiateSessionRequest | dict[str, Any],
    context: TContext | None = None,
) -> SiInitiateSessionResponse | NotImplementedResponse:
    """Initiate sponsored intelligence session.

    Validates params and delegates to handle_si_initiate_session.
    """
    try:
        request = SiInitiateSessionRequest.model_validate(params)
    except ValidationError as e:
        return NotImplementedResponse(
            supported=False,
            reason=f"Invalid request: {e}",
            error=Error(code="VALIDATION_ERROR", message=str(e)),
        )
    return await self.handle_si_initiate_session(request, context)

Initiate sponsored intelligence session.

Validates params and delegates to handle_si_initiate_session.

async def si_send_message(self,
params: SiSendMessageRequest | dict[str, Any],
context: TContext | None = None) ‑> adcp.types.generated_poc.sponsored_intelligence.si_send_message_response.SiSendMessageResponse | NotImplementedResponse
Expand source code
async def si_send_message(
    self,
    params: SiSendMessageRequest | dict[str, Any],
    context: TContext | None = None,
) -> SiSendMessageResponse | NotImplementedResponse:
    """Send message in sponsored intelligence session.

    Validates params and delegates to handle_si_send_message.
    """
    try:
        request = SiSendMessageRequest.model_validate(params)
    except ValidationError as e:
        return NotImplementedResponse(
            supported=False,
            reason=f"Invalid request: {e}",
            error=Error(code="VALIDATION_ERROR", message=str(e)),
        )
    return await self.handle_si_send_message(request, context)

Send message in sponsored intelligence session.

Validates params and delegates to handle_si_send_message.

async def si_terminate_session(self,
params: SiTerminateSessionRequest | dict[str, Any],
context: TContext | None = None) ‑> adcp.types.generated_poc.sponsored_intelligence.si_terminate_session_response.SiTerminateSessionResponse | NotImplementedResponse
Expand source code
async def si_terminate_session(
    self,
    params: SiTerminateSessionRequest | dict[str, Any],
    context: TContext | None = None,
) -> SiTerminateSessionResponse | NotImplementedResponse:
    """Terminate sponsored intelligence session.

    Validates params and delegates to handle_si_terminate_session.
    """
    try:
        request = SiTerminateSessionRequest.model_validate(params)
    except ValidationError as e:
        return NotImplementedResponse(
            supported=False,
            reason=f"Invalid request: {e}",
            error=Error(code="VALIDATION_ERROR", message=str(e)),
        )
    return await self.handle_si_terminate_session(request, context)

Terminate sponsored intelligence session.

Validates params and delegates to handle_si_terminate_session.

Inherited members

class SubdomainTenantMiddleware (app: ASGIApp,
*,
router: SubdomainTenantRouter,
excluded_paths: frozenset[str] = frozenset())
Expand source code
class SubdomainTenantMiddleware:
    """Starlette ASGI middleware: ``Host`` header → :class:`Tenant`.

    Wire via ``app.add_middleware(SubdomainTenantMiddleware,
    router=...)``. The middleware:

    1. Reads the ``Host`` header from the ASGI scope.
    2. Calls the router's ``resolve()`` method.
    3. On hit, sets the :data:`current_tenant` contextvar for the
       remainder of the request's lifetime.
    4. On miss, returns ``404 Not Found`` immediately — the wrapped
       app is never called.

    Non-HTTP scopes (websocket, lifespan) pass through unchanged
    so the middleware is safe on the standard Starlette stack.
    """

    def __init__(
        self,
        app: ASGIApp,
        *,
        router: SubdomainTenantRouter,
        excluded_paths: frozenset[str] = frozenset(),
    ) -> None:
        """Construct the middleware.

        :param app: The wrapped ASGI app (the next layer).
        :param router: The :class:`SubdomainTenantRouter` impl.
        :param excluded_paths: HTTP paths that bypass tenant routing
            entirely — typically ``{"/healthz", "/readyz"}``.
            Requests to these paths skip the router call and the
            contextvar set; downstream code sees
            :func:`current_tenant` returning ``None``.
        """
        self._app = app
        self._router = router
        self._excluded = excluded_paths

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        if scope["type"] != "http":
            await self._app(scope, receive, send)
            return

        path = scope.get("path", "")
        if path in self._excluded:
            await self._app(scope, receive, send)
            return

        host = _extract_host_header(scope)
        if host is None:
            await _send_404(send, reason="missing-host-header")
            return

        tenant = await self._router.resolve(host)
        if tenant is None:
            await _send_404(send, reason="unknown-host")
            return

        token = _current_tenant.set(tenant)
        try:
            await self._app(scope, receive, send)
        finally:
            _current_tenant.reset(token)

Starlette ASGI middleware: Host header → :class:Tenant.

Wire via app.add_middleware(SubdomainTenantMiddleware, router=...). The middleware:

  1. Reads the Host header from the ASGI scope.
  2. Calls the router's resolve() method.
  3. On hit, sets the :data:current_tenant() contextvar for the remainder of the request's lifetime.
  4. On miss, returns 404 Not Found immediately — the wrapped app is never called.

Non-HTTP scopes (websocket, lifespan) pass through unchanged so the middleware is safe on the standard Starlette stack.

Construct the middleware.

:param app: The wrapped ASGI app (the next layer). :param router: The :class:SubdomainTenantRouter impl. :param excluded_paths: HTTP paths that bypass tenant routing entirely — typically {"/healthz", "/readyz"}. Requests to these paths skip the router call and the contextvar set; downstream code sees :func:current_tenant() returning None.

class SubdomainTenantRouter (*args, **kwargs)
Expand source code
@runtime_checkable
class SubdomainTenantRouter(Protocol):
    """Resolves an HTTP ``Host`` header value to a :class:`Tenant`.

    Adopters back this Protocol with their tenant table — typically
    a SQL query against the deployment's tenant registry. The
    middleware calls :meth:`resolve` once per request; production
    adopters cache hot lookups in the impl since the host header is
    request-scoped.

    Returning ``None`` causes the middleware to ``404`` the
    request — unknown hosts MUST NOT pass through.
    """

    async def resolve(self, host: str) -> Tenant | None:
        """Return the :class:`Tenant` for ``host`` or ``None`` to 404.

        ``host`` is the raw ``Host`` header value (lower-cased by
        the middleware before this call). Implementations strip any
        ``:port`` suffix as needed; the middleware doesn't.
        """
        ...

Resolves an HTTP Host header value to a :class:Tenant.

Adopters back this Protocol with their tenant table — typically a SQL query against the deployment's tenant registry. The middleware calls :meth:resolve once per request; production adopters cache hot lookups in the impl since the host header is request-scoped.

Returning None causes the middleware to 404 the request — unknown hosts MUST NOT pass through.

Ancestors

  • typing.Protocol
  • typing.Generic

Methods

async def resolve(self, host: str) ‑> Tenant | None
Expand source code
async def resolve(self, host: str) -> Tenant | None:
    """Return the :class:`Tenant` for ``host`` or ``None`` to 404.

    ``host`` is the raw ``Host`` header value (lower-cased by
    the middleware before this call). Implementations strip any
    ``:port`` suffix as needed; the middleware doesn't.
    """
    ...

Return the :class:Tenant for host or None to 404.

host is the raw Host header value (lower-cased by the middleware before this call). Implementations strip any :port suffix as needed; the middleware doesn't.

class SyncTokenValidator (*args, **kwargs)
Expand source code
class SyncTokenValidator(Protocol):
    """Synchronous token validator — ``def validate_token(token) -> Principal | None``."""

    def __call__(self, token: str) -> Principal | None: ...

Synchronous token validator — def validate_token(token) -> Principal | None.

Ancestors

  • typing.Protocol
  • typing.Generic
class Tenant (id: str, display_name: str = '', ext: Mapping[str, Any] = <factory>)
Expand source code
@dataclass(frozen=True)
class Tenant:
    """The resolved tenant for a request.

    Frozen — the middleware caches resolved tenants in a contextvar
    that's read by downstream stores; mutation in-place would create
    cross-store inconsistency.

    :param id: Stable tenant identifier. Used as
        :attr:`ToolContext.tenant_id` and the scope key for
        per-tenant DB queries / cache scoping.
    :param display_name: Human-readable name for logging and admin
        UIs. Not used for routing.
    :param ext: Adopter passthrough — DB shard pointer, billing
        entity FK, locale, sandbox flag, etc.
    """

    id: str
    display_name: str = ""
    ext: Mapping[str, Any] = field(default_factory=dict)

The resolved tenant for a request.

Frozen — the middleware caches resolved tenants in a contextvar that's read by downstream stores; mutation in-place would create cross-store inconsistency.

:param id: Stable tenant identifier. Used as :attr:ToolContext.tenant_id and the scope key for per-tenant DB queries / cache scoping. :param display_name: Human-readable name for logging and admin UIs. Not used for routing. :param ext: Adopter passthrough — DB shard pointer, billing entity FK, locale, sandbox flag, etc.

Instance variables

var display_name : str
var ext : Mapping[str, Any]
var id : str
class TenantRegistry (*,
validator: TenantValidator | None = None,
default_serve_options: dict[str, Any] | None = None)
Expand source code
class TenantRegistry:
    """Higher-level multi-tenant primitive with health tracking.

    Mirrors JS SDK ``createTenantRegistry`` for Python deployments.
    Supports two registration modes:

    * **Eager** (:meth:`register`) — caller pre-builds the
      :class:`~adcp.decisioning.DecisioningPlatform` and passes it in.
      :meth:`resolve_by_host` (sync) and :meth:`resolve` (async) both
      return a resolution immediately.
    * **Lazy** (:meth:`register_lazy`) — caller supplies a factory
      callable; the platform is built on the first :meth:`resolve` call
      and cached. Avoids paying per-tenant construction costs (network
      handshakes, KMS credential fetches) at boot. Suitable for
      deployments with many tenants.

    **Health states:**

    * ``pending`` — registered, not yet validated (or lazy factory not
      yet invoked). Adopters should 503 traffic until validation
      completes.
    * ``healthy`` — validated and serving.
    * ``unverified`` — was healthy; a subsequent :meth:`recheck` failed
      (transient failure). The tenant still serves (graceful-degrade).
    * ``disabled`` — persistent failure. 503 until an operator calls
      :meth:`recheck` and validation succeeds.

    **Validator:** Optional callable ``(tenant_id, agent_url) -> bool``.
    Pass a JWKS health-check, a connectivity probe, or any custom
    validation logic. Adopters using principal-token bearer auth (no
    JWKS) pass ``None`` — validation always succeeds immediately so
    ``await_first_validation=True`` transitions the tenant to
    ``healthy`` without a network round-trip.

    **Per-tenant locks:** Each tenant gets an ``asyncio.Lock`` on first
    use. Locks are removed when the tenant is unregistered. Any
    in-flight :meth:`recheck` or :meth:`resolve` that held the lock
    before ``unregister()`` was called completes safely — zombie-entry
    guards in both methods prevent stale writes after removal.

    **Do not pass a TenantRegistry as a SubdomainTenantRouter.**
    Both classes expose ``async def resolve(host)``, but the return types
    are incompatible (:class:`TenantResolution` vs :class:`Tenant`).
    Mypy will flag the mismatch; duck-typing and ``isinstance`` checks
    will not.

    :param validator: Optional validation callable (sync or async).
        ``None`` → principal-token mode; validation always succeeds.
    :param default_serve_options: Optional dict of defaults to store for
        adopter convenience. Retrieve via :attr:`serve_options`.

    Example (using :meth:`as_platform` — recommended path for
    :func:`~adcp.decisioning.serve` integration)::

        from adcp.server import TenantRegistry
        from adcp.decisioning import serve

        registry = TenantRegistry(validator=check_jwks)

        for tenant in load_tenants_from_db():
            # await_first_validation=True pre-warms tenants at boot so the
            # first request doesn't see health='pending'.
            await registry.register_lazy(
                tenant.id,
                agent_url=tenant.agent_url,
                factory=build_platform_for_tenant,
                await_first_validation=True,
            )

        # Returns a DecisioningPlatform that routes per-request via
        # ctx.tenant_id (set from the Host header by SubdomainTenantMiddleware).
        serve(registry.as_platform(accounts=my_account_store), port=8080)

    Example (escape-hatch — manual resolve() when you need custom dispatch)::

        from adcp.server import TenantRegistry

        registry = TenantRegistry(validator=None)

        for tenant in load_tenants_from_db():
            await registry.register(
                tenant.id,
                agent_url=tenant.agent_url,
                platform=build_platform_for(tenant),
                await_first_validation=True,
            )

        async def resolve(ctx):
            # Use resolve_by_id when tenant_id is already known (e.g. from
            # ctx.tenant_id); use resolve(host) for host-based lookup.
            resolved = await registry.resolve_by_id(ctx.tenant_id)
            if resolved is None or resolved.health in ("pending", "disabled"):
                raise HTTPException(503)
            return resolved.platform

    Example (runtime admin operations)::

        # Hot-add a newly onboarded tenant
        await registry.register(new_id, agent_url=..., platform=...)

        # Remove a deactivated tenant
        registry.unregister(old_id)

        # Re-validate after key rotation
        await registry.recheck(rotated_id)
        status = registry.health(rotated_id)
    """

    def __init__(
        self,
        *,
        validator: TenantValidator | None = None,
        default_serve_options: dict[str, Any] | None = None,
    ) -> None:
        self._validator = validator
        self._default_serve_options: dict[str, Any] = default_serve_options or {}
        # Per-tenant health state.
        self._health: dict[str, TenantHealthState] = {}
        # Per-tenant DecisioningPlatform. Annotation uses TYPE_CHECKING import;
        # safe because from __future__ import annotations makes it a lazy string.
        self._platforms: dict[str, DecisioningPlatform] = {}
        # Per-tenant agent_url — used to derive + update host_map entries.
        self._agent_urls: dict[str, str] = {}
        # Normalized host → tenant_id for O(1) resolve_by_host lookups.
        self._host_map: dict[str, str] = {}
        # Per-tenant asyncio.Lock for TOCTOU-safe state transitions.
        # State mutations (register, recheck) read, await I/O, then write;
        # without a lock two concurrent rechecks for the same tenant could
        # both read, both await, and both commit — racing on the final state.
        self._locks: dict[str, asyncio.Lock] = {}
        # Per-tenant lazy platform factory. Set by register_lazy(); absent
        # for tenants registered eagerly via register().
        self._factories: dict[str, PlatformFactory] = {}

    # ----- internal helpers ------------------------------------------------

    def _get_lock(self, tenant_id: str) -> asyncio.Lock:
        # No await between the check and the insertion, so this is safe
        # under asyncio cooperative scheduling (single event loop thread).
        if tenant_id not in self._locks:
            self._locks[tenant_id] = asyncio.Lock()
        return self._locks[tenant_id]

    @staticmethod
    def _normalize_host(raw: str) -> str:
        """Lower-case and strip any port suffix from a host or URL.

        Accepts both full URLs (``https://acme.example.com``) and raw
        Host-header values (``acme.example.com``, ``acme.example.com:443``).

        Note: port stripping is correct for ``Host`` headers where the port
        matches the scheme default. Some load-balancers forward
        ``X-Forwarded-Host`` with non-default ports preserved; callers
        using that header should strip the port themselves before passing
        the value to :meth:`resolve_by_host` or :meth:`resolve`.
        """
        if "://" in raw:
            host = urlparse(raw).netloc or raw
        else:
            host = raw
        if ":" in host:
            host = host.rsplit(":", 1)[0]
        return host.lower()

    async def _run_validator(self, tenant_id: str) -> bool:
        """Invoke the configured validator; return True when valid."""
        if self._validator is None:
            return True
        agent_url = self._agent_urls.get(tenant_id, "")
        result = self._validator(tenant_id, agent_url)
        if inspect.isawaitable(result):
            result = await result
        return bool(result)

    # ----- public API ------------------------------------------------------

    async def register(
        self,
        tenant_id: str,
        *,
        agent_url: str,
        platform: DecisioningPlatform,
        await_first_validation: bool = False,
    ) -> None:
        """Register a tenant.

        Health starts as ``pending``. When ``await_first_validation=True``
        the coroutine suspends until the validator resolves, then
        transitions to ``healthy`` or ``disabled`` before returning — the
        next :meth:`resolve_by_host` call sees the final state.

        Re-registering an existing tenant atomically replaces its platform
        and agent_url under the per-tenant lock. The old host-map entry is
        removed if the URL changed.

        :param tenant_id: Stable identifier (e.g. DB primary key).
        :param agent_url: The tenant's agent endpoint URL. The host
            component is extracted and used as the key for
            :meth:`resolve_by_host` lookups.
        :param platform: Pre-built
            :class:`~adcp.decisioning.DecisioningPlatform` for this tenant.
        :param await_first_validation: When ``True``, suspends the caller
            until validation completes (not "blocks the event loop" — the
            coroutine yields cooperatively while awaiting I/O). Useful at
            boot so the first incoming request doesn't race the validation
            roundtrip. The typical ``False`` default is correct for
            background hot-add where traffic is gated on ``health != 'pending'``.
        """
        lock = self._get_lock(tenant_id)
        async with lock:
            # Remove stale host-map entry when the URL changes.
            old_url = self._agent_urls.get(tenant_id)
            if old_url is not None and old_url != agent_url:
                self._host_map.pop(self._normalize_host(old_url), None)

            self._platforms[tenant_id] = platform
            self._agent_urls[tenant_id] = agent_url
            self._host_map[self._normalize_host(agent_url)] = tenant_id
            self._health[tenant_id] = "pending"
            # Clear any lazy factory if re-registering as eager.
            self._factories.pop(tenant_id, None)

            if await_first_validation:
                try:
                    ok = await self._run_validator(tenant_id)
                except Exception:
                    logger.warning(
                        "TenantRegistry.register: validator raised for tenant %r; "
                        "health=disabled",
                        tenant_id,
                        exc_info=True,
                    )
                    self._health[tenant_id] = "disabled"
                    return
                self._health[tenant_id] = "healthy" if ok else "disabled"

    async def register_lazy(
        self,
        tenant_id: str,
        *,
        agent_url: str,
        factory: PlatformFactory,
        await_first_validation: bool = False,
    ) -> None:
        """Register a tenant with a lazy platform factory.

        The platform is built on the first :meth:`resolve` call for this
        tenant's host, then cached. Subsequent resolves return the cached
        instance. Suitable for deployments with many tenants where eager
        construction is too expensive at boot — network handshakes, KMS
        credential fetches, inventory-manager construction, etc.

        Health starts as ``pending``. When ``await_first_validation=True``
        the factory is invoked immediately, the platform is built, and
        validation completes before returning — the next :meth:`resolve`
        call sees the final state without triggering the factory again.

        Use :meth:`resolve` (async) to get a :class:`TenantResolution`
        for lazy-registered tenants; the synchronous :meth:`resolve_by_host`
        returns ``None`` until the platform is built.

        Lazy and eager tenants share the same health state machine:
        :meth:`health`, :meth:`unregister`, :meth:`recheck`, and
        :attr:`registered_tenants` work identically regardless of
        registration mode.

        Re-registering an existing tenant (eager or lazy) atomically
        replaces its factory and agent_url under the per-tenant lock.

        :param tenant_id: Stable identifier (e.g. DB primary key).
        :param agent_url: The tenant's agent endpoint URL. The host
            component is extracted for :meth:`resolve` / :meth:`resolve_by_host`.
        :param factory: Async callable ``(tenant_id) -> DecisioningPlatform``.
            Called at most once per registration (not once per request).
        :param await_first_validation: When ``True``, invokes the factory
            and validator immediately before returning.
        """
        lock = self._get_lock(tenant_id)
        async with lock:
            old_url = self._agent_urls.get(tenant_id)
            if old_url is not None and old_url != agent_url:
                self._host_map.pop(self._normalize_host(old_url), None)

            self._factories[tenant_id] = factory
            # Clear any eagerly-built platform if re-registering as lazy.
            self._platforms.pop(tenant_id, None)
            self._agent_urls[tenant_id] = agent_url
            self._host_map[self._normalize_host(agent_url)] = tenant_id
            self._health[tenant_id] = "pending"

            if await_first_validation:
                try:
                    platform = await factory(tenant_id)
                    ok = await self._run_validator(tenant_id)
                except Exception:
                    logger.warning(
                        "TenantRegistry.register_lazy: factory/validator raised for "
                        "tenant %r; health=disabled",
                        tenant_id,
                        exc_info=True,
                    )
                    self._health[tenant_id] = "disabled"
                    self._factories.pop(tenant_id, None)
                    return
                if ok:
                    self._platforms[tenant_id] = platform
                    self._factories.pop(tenant_id, None)
                    self._health[tenant_id] = "healthy"
                else:
                    # Validator rejected the platform — discard it and clear the
                    # factory to mirror resolve() cold-path behavior: a disabled
                    # lazy tenant needs register_lazy() + recheck() to recover.
                    self._health[tenant_id] = "disabled"
                    self._factories.pop(tenant_id, None)

    def unregister(self, tenant_id: str) -> None:
        """Remove a tenant from the registry.

        Callers that already hold a reference to the tenant's platform
        (e.g. an in-flight request that called :meth:`resolve_by_host`
        before this call) complete normally — the registry does not cancel
        in-flight work. Subsequent :meth:`resolve_by_host` calls for this
        host return ``None``.

        Safe to call when the tenant is not registered (no-op).
        """
        agent_url = self._agent_urls.pop(tenant_id, None)
        if agent_url is not None:
            self._host_map.pop(self._normalize_host(agent_url), None)
        self._platforms.pop(tenant_id, None)
        self._factories.pop(tenant_id, None)
        self._health.pop(tenant_id, None)
        self._locks.pop(tenant_id, None)

    async def recheck(self, tenant_id: str) -> None:
        """Re-validate a tenant after key rotation or config change.

        **State transitions on validator success:** any state → ``healthy``.

        **State transitions on validator failure or exception:**

        * ``healthy`` → ``unverified`` (was serving; graceful-degrade so
          existing traffic keeps flowing while the operator investigates).
        * ``pending`` / ``unverified`` / ``disabled`` → ``disabled``
          (no prior healthy baseline; fail closed).

        The health state is updated before any exception propagates, so
        the state is always consistent even when the validator raises.

        **Lazy-tenant caveats:**

        * For a lazy tenant in ``pending`` state (factory never invoked),
          ``recheck()`` runs the validator against the registered
          ``agent_url`` only. If it succeeds, health advances to
          ``healthy`` — but the platform has not been built yet.
          :meth:`resolve_by_host` still returns ``None``; use the async
          :meth:`resolve` which triggers the factory on first call.
        * For a lazy tenant that reached ``disabled`` via factory failure,
          the factory has been cleared. Calling ``recheck()`` alone is
          insufficient to recover — the validator may succeed but there
          is no platform to serve. To retry platform construction, call
          :meth:`register_lazy` again with the same factory, then call
          :meth:`recheck` if you also need to re-run the validator.

        :raises KeyError: when ``tenant_id`` is not registered.
        :raises Exception: re-raises any exception from the validator
            after updating the health state.
        """
        if tenant_id not in self._health:
            raise KeyError(f"Tenant {tenant_id!r} is not registered")

        lock = self._get_lock(tenant_id)
        async with lock:
            # Re-check inside the lock — unregister may have raced.
            if tenant_id not in self._health:
                raise KeyError(f"Tenant {tenant_id!r} is not registered")
            prior = self._health[tenant_id]
            try:
                ok = await self._run_validator(tenant_id)
            except Exception:
                # Guard: unregister() may have run while we awaited the validator.
                # If so, _health no longer has this tenant — writing back would
                # create a zombie entry visible via health() / registered_tenants.
                if tenant_id not in self._health:
                    return
                self._health[tenant_id] = (
                    "unverified" if prior == "healthy" else "disabled"
                )
                logger.warning(
                    "TenantRegistry.recheck: validator raised for tenant %r; "
                    "health=%s",
                    tenant_id,
                    self._health[tenant_id],
                    exc_info=True,
                )
                raise
            # Same guard for the success path.
            if tenant_id not in self._health:
                return
            if ok:
                self._health[tenant_id] = "healthy"
            else:
                self._health[tenant_id] = (
                    "unverified" if prior == "healthy" else "disabled"
                )

    def health(self, tenant_id: str) -> TenantHealthState | None:
        """Return the current health state for ``tenant_id``.

        Returns ``None`` when the tenant is not registered (distinct from
        any health state value — callers can use ``is None`` to detect
        unknown tenants).
        """
        return self._health.get(tenant_id)

    def resolve_by_host(self, host: str) -> TenantResolution | None:
        """Synchronous lookup by ``Host`` header value.

        Returns ``None`` when no tenant is registered for this host.
        The caller is responsible for checking ``result.health`` and
        gating traffic as appropriate — the registry does not 503
        automatically (health-gating belongs in the adopter's request
        dispatch layer).

        The lookup is synchronous because the registry maintains its own
        in-memory host → tenant mapping (updated eagerly by
        :meth:`register` and :meth:`unregister`). This intentionally
        departs from the JS SDK's async variant, which must call an
        external resolver; the Python registry owns the mapping directly.

        :param host: Raw ``Host`` header value. Port suffixes are stripped
            before lookup; the string may also be a full URL.
        """
        normalized = self._normalize_host(host)
        tenant_id = self._host_map.get(normalized)
        if tenant_id is None:
            return None
        platform = self._platforms.get(tenant_id)
        if platform is None:
            return None
        health = self._health.get(tenant_id, "pending")
        return TenantResolution(tenant_id=tenant_id, health=health, platform=platform)

    async def resolve(self, host: str) -> TenantResolution | None:
        """Async lookup by ``Host`` header value; builds lazy platforms on first hit.

        For eager tenants (registered via :meth:`register`), equivalent to
        :meth:`resolve_by_host` at an async call site — no I/O occurs.

        For lazy tenants (registered via :meth:`register_lazy`), the
        platform factory is invoked on the first call, then cached.
        Concurrent first-hit resolves for the same tenant serialize on
        the per-tenant lock — only one factory invocation occurs.

        Returns ``None`` when no tenant is registered for this host, or when
        a lazy tenant's factory/validator fails on this call (health set to
        ``disabled`` in both cases).

        Returns a :class:`TenantResolution` — which may have
        ``health="disabled"`` — when the platform was already built (eager
        registration, lazy + ``await_first_validation=True``, or a previous
        :meth:`resolve` call). **Always check ``result.health`` before
        serving; never gate solely on ``result is None``.**

        The caller is responsible for gating traffic — the registry does
        not 503 automatically.

        :param host: Raw ``Host`` header value. Port suffixes are stripped;
            full URLs are also accepted. See :meth:`_normalize_host` for
            load-balancer caveats.
        """
        normalized = self._normalize_host(host)
        tenant_id = self._host_map.get(normalized)
        if tenant_id is None:
            return None

        # Fast path: platform already built (eager or previously-resolved lazy).
        platform = self._platforms.get(tenant_id)
        if platform is not None:
            health = self._health.get(tenant_id, "pending")
            return TenantResolution(tenant_id=tenant_id, health=health, platform=platform)

        # If there is no factory either, nothing to do.
        if tenant_id not in self._factories:
            return None

        # Lazy path: acquire per-tenant lock to serialize concurrent first-hit
        # resolves — only one factory invocation per tenant.
        lock = self._get_lock(tenant_id)
        async with lock:
            # Double-check: another coroutine may have built the platform
            # while we waited for the lock.
            platform = self._platforms.get(tenant_id)
            if platform is not None:
                health = self._health.get(tenant_id, "pending")
                return TenantResolution(
                    tenant_id=tenant_id, health=health, platform=platform
                )

            # Guard: unregister() may have run while we waited.
            if tenant_id not in self._health:
                return None

            factory = self._factories.get(tenant_id)
            if factory is None:
                return None

            try:
                platform = await factory(tenant_id)
            except Exception:
                logger.warning(
                    "TenantRegistry.resolve: factory raised for tenant %r; "
                    "health=disabled",
                    tenant_id,
                    exc_info=True,
                )
                if tenant_id in self._health:
                    self._health[tenant_id] = "disabled"
                    # Drop the factory so subsequent resolve() calls don't re-invoke
                    # it — a disabled tenant needs operator intervention via recheck().
                    self._factories.pop(tenant_id, None)
                return None

            try:
                ok = await self._run_validator(tenant_id)
            except Exception:
                logger.warning(
                    "TenantRegistry.resolve: validator raised for tenant %r; "
                    "health=disabled",
                    tenant_id,
                    exc_info=True,
                )
                if tenant_id in self._health:
                    self._health[tenant_id] = "disabled"
                    self._factories.pop(tenant_id, None)
                return None

            # Guard: unregister() may have run while we awaited factory/validator.
            if tenant_id not in self._health:
                return None

            if ok:
                self._platforms[tenant_id] = platform
                self._health[tenant_id] = "healthy"
                # Factory no longer needed — platform is cached in _platforms.
                self._factories.pop(tenant_id, None)
                return TenantResolution(
                    tenant_id=tenant_id, health="healthy", platform=platform
                )
            else:
                self._health[tenant_id] = "disabled"
                self._factories.pop(tenant_id, None)
                return None

    async def resolve_by_id(self, tenant_id: str) -> TenantResolution | None:
        """Async lookup by ``tenant_id``; builds lazy platforms on first hit.

        Equivalent to :meth:`resolve` but accepts a ``tenant_id`` string
        directly instead of a ``Host`` header value. Used by the
        :meth:`as_platform` adapter to resolve per-request platforms keyed
        on ``ctx.tenant_id`` (set by the transport layer from the Host
        header) rather than re-doing the host → tenant_id lookup.

        For eager tenants this is a synchronous in-memory lookup wrapped in
        a coroutine — no I/O occurs. For lazy tenants (registered via
        :meth:`register_lazy`) the factory is invoked on the first call and
        the result is cached, with concurrent first-hit calls serialised on
        the per-tenant lock.

        Returns ``None`` when the tenant is not registered or when a lazy
        tenant's factory or validator fails.

        Returns a :class:`TenantResolution` — which may have any health
        state — when the platform is available. **Always check
        ``result.health`` before serving.**

        :param tenant_id: Stable tenant identifier as registered via
            :meth:`register` or :meth:`register_lazy`.
        """
        if tenant_id not in self._health:
            return None

        # Fast path: platform already built (eager or previously resolved lazy).
        platform = self._platforms.get(tenant_id)
        if platform is not None:
            health = self._health.get(tenant_id, "pending")
            return TenantResolution(tenant_id=tenant_id, health=health, platform=platform)

        # No factory either — lazy tenant that disabled or cleared itself.
        if tenant_id not in self._factories:
            return None

        # Lazy path: mirrors resolve()'s concurrent first-hit serialisation,
        # skipping the host_map lookup since we already have the tenant_id.
        lock = self._get_lock(tenant_id)
        async with lock:
            platform = self._platforms.get(tenant_id)
            if platform is not None:
                health = self._health.get(tenant_id, "pending")
                return TenantResolution(
                    tenant_id=tenant_id, health=health, platform=platform
                )

            if tenant_id not in self._health:
                return None

            factory = self._factories.get(tenant_id)
            if factory is None:
                return None

            try:
                platform = await factory(tenant_id)
            except Exception:
                logger.warning(
                    "TenantRegistry.resolve_by_id: factory raised for tenant %r; "
                    "health=disabled",
                    tenant_id,
                    exc_info=True,
                )
                if tenant_id in self._health:
                    self._health[tenant_id] = "disabled"
                    self._factories.pop(tenant_id, None)
                return None

            try:
                ok = await self._run_validator(tenant_id)
            except Exception:
                logger.warning(
                    "TenantRegistry.resolve_by_id: validator raised for tenant %r; "
                    "health=disabled",
                    tenant_id,
                    exc_info=True,
                )
                if tenant_id in self._health:
                    self._health[tenant_id] = "disabled"
                    self._factories.pop(tenant_id, None)
                return None

            if tenant_id not in self._health:
                return None

            if ok:
                self._platforms[tenant_id] = platform
                self._health[tenant_id] = "healthy"
                self._factories.pop(tenant_id, None)
                return TenantResolution(
                    tenant_id=tenant_id, health="healthy", platform=platform
                )
            else:
                self._health[tenant_id] = "disabled"
                self._factories.pop(tenant_id, None)
                return None

    def as_platform(
        self,
        *,
        accounts: AccountStore[Any],
        capabilities: DecisioningCapabilities | None = None,
        serve_states: frozenset[TenantHealthState] = _DEFAULT_SERVE_STATES,
    ) -> DecisioningPlatform:
        """Return a :class:`~adcp.decisioning.DecisioningPlatform` backed by this registry.

        The returned platform resolves the per-request tenant via
        ``ctx.tenant_id`` (populated by the transport layer from the
        ``Host`` header or URL path), applies health gating, and forwards
        every specialism method call to the resolved tenant's platform.
        Pass it directly to :func:`adcp.decisioning.serve`::

            registry = TenantRegistry(validator=check_jwks)
            for tenant in load_tenants():
                await registry.register_lazy(
                    tenant.id, agent_url=tenant.url, factory=build_platform
                )

            serve(registry.as_platform(accounts=my_account_store), port=8080)

        **Tenant resolution.** The adapter reads ``ctx.tenant_id``, which
        the transport layer sets from the ``Host`` header (via
        :class:`~adcp.server.SubdomainTenantMiddleware`) or your custom
        ``context_factory``. **This value must equal the ``tenant_id``
        string you passed as the first argument to** :meth:`register` **/**
        :meth:`register_lazy`. The host itself (e.g. ``"acme.example.com"``)
        is NOT used — only the registry key (e.g. ``"acme"``).

        If you use :class:`~adcp.server.SubdomainTenantMiddleware`, wire
        ``tenant_id`` in your ``context_factory`` like this::

            from adcp.server import current_tenant

            def context_factory(request):
                t = current_tenant()
                return {"tenant_id": t.id if t else None}

        The ``Tenant.id`` value (from your
        :class:`~adcp.server.SubdomainTenantRouter`) must match the key
        you registered — ``register_lazy("acme", ...)`` requires
        ``Tenant(id="acme", ...)``, not ``Tenant(id="acme.example.com", ...)``.
        A mismatch silently produces ``SERVICE_UNAVAILABLE`` with
        ``health=None`` on every request.

        **Health gating.** By default the adapter serves ``healthy`` and
        ``unverified`` tenants, and raises ``SERVICE_UNAVAILABLE`` for
        ``pending`` and ``disabled`` tenants. Override via
        ``serve_states`` for fail-closed behaviour::

            # Serve only fully-validated tenants (fail-closed):
            registry.as_platform(
                accounts=store,
                serve_states=frozenset({"healthy"}),
            )

        **``accounts`` parameter.** :func:`~adcp.decisioning.serve` validates
        ``platform.accounts`` at boot time before any request arrives. Pass
        the same tenant-aware :class:`~adcp.decisioning.AccountStore` you
        would pass to :class:`~adcp.decisioning.PlatformRouter` — typically
        one that reads ``tenant_id`` from the resolved account's metadata or
        from the transport-layer ``current_tenant`` ContextVar.

        **``capabilities`` parameter.** Should be the union of all tenants'
        specialisms. The adapter cannot introspect child platforms at
        boot time, so the adopter is the source of truth. Defaults to an
        empty :class:`~adcp.decisioning.DecisioningCapabilities` (no
        specialisms advertised) — pass the full union for accurate
        ``tools/list`` projection.

        :param accounts: The :class:`~adcp.decisioning.AccountStore` for the
            returned platform. Required by framework boot-time validation.
        :param capabilities: Capability declaration for the adapter. Defaults
            to an empty :class:`~adcp.decisioning.DecisioningCapabilities`.
        :param serve_states: Health states for which requests proceed.
            Default is ``frozenset({"healthy", "unverified"})``.
        :returns: A :class:`~adcp.decisioning.DecisioningPlatform` suitable
            for passing to :func:`adcp.decisioning.serve`.
        """
        from adcp.decisioning.platform import DecisioningCapabilities as _DecisioningCapabilities
        from adcp.decisioning.platform_router import _make_registry_platform_adapter

        if capabilities is None:
            logger.warning(
                "TenantRegistry.as_platform: no capabilities= passed; tools/list will "
                "advertise no tools. Pass capabilities=DecisioningCapabilities(specialisms=[...]) "
                "for accurate tools/list projection."
            )
        cap = capabilities if capabilities is not None else _DecisioningCapabilities()
        return _make_registry_platform_adapter(
            self,
            accounts=accounts,
            capabilities=cap,
            serve_states=frozenset(serve_states),
        )

    @property
    def serve_options(self) -> dict[str, Any]:
        """The ``default_serve_options`` dict passed at construction.

        Convenience accessor for single-tenant setups or when spreading
        common options into :func:`adcp.decisioning.serve`::

            serve(platform, **registry.serve_options)

        Multi-tenant deployments typically pass a router (not a single
        platform) to ``serve()``; in that case these options are consumed
        by the per-request dispatch layer rather than passed to ``serve``
        directly.

        Returns an empty dict when no options were passed at construction.
        Returns a shallow copy — mutations to the returned dict do not
        affect the registry's stored options.
        """
        return dict(self._default_serve_options)

    @property
    def registered_tenants(self) -> frozenset[str]:
        """Snapshot of the currently registered tenant ids.

        Read-only — mutations to the registry after this property is read
        are not reflected in the returned frozenset.
        """
        return frozenset(self._health)

Higher-level multi-tenant primitive with health tracking.

Mirrors JS SDK createTenantRegistry for Python deployments. Supports two registration modes:

  • Eager (:meth:register) — caller pre-builds the :class:~adcp.decisioning.DecisioningPlatform and passes it in. :meth:resolve_by_host (sync) and :meth:resolve (async) both return a resolution immediately.
  • Lazy (:meth:register_lazy) — caller supplies a factory callable; the platform is built on the first :meth:resolve call and cached. Avoids paying per-tenant construction costs (network handshakes, KMS credential fetches) at boot. Suitable for deployments with many tenants.

Health states:

  • pending — registered, not yet validated (or lazy factory not yet invoked). Adopters should 503 traffic until validation completes.
  • healthy — validated and serving.
  • unverified — was healthy; a subsequent :meth:recheck failed (transient failure). The tenant still serves (graceful-degrade).
  • disabled — persistent failure. 503 until an operator calls :meth:recheck and validation succeeds.

Validator: Optional callable (tenant_id, agent_url) -> bool. Pass a JWKS health-check, a connectivity probe, or any custom validation logic. Adopters using principal-token bearer auth (no JWKS) pass None — validation always succeeds immediately so await_first_validation=True transitions the tenant to healthy without a network round-trip.

Per-tenant locks: Each tenant gets an asyncio.Lock on first use. Locks are removed when the tenant is unregistered. Any in-flight :meth:recheck or :meth:resolve that held the lock before unregister() was called completes safely — zombie-entry guards in both methods prevent stale writes after removal.

Do not pass a TenantRegistry as a SubdomainTenantRouter. Both classes expose async def resolve(host), but the return types are incompatible (:class:TenantResolution vs :class:Tenant). Mypy will flag the mismatch; duck-typing and isinstance checks will not.

:param validator: Optional validation callable (sync or async). None → principal-token mode; validation always succeeds. :param default_serve_options: Optional dict of defaults to store for adopter convenience. Retrieve via :attr:serve_options.

Example (using :meth:as_platform — recommended path for :func:~adcp.decisioning.serve integration)::

from adcp.server import TenantRegistry
from adcp.decisioning import serve

registry = TenantRegistry(validator=check_jwks)

for tenant in load_tenants_from_db():
    # await_first_validation=True pre-warms tenants at boot so the
    # first request doesn't see health='pending'.
    await registry.register_lazy(
        tenant.id,
        agent_url=tenant.agent_url,
        factory=build_platform_for_tenant,
        await_first_validation=True,
    )

# Returns a DecisioningPlatform that routes per-request via
# ctx.tenant_id (set from the Host header by SubdomainTenantMiddleware).
serve(registry.as_platform(accounts=my_account_store), port=8080)

Example (escape-hatch — manual resolve() when you need custom dispatch)::

from adcp.server import TenantRegistry

registry = TenantRegistry(validator=None)

for tenant in load_tenants_from_db():
    await registry.register(
        tenant.id,
        agent_url=tenant.agent_url,
        platform=build_platform_for(tenant),
        await_first_validation=True,
    )

async def resolve(ctx):
    # Use resolve_by_id when tenant_id is already known (e.g. from
    # ctx.tenant_id); use resolve(host) for host-based lookup.
    resolved = await registry.resolve_by_id(ctx.tenant_id)
    if resolved is None or resolved.health in ("pending", "disabled"):
        raise HTTPException(503)
    return resolved.platform

Example (runtime admin operations)::

# Hot-add a newly onboarded tenant
await registry.register(new_id, agent_url=..., platform=...)

# Remove a deactivated tenant
registry.unregister(old_id)

# Re-validate after key rotation
await registry.recheck(rotated_id)
status = registry.health(rotated_id)

Instance variables

prop registered_tenants : frozenset[str]
Expand source code
@property
def registered_tenants(self) -> frozenset[str]:
    """Snapshot of the currently registered tenant ids.

    Read-only — mutations to the registry after this property is read
    are not reflected in the returned frozenset.
    """
    return frozenset(self._health)

Snapshot of the currently registered tenant ids.

Read-only — mutations to the registry after this property is read are not reflected in the returned frozenset.

prop serve_options : dict[str, Any]
Expand source code
@property
def serve_options(self) -> dict[str, Any]:
    """The ``default_serve_options`` dict passed at construction.

    Convenience accessor for single-tenant setups or when spreading
    common options into :func:`adcp.decisioning.serve`::

        serve(platform, **registry.serve_options)

    Multi-tenant deployments typically pass a router (not a single
    platform) to ``serve()``; in that case these options are consumed
    by the per-request dispatch layer rather than passed to ``serve``
    directly.

    Returns an empty dict when no options were passed at construction.
    Returns a shallow copy — mutations to the returned dict do not
    affect the registry's stored options.
    """
    return dict(self._default_serve_options)

The default_serve_options dict passed at construction.

Convenience accessor for single-tenant setups or when spreading common options into :func:serve()::

serve(platform, **registry.serve_options)

Multi-tenant deployments typically pass a router (not a single platform) to serve(); in that case these options are consumed by the per-request dispatch layer rather than passed to serve() directly.

Returns an empty dict when no options were passed at construction. Returns a shallow copy — mutations to the returned dict do not affect the registry's stored options.

Methods

def as_platform(self,
*,
accounts: AccountStore[Any],
capabilities: DecisioningCapabilities | None = None,
serve_states: frozenset[TenantHealthState] = frozenset({'unverified', 'healthy'})) ‑> DecisioningPlatform
Expand source code
def as_platform(
    self,
    *,
    accounts: AccountStore[Any],
    capabilities: DecisioningCapabilities | None = None,
    serve_states: frozenset[TenantHealthState] = _DEFAULT_SERVE_STATES,
) -> DecisioningPlatform:
    """Return a :class:`~adcp.decisioning.DecisioningPlatform` backed by this registry.

    The returned platform resolves the per-request tenant via
    ``ctx.tenant_id`` (populated by the transport layer from the
    ``Host`` header or URL path), applies health gating, and forwards
    every specialism method call to the resolved tenant's platform.
    Pass it directly to :func:`adcp.decisioning.serve`::

        registry = TenantRegistry(validator=check_jwks)
        for tenant in load_tenants():
            await registry.register_lazy(
                tenant.id, agent_url=tenant.url, factory=build_platform
            )

        serve(registry.as_platform(accounts=my_account_store), port=8080)

    **Tenant resolution.** The adapter reads ``ctx.tenant_id``, which
    the transport layer sets from the ``Host`` header (via
    :class:`~adcp.server.SubdomainTenantMiddleware`) or your custom
    ``context_factory``. **This value must equal the ``tenant_id``
    string you passed as the first argument to** :meth:`register` **/**
    :meth:`register_lazy`. The host itself (e.g. ``"acme.example.com"``)
    is NOT used — only the registry key (e.g. ``"acme"``).

    If you use :class:`~adcp.server.SubdomainTenantMiddleware`, wire
    ``tenant_id`` in your ``context_factory`` like this::

        from adcp.server import current_tenant

        def context_factory(request):
            t = current_tenant()
            return {"tenant_id": t.id if t else None}

    The ``Tenant.id`` value (from your
    :class:`~adcp.server.SubdomainTenantRouter`) must match the key
    you registered — ``register_lazy("acme", ...)`` requires
    ``Tenant(id="acme", ...)``, not ``Tenant(id="acme.example.com", ...)``.
    A mismatch silently produces ``SERVICE_UNAVAILABLE`` with
    ``health=None`` on every request.

    **Health gating.** By default the adapter serves ``healthy`` and
    ``unverified`` tenants, and raises ``SERVICE_UNAVAILABLE`` for
    ``pending`` and ``disabled`` tenants. Override via
    ``serve_states`` for fail-closed behaviour::

        # Serve only fully-validated tenants (fail-closed):
        registry.as_platform(
            accounts=store,
            serve_states=frozenset({"healthy"}),
        )

    **``accounts`` parameter.** :func:`~adcp.decisioning.serve` validates
    ``platform.accounts`` at boot time before any request arrives. Pass
    the same tenant-aware :class:`~adcp.decisioning.AccountStore` you
    would pass to :class:`~adcp.decisioning.PlatformRouter` — typically
    one that reads ``tenant_id`` from the resolved account's metadata or
    from the transport-layer ``current_tenant`` ContextVar.

    **``capabilities`` parameter.** Should be the union of all tenants'
    specialisms. The adapter cannot introspect child platforms at
    boot time, so the adopter is the source of truth. Defaults to an
    empty :class:`~adcp.decisioning.DecisioningCapabilities` (no
    specialisms advertised) — pass the full union for accurate
    ``tools/list`` projection.

    :param accounts: The :class:`~adcp.decisioning.AccountStore` for the
        returned platform. Required by framework boot-time validation.
    :param capabilities: Capability declaration for the adapter. Defaults
        to an empty :class:`~adcp.decisioning.DecisioningCapabilities`.
    :param serve_states: Health states for which requests proceed.
        Default is ``frozenset({"healthy", "unverified"})``.
    :returns: A :class:`~adcp.decisioning.DecisioningPlatform` suitable
        for passing to :func:`adcp.decisioning.serve`.
    """
    from adcp.decisioning.platform import DecisioningCapabilities as _DecisioningCapabilities
    from adcp.decisioning.platform_router import _make_registry_platform_adapter

    if capabilities is None:
        logger.warning(
            "TenantRegistry.as_platform: no capabilities= passed; tools/list will "
            "advertise no tools. Pass capabilities=DecisioningCapabilities(specialisms=[...]) "
            "for accurate tools/list projection."
        )
    cap = capabilities if capabilities is not None else _DecisioningCapabilities()
    return _make_registry_platform_adapter(
        self,
        accounts=accounts,
        capabilities=cap,
        serve_states=frozenset(serve_states),
    )

Return a :class:~adcp.decisioning.DecisioningPlatform backed by this registry.

The returned platform resolves the per-request tenant via ctx.tenant_id (populated by the transport layer from the Host header or URL path), applies health gating, and forwards every specialism method call to the resolved tenant's platform. Pass it directly to :func:serve()::

registry = TenantRegistry(validator=check_jwks)
for tenant in load_tenants():
    await registry.register_lazy(
        tenant.id, agent_url=tenant.url, factory=build_platform
    )

serve(registry.as_platform(accounts=my_account_store), port=8080)

Tenant resolution. The adapter reads ctx.tenant_id, which the transport layer sets from the Host header (via :class:~adcp.server.SubdomainTenantMiddleware) or your custom context_factory. This value must equal the tenant_id string you passed as the first argument to :meth:register / :meth:register_lazy. The host itself (e.g. "acme.example.com") is NOT used — only the registry key (e.g. "acme").

If you use :class:~adcp.server.SubdomainTenantMiddleware, wire tenant_id in your context_factory like this::

from adcp.server import current_tenant

def context_factory(request):
    t = current_tenant()
    return {"tenant_id": t.id if t else None}

The Tenant.id value (from your :class:~adcp.server.SubdomainTenantRouter) must match the key you registered — register_lazy("acme", ...) requires Tenant(id="acme", ...), not Tenant(id="acme.example.com", ...). A mismatch silently produces SERVICE_UNAVAILABLE with health=None on every request.

Health gating. By default the adapter serves healthy and unverified tenants, and raises SERVICE_UNAVAILABLE for pending and disabled tenants. Override via serve_states for fail-closed behaviour::

# Serve only fully-validated tenants (fail-closed):
registry.as_platform(
    accounts=store,
    serve_states=frozenset({"healthy"}),
)

accounts parameter. :func:~adcp.decisioning.serve validates platform.accounts at boot time before any request arrives. Pass the same tenant-aware :class:~adcp.decisioning.AccountStore you would pass to :class:~adcp.decisioning.PlatformRouter — typically one that reads tenant_id from the resolved account's metadata or from the transport-layer current_tenant() ContextVar.

capabilities parameter. Should be the union of all tenants' specialisms. The adapter cannot introspect child platforms at boot time, so the adopter is the source of truth. Defaults to an empty :class:~adcp.decisioning.DecisioningCapabilities (no specialisms advertised) — pass the full union for accurate tools/list projection.

:param accounts: The :class:~adcp.decisioning.AccountStore for the returned platform. Required by framework boot-time validation. :param capabilities: Capability declaration for the adapter. Defaults to an empty :class:~adcp.decisioning.DecisioningCapabilities. :param serve_states: Health states for which requests proceed. Default is frozenset({"healthy", "unverified"}). :returns: A :class:~adcp.decisioning.DecisioningPlatform suitable for passing to :func:serve().

def health(self, tenant_id: str) ‑> Literal['pending', 'healthy', 'unverified', 'disabled'] | None
Expand source code
def health(self, tenant_id: str) -> TenantHealthState | None:
    """Return the current health state for ``tenant_id``.

    Returns ``None`` when the tenant is not registered (distinct from
    any health state value — callers can use ``is None`` to detect
    unknown tenants).
    """
    return self._health.get(tenant_id)

Return the current health state for tenant_id.

Returns None when the tenant is not registered (distinct from any health state value — callers can use is None to detect unknown tenants).

async def recheck(self, tenant_id: str) ‑> None
Expand source code
async def recheck(self, tenant_id: str) -> None:
    """Re-validate a tenant after key rotation or config change.

    **State transitions on validator success:** any state → ``healthy``.

    **State transitions on validator failure or exception:**

    * ``healthy`` → ``unverified`` (was serving; graceful-degrade so
      existing traffic keeps flowing while the operator investigates).
    * ``pending`` / ``unverified`` / ``disabled`` → ``disabled``
      (no prior healthy baseline; fail closed).

    The health state is updated before any exception propagates, so
    the state is always consistent even when the validator raises.

    **Lazy-tenant caveats:**

    * For a lazy tenant in ``pending`` state (factory never invoked),
      ``recheck()`` runs the validator against the registered
      ``agent_url`` only. If it succeeds, health advances to
      ``healthy`` — but the platform has not been built yet.
      :meth:`resolve_by_host` still returns ``None``; use the async
      :meth:`resolve` which triggers the factory on first call.
    * For a lazy tenant that reached ``disabled`` via factory failure,
      the factory has been cleared. Calling ``recheck()`` alone is
      insufficient to recover — the validator may succeed but there
      is no platform to serve. To retry platform construction, call
      :meth:`register_lazy` again with the same factory, then call
      :meth:`recheck` if you also need to re-run the validator.

    :raises KeyError: when ``tenant_id`` is not registered.
    :raises Exception: re-raises any exception from the validator
        after updating the health state.
    """
    if tenant_id not in self._health:
        raise KeyError(f"Tenant {tenant_id!r} is not registered")

    lock = self._get_lock(tenant_id)
    async with lock:
        # Re-check inside the lock — unregister may have raced.
        if tenant_id not in self._health:
            raise KeyError(f"Tenant {tenant_id!r} is not registered")
        prior = self._health[tenant_id]
        try:
            ok = await self._run_validator(tenant_id)
        except Exception:
            # Guard: unregister() may have run while we awaited the validator.
            # If so, _health no longer has this tenant — writing back would
            # create a zombie entry visible via health() / registered_tenants.
            if tenant_id not in self._health:
                return
            self._health[tenant_id] = (
                "unverified" if prior == "healthy" else "disabled"
            )
            logger.warning(
                "TenantRegistry.recheck: validator raised for tenant %r; "
                "health=%s",
                tenant_id,
                self._health[tenant_id],
                exc_info=True,
            )
            raise
        # Same guard for the success path.
        if tenant_id not in self._health:
            return
        if ok:
            self._health[tenant_id] = "healthy"
        else:
            self._health[tenant_id] = (
                "unverified" if prior == "healthy" else "disabled"
            )

Re-validate a tenant after key rotation or config change.

State transitions on validator success: any state → healthy.

State transitions on validator failure or exception:

  • healthyunverified (was serving; graceful-degrade so existing traffic keeps flowing while the operator investigates).
  • pending / unverified / disableddisabled (no prior healthy baseline; fail closed).

The health state is updated before any exception propagates, so the state is always consistent even when the validator raises.

Lazy-tenant caveats:

  • For a lazy tenant in pending state (factory never invoked), recheck() runs the validator against the registered agent_url only. If it succeeds, health advances to healthy — but the platform has not been built yet. :meth:resolve_by_host still returns None; use the async :meth:resolve which triggers the factory on first call.
  • For a lazy tenant that reached disabled via factory failure, the factory has been cleared. Calling recheck() alone is insufficient to recover — the validator may succeed but there is no platform to serve. To retry platform construction, call :meth:register_lazy again with the same factory, then call :meth:recheck if you also need to re-run the validator.

:raises KeyError: when tenant_id is not registered. :raises Exception: re-raises any exception from the validator after updating the health state.

async def register(self,
tenant_id: str,
*,
agent_url: str,
platform: DecisioningPlatform,
await_first_validation: bool = False) ‑> None
Expand source code
async def register(
    self,
    tenant_id: str,
    *,
    agent_url: str,
    platform: DecisioningPlatform,
    await_first_validation: bool = False,
) -> None:
    """Register a tenant.

    Health starts as ``pending``. When ``await_first_validation=True``
    the coroutine suspends until the validator resolves, then
    transitions to ``healthy`` or ``disabled`` before returning — the
    next :meth:`resolve_by_host` call sees the final state.

    Re-registering an existing tenant atomically replaces its platform
    and agent_url under the per-tenant lock. The old host-map entry is
    removed if the URL changed.

    :param tenant_id: Stable identifier (e.g. DB primary key).
    :param agent_url: The tenant's agent endpoint URL. The host
        component is extracted and used as the key for
        :meth:`resolve_by_host` lookups.
    :param platform: Pre-built
        :class:`~adcp.decisioning.DecisioningPlatform` for this tenant.
    :param await_first_validation: When ``True``, suspends the caller
        until validation completes (not "blocks the event loop" — the
        coroutine yields cooperatively while awaiting I/O). Useful at
        boot so the first incoming request doesn't race the validation
        roundtrip. The typical ``False`` default is correct for
        background hot-add where traffic is gated on ``health != 'pending'``.
    """
    lock = self._get_lock(tenant_id)
    async with lock:
        # Remove stale host-map entry when the URL changes.
        old_url = self._agent_urls.get(tenant_id)
        if old_url is not None and old_url != agent_url:
            self._host_map.pop(self._normalize_host(old_url), None)

        self._platforms[tenant_id] = platform
        self._agent_urls[tenant_id] = agent_url
        self._host_map[self._normalize_host(agent_url)] = tenant_id
        self._health[tenant_id] = "pending"
        # Clear any lazy factory if re-registering as eager.
        self._factories.pop(tenant_id, None)

        if await_first_validation:
            try:
                ok = await self._run_validator(tenant_id)
            except Exception:
                logger.warning(
                    "TenantRegistry.register: validator raised for tenant %r; "
                    "health=disabled",
                    tenant_id,
                    exc_info=True,
                )
                self._health[tenant_id] = "disabled"
                return
            self._health[tenant_id] = "healthy" if ok else "disabled"

Register a tenant.

Health starts as pending. When await_first_validation=True the coroutine suspends until the validator resolves, then transitions to healthy or disabled before returning — the next :meth:resolve_by_host call sees the final state.

Re-registering an existing tenant atomically replaces its platform and agent_url under the per-tenant lock. The old host-map entry is removed if the URL changed.

:param tenant_id: Stable identifier (e.g. DB primary key). :param agent_url: The tenant's agent endpoint URL. The host component is extracted and used as the key for :meth:resolve_by_host lookups. :param platform: Pre-built :class:~adcp.decisioning.DecisioningPlatform for this tenant. :param await_first_validation: When True, suspends the caller until validation completes (not "blocks the event loop" — the coroutine yields cooperatively while awaiting I/O). Useful at boot so the first incoming request doesn't race the validation roundtrip. The typical False default is correct for background hot-add where traffic is gated on health != 'pending'.

async def register_lazy(self,
tenant_id: str,
*,
agent_url: str,
factory: PlatformFactory,
await_first_validation: bool = False) ‑> None
Expand source code
async def register_lazy(
    self,
    tenant_id: str,
    *,
    agent_url: str,
    factory: PlatformFactory,
    await_first_validation: bool = False,
) -> None:
    """Register a tenant with a lazy platform factory.

    The platform is built on the first :meth:`resolve` call for this
    tenant's host, then cached. Subsequent resolves return the cached
    instance. Suitable for deployments with many tenants where eager
    construction is too expensive at boot — network handshakes, KMS
    credential fetches, inventory-manager construction, etc.

    Health starts as ``pending``. When ``await_first_validation=True``
    the factory is invoked immediately, the platform is built, and
    validation completes before returning — the next :meth:`resolve`
    call sees the final state without triggering the factory again.

    Use :meth:`resolve` (async) to get a :class:`TenantResolution`
    for lazy-registered tenants; the synchronous :meth:`resolve_by_host`
    returns ``None`` until the platform is built.

    Lazy and eager tenants share the same health state machine:
    :meth:`health`, :meth:`unregister`, :meth:`recheck`, and
    :attr:`registered_tenants` work identically regardless of
    registration mode.

    Re-registering an existing tenant (eager or lazy) atomically
    replaces its factory and agent_url under the per-tenant lock.

    :param tenant_id: Stable identifier (e.g. DB primary key).
    :param agent_url: The tenant's agent endpoint URL. The host
        component is extracted for :meth:`resolve` / :meth:`resolve_by_host`.
    :param factory: Async callable ``(tenant_id) -> DecisioningPlatform``.
        Called at most once per registration (not once per request).
    :param await_first_validation: When ``True``, invokes the factory
        and validator immediately before returning.
    """
    lock = self._get_lock(tenant_id)
    async with lock:
        old_url = self._agent_urls.get(tenant_id)
        if old_url is not None and old_url != agent_url:
            self._host_map.pop(self._normalize_host(old_url), None)

        self._factories[tenant_id] = factory
        # Clear any eagerly-built platform if re-registering as lazy.
        self._platforms.pop(tenant_id, None)
        self._agent_urls[tenant_id] = agent_url
        self._host_map[self._normalize_host(agent_url)] = tenant_id
        self._health[tenant_id] = "pending"

        if await_first_validation:
            try:
                platform = await factory(tenant_id)
                ok = await self._run_validator(tenant_id)
            except Exception:
                logger.warning(
                    "TenantRegistry.register_lazy: factory/validator raised for "
                    "tenant %r; health=disabled",
                    tenant_id,
                    exc_info=True,
                )
                self._health[tenant_id] = "disabled"
                self._factories.pop(tenant_id, None)
                return
            if ok:
                self._platforms[tenant_id] = platform
                self._factories.pop(tenant_id, None)
                self._health[tenant_id] = "healthy"
            else:
                # Validator rejected the platform — discard it and clear the
                # factory to mirror resolve() cold-path behavior: a disabled
                # lazy tenant needs register_lazy() + recheck() to recover.
                self._health[tenant_id] = "disabled"
                self._factories.pop(tenant_id, None)

Register a tenant with a lazy platform factory.

The platform is built on the first :meth:resolve call for this tenant's host, then cached. Subsequent resolves return the cached instance. Suitable for deployments with many tenants where eager construction is too expensive at boot — network handshakes, KMS credential fetches, inventory-manager construction, etc.

Health starts as pending. When await_first_validation=True the factory is invoked immediately, the platform is built, and validation completes before returning — the next :meth:resolve call sees the final state without triggering the factory again.

Use :meth:resolve (async) to get a :class:TenantResolution for lazy-registered tenants; the synchronous :meth:resolve_by_host returns None until the platform is built.

Lazy and eager tenants share the same health state machine: :meth:health, :meth:unregister, :meth:recheck, and :attr:registered_tenants work identically regardless of registration mode.

Re-registering an existing tenant (eager or lazy) atomically replaces its factory and agent_url under the per-tenant lock.

:param tenant_id: Stable identifier (e.g. DB primary key). :param agent_url: The tenant's agent endpoint URL. The host component is extracted for :meth:resolve / :meth:resolve_by_host. :param factory: Async callable (tenant_id) -> DecisioningPlatform. Called at most once per registration (not once per request). :param await_first_validation: When True, invokes the factory and validator immediately before returning.

async def resolve(self, host: str) ‑> TenantResolution | None
Expand source code
async def resolve(self, host: str) -> TenantResolution | None:
    """Async lookup by ``Host`` header value; builds lazy platforms on first hit.

    For eager tenants (registered via :meth:`register`), equivalent to
    :meth:`resolve_by_host` at an async call site — no I/O occurs.

    For lazy tenants (registered via :meth:`register_lazy`), the
    platform factory is invoked on the first call, then cached.
    Concurrent first-hit resolves for the same tenant serialize on
    the per-tenant lock — only one factory invocation occurs.

    Returns ``None`` when no tenant is registered for this host, or when
    a lazy tenant's factory/validator fails on this call (health set to
    ``disabled`` in both cases).

    Returns a :class:`TenantResolution` — which may have
    ``health="disabled"`` — when the platform was already built (eager
    registration, lazy + ``await_first_validation=True``, or a previous
    :meth:`resolve` call). **Always check ``result.health`` before
    serving; never gate solely on ``result is None``.**

    The caller is responsible for gating traffic — the registry does
    not 503 automatically.

    :param host: Raw ``Host`` header value. Port suffixes are stripped;
        full URLs are also accepted. See :meth:`_normalize_host` for
        load-balancer caveats.
    """
    normalized = self._normalize_host(host)
    tenant_id = self._host_map.get(normalized)
    if tenant_id is None:
        return None

    # Fast path: platform already built (eager or previously-resolved lazy).
    platform = self._platforms.get(tenant_id)
    if platform is not None:
        health = self._health.get(tenant_id, "pending")
        return TenantResolution(tenant_id=tenant_id, health=health, platform=platform)

    # If there is no factory either, nothing to do.
    if tenant_id not in self._factories:
        return None

    # Lazy path: acquire per-tenant lock to serialize concurrent first-hit
    # resolves — only one factory invocation per tenant.
    lock = self._get_lock(tenant_id)
    async with lock:
        # Double-check: another coroutine may have built the platform
        # while we waited for the lock.
        platform = self._platforms.get(tenant_id)
        if platform is not None:
            health = self._health.get(tenant_id, "pending")
            return TenantResolution(
                tenant_id=tenant_id, health=health, platform=platform
            )

        # Guard: unregister() may have run while we waited.
        if tenant_id not in self._health:
            return None

        factory = self._factories.get(tenant_id)
        if factory is None:
            return None

        try:
            platform = await factory(tenant_id)
        except Exception:
            logger.warning(
                "TenantRegistry.resolve: factory raised for tenant %r; "
                "health=disabled",
                tenant_id,
                exc_info=True,
            )
            if tenant_id in self._health:
                self._health[tenant_id] = "disabled"
                # Drop the factory so subsequent resolve() calls don't re-invoke
                # it — a disabled tenant needs operator intervention via recheck().
                self._factories.pop(tenant_id, None)
            return None

        try:
            ok = await self._run_validator(tenant_id)
        except Exception:
            logger.warning(
                "TenantRegistry.resolve: validator raised for tenant %r; "
                "health=disabled",
                tenant_id,
                exc_info=True,
            )
            if tenant_id in self._health:
                self._health[tenant_id] = "disabled"
                self._factories.pop(tenant_id, None)
            return None

        # Guard: unregister() may have run while we awaited factory/validator.
        if tenant_id not in self._health:
            return None

        if ok:
            self._platforms[tenant_id] = platform
            self._health[tenant_id] = "healthy"
            # Factory no longer needed — platform is cached in _platforms.
            self._factories.pop(tenant_id, None)
            return TenantResolution(
                tenant_id=tenant_id, health="healthy", platform=platform
            )
        else:
            self._health[tenant_id] = "disabled"
            self._factories.pop(tenant_id, None)
            return None

Async lookup by Host header value; builds lazy platforms on first hit.

For eager tenants (registered via :meth:register), equivalent to :meth:resolve_by_host at an async call site — no I/O occurs.

For lazy tenants (registered via :meth:register_lazy), the platform factory is invoked on the first call, then cached. Concurrent first-hit resolves for the same tenant serialize on the per-tenant lock — only one factory invocation occurs.

Returns None when no tenant is registered for this host, or when a lazy tenant's factory/validator fails on this call (health set to disabled in both cases).

Returns a :class:TenantResolution — which may have health="disabled" — when the platform was already built (eager registration, lazy + await_first_validation=True, or a previous :meth:resolve call). Always check result.health before serving; never gate solely on result is None.

The caller is responsible for gating traffic — the registry does not 503 automatically.

:param host: Raw Host header value. Port suffixes are stripped; full URLs are also accepted. See :meth:_normalize_host for load-balancer caveats.

def resolve_by_host(self, host: str) ‑> TenantResolution | None
Expand source code
def resolve_by_host(self, host: str) -> TenantResolution | None:
    """Synchronous lookup by ``Host`` header value.

    Returns ``None`` when no tenant is registered for this host.
    The caller is responsible for checking ``result.health`` and
    gating traffic as appropriate — the registry does not 503
    automatically (health-gating belongs in the adopter's request
    dispatch layer).

    The lookup is synchronous because the registry maintains its own
    in-memory host → tenant mapping (updated eagerly by
    :meth:`register` and :meth:`unregister`). This intentionally
    departs from the JS SDK's async variant, which must call an
    external resolver; the Python registry owns the mapping directly.

    :param host: Raw ``Host`` header value. Port suffixes are stripped
        before lookup; the string may also be a full URL.
    """
    normalized = self._normalize_host(host)
    tenant_id = self._host_map.get(normalized)
    if tenant_id is None:
        return None
    platform = self._platforms.get(tenant_id)
    if platform is None:
        return None
    health = self._health.get(tenant_id, "pending")
    return TenantResolution(tenant_id=tenant_id, health=health, platform=platform)

Synchronous lookup by Host header value.

Returns None when no tenant is registered for this host. The caller is responsible for checking result.health and gating traffic as appropriate — the registry does not 503 automatically (health-gating belongs in the adopter's request dispatch layer).

The lookup is synchronous because the registry maintains its own in-memory host → tenant mapping (updated eagerly by :meth:register and :meth:unregister). This intentionally departs from the JS SDK's async variant, which must call an external resolver; the Python registry owns the mapping directly.

:param host: Raw Host header value. Port suffixes are stripped before lookup; the string may also be a full URL.

async def resolve_by_id(self, tenant_id: str) ‑> TenantResolution | None
Expand source code
async def resolve_by_id(self, tenant_id: str) -> TenantResolution | None:
    """Async lookup by ``tenant_id``; builds lazy platforms on first hit.

    Equivalent to :meth:`resolve` but accepts a ``tenant_id`` string
    directly instead of a ``Host`` header value. Used by the
    :meth:`as_platform` adapter to resolve per-request platforms keyed
    on ``ctx.tenant_id`` (set by the transport layer from the Host
    header) rather than re-doing the host → tenant_id lookup.

    For eager tenants this is a synchronous in-memory lookup wrapped in
    a coroutine — no I/O occurs. For lazy tenants (registered via
    :meth:`register_lazy`) the factory is invoked on the first call and
    the result is cached, with concurrent first-hit calls serialised on
    the per-tenant lock.

    Returns ``None`` when the tenant is not registered or when a lazy
    tenant's factory or validator fails.

    Returns a :class:`TenantResolution` — which may have any health
    state — when the platform is available. **Always check
    ``result.health`` before serving.**

    :param tenant_id: Stable tenant identifier as registered via
        :meth:`register` or :meth:`register_lazy`.
    """
    if tenant_id not in self._health:
        return None

    # Fast path: platform already built (eager or previously resolved lazy).
    platform = self._platforms.get(tenant_id)
    if platform is not None:
        health = self._health.get(tenant_id, "pending")
        return TenantResolution(tenant_id=tenant_id, health=health, platform=platform)

    # No factory either — lazy tenant that disabled or cleared itself.
    if tenant_id not in self._factories:
        return None

    # Lazy path: mirrors resolve()'s concurrent first-hit serialisation,
    # skipping the host_map lookup since we already have the tenant_id.
    lock = self._get_lock(tenant_id)
    async with lock:
        platform = self._platforms.get(tenant_id)
        if platform is not None:
            health = self._health.get(tenant_id, "pending")
            return TenantResolution(
                tenant_id=tenant_id, health=health, platform=platform
            )

        if tenant_id not in self._health:
            return None

        factory = self._factories.get(tenant_id)
        if factory is None:
            return None

        try:
            platform = await factory(tenant_id)
        except Exception:
            logger.warning(
                "TenantRegistry.resolve_by_id: factory raised for tenant %r; "
                "health=disabled",
                tenant_id,
                exc_info=True,
            )
            if tenant_id in self._health:
                self._health[tenant_id] = "disabled"
                self._factories.pop(tenant_id, None)
            return None

        try:
            ok = await self._run_validator(tenant_id)
        except Exception:
            logger.warning(
                "TenantRegistry.resolve_by_id: validator raised for tenant %r; "
                "health=disabled",
                tenant_id,
                exc_info=True,
            )
            if tenant_id in self._health:
                self._health[tenant_id] = "disabled"
                self._factories.pop(tenant_id, None)
            return None

        if tenant_id not in self._health:
            return None

        if ok:
            self._platforms[tenant_id] = platform
            self._health[tenant_id] = "healthy"
            self._factories.pop(tenant_id, None)
            return TenantResolution(
                tenant_id=tenant_id, health="healthy", platform=platform
            )
        else:
            self._health[tenant_id] = "disabled"
            self._factories.pop(tenant_id, None)
            return None

Async lookup by tenant_id; builds lazy platforms on first hit.

Equivalent to :meth:resolve but accepts a tenant_id string directly instead of a Host header value. Used by the :meth:as_platform adapter to resolve per-request platforms keyed on ctx.tenant_id (set by the transport layer from the Host header) rather than re-doing the host → tenant_id lookup.

For eager tenants this is a synchronous in-memory lookup wrapped in a coroutine — no I/O occurs. For lazy tenants (registered via :meth:register_lazy) the factory is invoked on the first call and the result is cached, with concurrent first-hit calls serialised on the per-tenant lock.

Returns None when the tenant is not registered or when a lazy tenant's factory or validator fails.

Returns a :class:TenantResolution — which may have any health state — when the platform is available. Always check result.health before serving.

:param tenant_id: Stable tenant identifier as registered via :meth:register or :meth:register_lazy.

def unregister(self, tenant_id: str) ‑> None
Expand source code
def unregister(self, tenant_id: str) -> None:
    """Remove a tenant from the registry.

    Callers that already hold a reference to the tenant's platform
    (e.g. an in-flight request that called :meth:`resolve_by_host`
    before this call) complete normally — the registry does not cancel
    in-flight work. Subsequent :meth:`resolve_by_host` calls for this
    host return ``None``.

    Safe to call when the tenant is not registered (no-op).
    """
    agent_url = self._agent_urls.pop(tenant_id, None)
    if agent_url is not None:
        self._host_map.pop(self._normalize_host(agent_url), None)
    self._platforms.pop(tenant_id, None)
    self._factories.pop(tenant_id, None)
    self._health.pop(tenant_id, None)
    self._locks.pop(tenant_id, None)

Remove a tenant from the registry.

Callers that already hold a reference to the tenant's platform (e.g. an in-flight request that called :meth:resolve_by_host before this call) complete normally — the registry does not cancel in-flight work. Subsequent :meth:resolve_by_host calls for this host return None.

Safe to call when the tenant is not registered (no-op).

class TenantResolution (tenant_id: str, health: TenantHealthState, platform: DecisioningPlatform)
Expand source code
@dataclass(frozen=True)
class TenantResolution:
    """Result of :meth:`TenantRegistry.resolve_by_host`.

    :param tenant_id: Stable identifier for the resolved tenant.
    :param health: Current health state. Callers gate traffic on this —
        typically 503 for ``pending`` and ``disabled``, serve for
        ``healthy`` and ``unverified``.
    :param platform: The :class:`~adcp.decisioning.DecisioningPlatform`
        for this tenant. Pass to :func:`adcp.decisioning.serve` or use
        with a :class:`~adcp.decisioning.PlatformRouter`.
    """

    tenant_id: str
    health: TenantHealthState
    platform: DecisioningPlatform

Result of :meth:TenantRegistry.resolve_by_host().

:param tenant_id: Stable identifier for the resolved tenant. :param health: Current health state. Callers gate traffic on this — typically 503 for pending and disabled, serve for healthy and unverified. :param platform: The :class:~adcp.decisioning.DecisioningPlatform for this tenant. Pass to :func:serve() or use with a :class:~adcp.decisioning.PlatformRouter.

Instance variables

var health : TenantHealthState
var platform : DecisioningPlatform
var tenant_id : str
class TestControllerError (code: str, message: str, current_state: str | None = None)
Expand source code
class TestControllerError(Exception):
    """Typed error for test controller store methods.

    Raise this from your TestControllerStore methods to return structured
    error responses. The dispatcher catches it and converts to the AdCP
    comply_test_controller error format.

    Example:
        async def force_media_buy_status(self, media_buy_id, status, rejection_reason=None):
            prev = self.media_buys.get(media_buy_id)
            if prev is None:
                raise TestControllerError("NOT_FOUND", f"Media buy {media_buy_id} not found")
            if prev in ("completed", "rejected", "canceled"):
                raise TestControllerError(
                    "INVALID_TRANSITION",
                    f"Cannot transition from {prev}",
                    current_state=prev,
                )
            self.media_buys[media_buy_id] = status
            return {"previous_state": prev, "current_state": status}
    """

    def __init__(self, code: str, message: str, current_state: str | None = None):
        super().__init__(message)
        self.code = code
        self.current_state = current_state

Typed error for test controller store methods.

Raise this from your TestControllerStore methods to return structured error responses. The dispatcher catches it and converts to the AdCP comply_test_controller error format.

Example

async def force_media_buy_status(self, media_buy_id, status, rejection_reason=None): prev = self.media_buys.get(media_buy_id) if prev is None: raise TestControllerError("NOT_FOUND", f"Media buy {media_buy_id} not found") if prev in ("completed", "rejected", "canceled"): raise TestControllerError( "INVALID_TRANSITION", f"Cannot transition from {prev}", current_state=prev, ) self.media_buys[media_buy_id] = status return {"previous_state": prev, "current_state": status}

Ancestors

  • builtins.Exception
  • builtins.BaseException
class TestControllerStore
Expand source code
class TestControllerStore:
    """Base class for test controller state management.

    Subclass this and override the methods for scenarios your agent supports.
    Methods you don't override will be reported as unsupported scenarios
    and excluded from list_scenarios.

    Raise TestControllerError for structured error responses.

    Methods MAY declare an optional keyword-only ``context: ToolContext |
    None = None`` parameter. When present, the dispatcher threads the
    ``ToolContext`` built by the server's ``context_factory`` into the
    call — header-driven mock state (e.g. ``AdCPTestContext.from_headers``)
    populated in the factory is readable off ``context.metadata``.
    Stores that don't declare ``context`` keep working unchanged.
    """

    async def force_creative_status(
        self,
        creative_id: str,
        status: str,
        rejection_reason: str | None = None,
        *,
        context: ToolContext | None = None,
    ) -> dict[str, Any]:
        """Force a creative to a given status.

        Returns:
            {"previous_state": str, "current_state": str}
        """
        raise NotImplementedError

    async def force_account_status(
        self,
        account_id: str,
        status: str,
        *,
        context: ToolContext | None = None,
    ) -> dict[str, Any]:
        """Force an account to a given status.

        Returns:
            {"previous_state": str, "current_state": str}
        """
        raise NotImplementedError

    async def force_media_buy_status(
        self,
        media_buy_id: str,
        status: str,
        rejection_reason: str | None = None,
        *,
        context: ToolContext | None = None,
    ) -> dict[str, Any]:
        """Force a media buy to a given status.

        Returns:
            {"previous_state": str, "current_state": str}
        """
        raise NotImplementedError

    async def force_session_status(
        self,
        session_id: str,
        status: str,
        termination_reason: str | None = None,
        *,
        context: ToolContext | None = None,
    ) -> dict[str, Any]:
        """Force a session to a given status.

        Returns:
            {"previous_state": str, "current_state": str}
        """
        raise NotImplementedError

    async def force_create_media_buy_arm(
        self,
        arm: str,
        task_id: str | None = None,
        message: str | None = None,
        *,
        account: dict[str, Any] | None = None,
        context: ToolContext | None = None,
    ) -> dict[str, Any]:
        """Register a single-shot directive for the next create_media_buy call.

        The directive is consumed by the next create_media_buy call from the
        same authenticated sandbox account, then cleared. A second registration
        before consumption overwrites the first.

        Args:
            arm: Response arm — ``'submitted'`` or ``'input-required'``.
            task_id: Required when ``arm='submitted'``. The seller MUST emit
                this exact value on the next create_media_buy task envelope
                and accept it on subsequent tasks/get calls within the same
                sandbox account. Max 128 chars.
            message: Optional plain-text note surfaced on the response.
                Max 2000 chars.
            account: Caller-supplied account object from the MCP request.
                Implementations use this for single-shot-per-account isolation.
            context: Optional ToolContext from the server's context_factory.

        Returns:
            ForcedDirectiveSuccess::

                {"success": True, "forced": {"arm": str, "task_id"?: str}}

        Raises:
            TestControllerError: with code ``"NOT_FOUND"`` if the caller
                account is not recognized, or ``"INVALID_PARAMS"`` on
                validation failure.
        """
        raise NotImplementedError

    async def force_task_completion(
        self,
        task_id: str,
        result: dict[str, Any],
        *,
        account: dict[str, Any] | None = None,
        context: ToolContext | None = None,
    ) -> dict[str, Any]:
        """Resolve a previously-submitted task to ``'completed'``.

        Isolation and idempotency contract:

        - **Cross-account replay** — raise ``TestControllerError("NOT_FOUND", ...)``
          when the task_id was registered by a different sandbox account.
        - **Identical-params replay** — idempotent; return the same
          ``StateTransitionSuccess``.
        - **Diverging-params replay** against a terminal task — raise
          ``TestControllerError("INVALID_TRANSITION", ...,
          current_state="completed")``.

        Args:
            task_id: Task handle to resolve. Max 128 chars.
            result: Completion payload (non-empty object). Implementations
                SHOULD validate it against the response branch for the task's
                original method and MUST reject payloads that fail that check
                with ``TestControllerError("INVALID_PARAMS", ...)``.
            account: Caller-supplied account object from the MCP request.
                Used for cross-account isolation.
            context: Optional ToolContext from the server's context_factory.

        Returns:
            StateTransitionSuccess::

                {"success": True, "previous_state": "submitted",
                 "current_state": "completed"}

        Raises:
            TestControllerError: with code ``"NOT_FOUND"`` if the task_id
                is unknown or owned by a different account,
                ``"INVALID_TRANSITION"`` if the task is already terminal and
                params diverge, or ``"INVALID_PARAMS"`` on validation failure.
        """
        raise NotImplementedError

    async def simulate_delivery(
        self,
        media_buy_id: str,
        impressions: int | None = None,
        clicks: int | None = None,
        conversions: int | None = None,
        reported_spend: dict[str, Any] | None = None,
        *,
        context: ToolContext | None = None,
    ) -> dict[str, Any]:
        """Simulate delivery metrics for a media buy.

        Returns:
            {"simulated": {...}, "cumulative": {...} | None}
        """
        raise NotImplementedError

    async def simulate_budget_spend(
        self,
        spend_percentage: float,
        account_id: str | None = None,
        media_buy_id: str | None = None,
        *,
        context: ToolContext | None = None,
    ) -> dict[str, Any]:
        """Simulate budget spend to a percentage.

        Returns:
            {"simulated": {...}}
        """
        raise NotImplementedError

    async def seed_product(
        self,
        fixture: dict[str, Any] | None = None,
        product_id: str | None = None,
        *,
        context: ToolContext | None = None,
    ) -> dict[str, Any]:
        """Pre-populate a product fixture for storyboard tests (AdCP 3.0.1).

        Returns:
            {"product_id": str}
        """
        raise NotImplementedError

    async def seed_pricing_option(
        self,
        fixture: dict[str, Any] | None = None,
        product_id: str | None = None,
        pricing_option_id: str | None = None,
        *,
        context: ToolContext | None = None,
    ) -> dict[str, Any]:
        """Pre-populate a pricing option fixture for storyboard tests (AdCP 3.0.1).

        Returns:
            {"pricing_option_id": str}
        """
        raise NotImplementedError

    async def seed_creative(
        self,
        fixture: dict[str, Any] | None = None,
        creative_id: str | None = None,
        *,
        context: ToolContext | None = None,
    ) -> dict[str, Any]:
        """Pre-populate a creative fixture for storyboard tests (AdCP 3.0.1).

        Returns:
            {"creative_id": str}
        """
        raise NotImplementedError

    async def seed_plan(
        self,
        fixture: dict[str, Any] | None = None,
        plan_id: str | None = None,
        *,
        context: ToolContext | None = None,
    ) -> dict[str, Any]:
        """Pre-populate a plan fixture for storyboard tests (AdCP 3.0.1).

        Returns:
            {"plan_id": str}
        """
        raise NotImplementedError

    async def seed_media_buy(
        self,
        fixture: dict[str, Any] | None = None,
        media_buy_id: str | None = None,
        *,
        context: ToolContext | None = None,
    ) -> dict[str, Any]:
        """Pre-populate a media buy fixture for storyboard tests (AdCP 3.0.1).

        Returns:
            {"media_buy_id": str}
        """
        raise NotImplementedError

    async def seed_creative_format(
        self,
        fixture: dict[str, Any] | None = None,
        format_id: str | None = None,
        *,
        context: ToolContext | None = None,
    ) -> dict[str, Any]:
        """Pre-populate a creative format fixture for storyboard tests (AdCP 3.0.1).

        The seller MUST expose the seeded format_id in list_creative_formats
        responses for the duration of the compliance session.

        Returns:
            {"format_id": str}
        """
        raise NotImplementedError

Base class for test controller state management.

Subclass this and override the methods for scenarios your agent supports. Methods you don't override will be reported as unsupported scenarios and excluded from list_scenarios.

Raise TestControllerError for structured error responses.

Methods MAY declare an optional keyword-only context: ToolContext | None = None parameter. When present, the dispatcher threads the ToolContext built by the server's context_factory into the call — header-driven mock state (e.g. AdCPTestContext.from_headers) populated in the factory is readable off context.metadata. Stores that don't declare context keep working unchanged.

Methods

async def force_account_status(self,
account_id: str,
status: str,
*,
context: ToolContext | None = None) ‑> dict[str, Any]
Expand source code
async def force_account_status(
    self,
    account_id: str,
    status: str,
    *,
    context: ToolContext | None = None,
) -> dict[str, Any]:
    """Force an account to a given status.

    Returns:
        {"previous_state": str, "current_state": str}
    """
    raise NotImplementedError

Force an account to a given status.

Returns

{"previous_state": str, "current_state": str}

async def force_create_media_buy_arm(self,
arm: str,
task_id: str | None = None,
message: str | None = None,
*,
account: dict[str, Any] | None = None,
context: ToolContext | None = None) ‑> dict[str, Any]
Expand source code
async def force_create_media_buy_arm(
    self,
    arm: str,
    task_id: str | None = None,
    message: str | None = None,
    *,
    account: dict[str, Any] | None = None,
    context: ToolContext | None = None,
) -> dict[str, Any]:
    """Register a single-shot directive for the next create_media_buy call.

    The directive is consumed by the next create_media_buy call from the
    same authenticated sandbox account, then cleared. A second registration
    before consumption overwrites the first.

    Args:
        arm: Response arm — ``'submitted'`` or ``'input-required'``.
        task_id: Required when ``arm='submitted'``. The seller MUST emit
            this exact value on the next create_media_buy task envelope
            and accept it on subsequent tasks/get calls within the same
            sandbox account. Max 128 chars.
        message: Optional plain-text note surfaced on the response.
            Max 2000 chars.
        account: Caller-supplied account object from the MCP request.
            Implementations use this for single-shot-per-account isolation.
        context: Optional ToolContext from the server's context_factory.

    Returns:
        ForcedDirectiveSuccess::

            {"success": True, "forced": {"arm": str, "task_id"?: str}}

    Raises:
        TestControllerError: with code ``"NOT_FOUND"`` if the caller
            account is not recognized, or ``"INVALID_PARAMS"`` on
            validation failure.
    """
    raise NotImplementedError

Register a single-shot directive for the next create_media_buy call.

The directive is consumed by the next create_media_buy call from the same authenticated sandbox account, then cleared. A second registration before consumption overwrites the first.

Args

arm
Response arm — 'submitted' or 'input-required'.
task_id
Required when arm='submitted'. The seller MUST emit this exact value on the next create_media_buy task envelope and accept it on subsequent tasks/get calls within the same sandbox account. Max 128 chars.
message
Optional plain-text note surfaced on the response. Max 2000 chars.
account
Caller-supplied account object from the MCP request. Implementations use this for single-shot-per-account isolation.
context
Optional ToolContext from the server's context_factory.

Returns

ForcedDirectiveSuccess::

{"success": True, "forced": {"arm": str, "task_id"?: str}}

Raises

TestControllerError
with code "NOT_FOUND" if the caller account is not recognized, or "INVALID_PARAMS" on validation failure.
async def force_creative_status(self,
creative_id: str,
status: str,
rejection_reason: str | None = None,
*,
context: ToolContext | None = None) ‑> dict[str, Any]
Expand source code
async def force_creative_status(
    self,
    creative_id: str,
    status: str,
    rejection_reason: str | None = None,
    *,
    context: ToolContext | None = None,
) -> dict[str, Any]:
    """Force a creative to a given status.

    Returns:
        {"previous_state": str, "current_state": str}
    """
    raise NotImplementedError

Force a creative to a given status.

Returns

{"previous_state": str, "current_state": str}

async def force_media_buy_status(self,
media_buy_id: str,
status: str,
rejection_reason: str | None = None,
*,
context: ToolContext | None = None) ‑> dict[str, Any]
Expand source code
async def force_media_buy_status(
    self,
    media_buy_id: str,
    status: str,
    rejection_reason: str | None = None,
    *,
    context: ToolContext | None = None,
) -> dict[str, Any]:
    """Force a media buy to a given status.

    Returns:
        {"previous_state": str, "current_state": str}
    """
    raise NotImplementedError

Force a media buy to a given status.

Returns

{"previous_state": str, "current_state": str}

async def force_session_status(self,
session_id: str,
status: str,
termination_reason: str | None = None,
*,
context: ToolContext | None = None) ‑> dict[str, Any]
Expand source code
async def force_session_status(
    self,
    session_id: str,
    status: str,
    termination_reason: str | None = None,
    *,
    context: ToolContext | None = None,
) -> dict[str, Any]:
    """Force a session to a given status.

    Returns:
        {"previous_state": str, "current_state": str}
    """
    raise NotImplementedError

Force a session to a given status.

Returns

{"previous_state": str, "current_state": str}

async def force_task_completion(self,
task_id: str,
result: dict[str, Any],
*,
account: dict[str, Any] | None = None,
context: ToolContext | None = None) ‑> dict[str, Any]
Expand source code
async def force_task_completion(
    self,
    task_id: str,
    result: dict[str, Any],
    *,
    account: dict[str, Any] | None = None,
    context: ToolContext | None = None,
) -> dict[str, Any]:
    """Resolve a previously-submitted task to ``'completed'``.

    Isolation and idempotency contract:

    - **Cross-account replay** — raise ``TestControllerError("NOT_FOUND", ...)``
      when the task_id was registered by a different sandbox account.
    - **Identical-params replay** — idempotent; return the same
      ``StateTransitionSuccess``.
    - **Diverging-params replay** against a terminal task — raise
      ``TestControllerError("INVALID_TRANSITION", ...,
      current_state="completed")``.

    Args:
        task_id: Task handle to resolve. Max 128 chars.
        result: Completion payload (non-empty object). Implementations
            SHOULD validate it against the response branch for the task's
            original method and MUST reject payloads that fail that check
            with ``TestControllerError("INVALID_PARAMS", ...)``.
        account: Caller-supplied account object from the MCP request.
            Used for cross-account isolation.
        context: Optional ToolContext from the server's context_factory.

    Returns:
        StateTransitionSuccess::

            {"success": True, "previous_state": "submitted",
             "current_state": "completed"}

    Raises:
        TestControllerError: with code ``"NOT_FOUND"`` if the task_id
            is unknown or owned by a different account,
            ``"INVALID_TRANSITION"`` if the task is already terminal and
            params diverge, or ``"INVALID_PARAMS"`` on validation failure.
    """
    raise NotImplementedError

Resolve a previously-submitted task to 'completed'.

Isolation and idempotency contract:

  • Cross-account replay — raise TestControllerError("NOT_FOUND", ...) when the task_id was registered by a different sandbox account.
  • Identical-params replay — idempotent; return the same StateTransitionSuccess.
  • Diverging-params replay against a terminal task — raise TestControllerError("INVALID_TRANSITION", ..., current_state="completed").

Args

task_id
Task handle to resolve. Max 128 chars.
result
Completion payload (non-empty object). Implementations SHOULD validate it against the response branch for the task's original method and MUST reject payloads that fail that check with TestControllerError("INVALID_PARAMS", ...).
account
Caller-supplied account object from the MCP request. Used for cross-account isolation.
context
Optional ToolContext from the server's context_factory.

Returns

StateTransitionSuccess::

{"success": True, "previous_state": "submitted",
 "current_state": "completed"}

Raises

TestControllerError
with code "NOT_FOUND" if the task_id is unknown or owned by a different account, "INVALID_TRANSITION" if the task is already terminal and params diverge, or "INVALID_PARAMS" on validation failure.
async def seed_creative(self,
fixture: dict[str, Any] | None = None,
creative_id: str | None = None,
*,
context: ToolContext | None = None) ‑> dict[str, Any]
Expand source code
async def seed_creative(
    self,
    fixture: dict[str, Any] | None = None,
    creative_id: str | None = None,
    *,
    context: ToolContext | None = None,
) -> dict[str, Any]:
    """Pre-populate a creative fixture for storyboard tests (AdCP 3.0.1).

    Returns:
        {"creative_id": str}
    """
    raise NotImplementedError

Pre-populate a creative fixture for storyboard tests (AdCP 3.0.1).

Returns

{"creative_id": str}

async def seed_creative_format(self,
fixture: dict[str, Any] | None = None,
format_id: str | None = None,
*,
context: ToolContext | None = None) ‑> dict[str, Any]
Expand source code
async def seed_creative_format(
    self,
    fixture: dict[str, Any] | None = None,
    format_id: str | None = None,
    *,
    context: ToolContext | None = None,
) -> dict[str, Any]:
    """Pre-populate a creative format fixture for storyboard tests (AdCP 3.0.1).

    The seller MUST expose the seeded format_id in list_creative_formats
    responses for the duration of the compliance session.

    Returns:
        {"format_id": str}
    """
    raise NotImplementedError

Pre-populate a creative format fixture for storyboard tests (AdCP 3.0.1).

The seller MUST expose the seeded format_id in list_creative_formats responses for the duration of the compliance session.

Returns

{"format_id": str}

async def seed_media_buy(self,
fixture: dict[str, Any] | None = None,
media_buy_id: str | None = None,
*,
context: ToolContext | None = None) ‑> dict[str, Any]
Expand source code
async def seed_media_buy(
    self,
    fixture: dict[str, Any] | None = None,
    media_buy_id: str | None = None,
    *,
    context: ToolContext | None = None,
) -> dict[str, Any]:
    """Pre-populate a media buy fixture for storyboard tests (AdCP 3.0.1).

    Returns:
        {"media_buy_id": str}
    """
    raise NotImplementedError

Pre-populate a media buy fixture for storyboard tests (AdCP 3.0.1).

Returns

{"media_buy_id": str}

async def seed_plan(self,
fixture: dict[str, Any] | None = None,
plan_id: str | None = None,
*,
context: ToolContext | None = None) ‑> dict[str, Any]
Expand source code
async def seed_plan(
    self,
    fixture: dict[str, Any] | None = None,
    plan_id: str | None = None,
    *,
    context: ToolContext | None = None,
) -> dict[str, Any]:
    """Pre-populate a plan fixture for storyboard tests (AdCP 3.0.1).

    Returns:
        {"plan_id": str}
    """
    raise NotImplementedError

Pre-populate a plan fixture for storyboard tests (AdCP 3.0.1).

Returns

{"plan_id": str}

async def seed_pricing_option(self,
fixture: dict[str, Any] | None = None,
product_id: str | None = None,
pricing_option_id: str | None = None,
*,
context: ToolContext | None = None) ‑> dict[str, Any]
Expand source code
async def seed_pricing_option(
    self,
    fixture: dict[str, Any] | None = None,
    product_id: str | None = None,
    pricing_option_id: str | None = None,
    *,
    context: ToolContext | None = None,
) -> dict[str, Any]:
    """Pre-populate a pricing option fixture for storyboard tests (AdCP 3.0.1).

    Returns:
        {"pricing_option_id": str}
    """
    raise NotImplementedError

Pre-populate a pricing option fixture for storyboard tests (AdCP 3.0.1).

Returns

{"pricing_option_id": str}

async def seed_product(self,
fixture: dict[str, Any] | None = None,
product_id: str | None = None,
*,
context: ToolContext | None = None) ‑> dict[str, Any]
Expand source code
async def seed_product(
    self,
    fixture: dict[str, Any] | None = None,
    product_id: str | None = None,
    *,
    context: ToolContext | None = None,
) -> dict[str, Any]:
    """Pre-populate a product fixture for storyboard tests (AdCP 3.0.1).

    Returns:
        {"product_id": str}
    """
    raise NotImplementedError

Pre-populate a product fixture for storyboard tests (AdCP 3.0.1).

Returns

{"product_id": str}

async def simulate_budget_spend(self,
spend_percentage: float,
account_id: str | None = None,
media_buy_id: str | None = None,
*,
context: ToolContext | None = None) ‑> dict[str, Any]
Expand source code
async def simulate_budget_spend(
    self,
    spend_percentage: float,
    account_id: str | None = None,
    media_buy_id: str | None = None,
    *,
    context: ToolContext | None = None,
) -> dict[str, Any]:
    """Simulate budget spend to a percentage.

    Returns:
        {"simulated": {...}}
    """
    raise NotImplementedError

Simulate budget spend to a percentage.

Returns

{"simulated": {…}}

async def simulate_delivery(self,
media_buy_id: str,
impressions: int | None = None,
clicks: int | None = None,
conversions: int | None = None,
reported_spend: dict[str, Any] | None = None,
*,
context: ToolContext | None = None) ‑> dict[str, Any]
Expand source code
async def simulate_delivery(
    self,
    media_buy_id: str,
    impressions: int | None = None,
    clicks: int | None = None,
    conversions: int | None = None,
    reported_spend: dict[str, Any] | None = None,
    *,
    context: ToolContext | None = None,
) -> dict[str, Any]:
    """Simulate delivery metrics for a media buy.

    Returns:
        {"simulated": {...}, "cumulative": {...} | None}
    """
    raise NotImplementedError

Simulate delivery metrics for a media buy.

Returns

{"simulated": {…}, "cumulative": {…} | None}

class TmpHandler
Expand source code
class TmpHandler(ADCPHandler[TContext], Generic[TContext]):
    """Handler for Temporal Matching Protocol operations.

    Subclass this to implement context matching and identity matching.
    Only TMP tools will be exposed via MCP.

    Example:
        class MyTmpAgent(TmpHandler):
            async def context_match(self, params, context=None):
                # Evaluate context signals against buyer packages
                pass

            async def identity_match(self, params, context=None):
                # Evaluate user identity for package eligibility
                pass
    """

    _agent_type = "TMP agents"

Handler for Temporal Matching Protocol operations.

Subclass this to implement context matching and identity matching. Only TMP tools will be exposed via MCP.

Example

class MyTmpAgent(TmpHandler): async def context_match(self, params, context=None): # Evaluate context signals against buyer packages pass

async def identity_match(self, params, context=None):
    # Evaluate user identity for package eligibility
    pass

Ancestors

Inherited members

class ToolContext (request_id: str | None = None,
caller_identity: str | None = None,
tenant_id: str | None = None,
metadata: dict[str, Any] = <factory>,
resolved_adcp_version: str | None = None)
Expand source code
@dataclass
class ToolContext:
    """Context passed to tool handlers.

    Contains metadata about the current request that may be useful
    for logging, authorization, or other cross-cutting concerns.

    Subclassing is supported. Multi-tenant agents commonly define a
    subclass carrying typed tenant + adapter fields (see
    ``docs/handler-authoring.md``) and populate it from a
    ``context_factory`` passed to :func:`create_mcp_server`.

    :param caller_identity: The authenticated principal making the request.
        **MUST** be a stable, globally-unique identifier within the seller's
        tenant — never an email, display name, or any other mutable handle.
        The server-side idempotency middleware keys its cache by
        ``(caller_identity, idempotency_key)`` — reuse of the same string for
        two distinct principals (e.g. email reuse after account deletion)
        causes cross-principal replay (confidentiality leak). Populated by
        the transport layer (A2A: ``ServerCallContext.user.user_name``; MCP:
        seller's FastMCP auth middleware).
    :param tenant_id: Multi-tenant agents may populate this with the tenant
        the request is scoped to. Typed as a first-class field so
        multi-tenant handlers don't have to smuggle it through ``metadata``.
        The server-side idempotency middleware composes the cache scope key
        from ``(tenant_id, caller_identity)`` when ``tenant_id`` is set —
        sellers whose principal IDs are only unique *within* a tenant (Okta
        group-scoped, SCIM per-tenant, seller-internal employee IDs) **MUST**
        populate this so cross-tenant response replay can't happen. When
        unset, the scope collapses to ``caller_identity`` alone (safe for
        single-tenant deployments).
    :param metadata: Open extension point for transport-specific or
        agent-specific fields (e.g. adapter instance handles, request
        headers, testing hooks). Downstream agents may subclass
        :class:`ToolContext` for typed fields; ``metadata`` is the escape
        hatch when subclassing isn't worth it.
    :param resolved_adcp_version: Release-precision AdCP version resolved by
        the transport dispatcher for this request. MCP unversioned native
        traffic is pinned to ``"3.0"`` for compatibility; A2A unversioned
        traffic leaves this as ``None`` and is served as the current SDK
        shape. Handlers should read this only when business logic truly
        depends on the buyer's wire contract.
    """

    request_id: str | None = None
    caller_identity: str | None = None
    tenant_id: str | None = None
    metadata: dict[str, Any] = field(default_factory=dict)
    resolved_adcp_version: str | None = None

Context passed to tool handlers.

Contains metadata about the current request that may be useful for logging, authorization, or other cross-cutting concerns.

Subclassing is supported. Multi-tenant agents commonly define a subclass carrying typed tenant + adapter fields (see docs/handler-authoring.md) and populate it from a context_factory passed to :func:create_mcp_server().

:param caller_identity: The authenticated principal making the request. MUST be a stable, globally-unique identifier within the seller's tenant — never an email, display name, or any other mutable handle. The server-side idempotency middleware keys its cache by (caller_identity, idempotency_key) — reuse of the same string for two distinct principals (e.g. email reuse after account deletion) causes cross-principal replay (confidentiality leak). Populated by the transport layer (A2A: ServerCallContext.user.user_name; MCP: seller's FastMCP auth middleware). :param tenant_id: Multi-tenant agents may populate this with the tenant the request is scoped to. Typed as a first-class field so multi-tenant handlers don't have to smuggle it through metadata. The server-side idempotency middleware composes the cache scope key from (tenant_id, caller_identity) when tenant_id is set — sellers whose principal IDs are only unique within a tenant (Okta group-scoped, SCIM per-tenant, seller-internal employee IDs) MUST populate this so cross-tenant response replay can't happen. When unset, the scope collapses to caller_identity alone (safe for single-tenant deployments). :param metadata: Open extension point for transport-specific or agent-specific fields (e.g. adapter instance handles, request headers, testing hooks). Downstream agents may subclass :class:ToolContext for typed fields; metadata is the escape hatch when subclassing isn't worth it. :param resolved_adcp_version: Release-precision AdCP version resolved by the transport dispatcher for this request. MCP unversioned native traffic is pinned to "3.0" for compatibility; A2A unversioned traffic leaves this as None and is served as the current SDK shape. Handlers should read this only when business logic truly depends on the buyer's wire contract.

Subclasses

Instance variables

var caller_identity : str | None
var metadata : dict[str, typing.Any]
var request_id : str | None
var resolved_adcp_version : str | None
var tenant_id : str | None
class UnsupportedVersionError (wire_value: str | int, supported: tuple[str, ...])
Expand source code
class UnsupportedVersionError(ValueError):
    """The wire version the buyer claims isn't supported by this server.

    Carries the original wire value plus the supported list so the
    dispatcher can echo both into ``VERSION_UNSUPPORTED`` error details.
    """

    def __init__(self, wire_value: str | int, supported: tuple[str, ...]) -> None:
        self.wire_value = wire_value
        self.supported = supported
        super().__init__(
            f"AdCP version {wire_value!r} is not supported by this server "
            f"(supported release-precision versions: {list(supported)})."
        )

The wire version the buyer claims isn't supported by this server.

Carries the original wire value plus the supported list so the dispatcher can echo both into VERSION_UNSUPPORTED error details.

Ancestors

  • builtins.ValueError
  • builtins.Exception
  • builtins.BaseException