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-populatesvalid_actionsfrom 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_capabilitiesfrom 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.ToolContextfrom auth state the :class:BearerTokenAuthMiddlewarepopulated 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:
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 StarletteRequestvia the upstream MCPrequest_ctxcontextvar). Works on both stateless and stateful streamable-http.- Module-level :data:
current_principaletc. 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 readNonebecause the session task is a separate task.
Populates
caller_identity,tenant_id, and ametadatadict containing the transport + tool name plus anything the :class:Principalprovided. SDK-owned keys (tool_name,transport) take precedence over principal-supplied keys, so a validator returningPrincipal(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.AuthInfowhen the request is authenticated, so :meth:~adcp.decisioning.PlatformHandler._extract_auth_infosurfaces a non-None:attr:~adcp.decisioning.RequestContext.auth_infofor bearer flows — the same typed surface signed-request flows already populate.credentialisNonefor bearer flows because inbound bearer tokens are not for upstream propagation; adopters who need :class:~adcp.decisioning.BuyerAgentRegistrydispatch must supply a typed credential in a customcontext_factorysubclass.adcp.auth_infois server-internal and never wire-echoed by the framework. Do not passctx.metadatawholesale to a JSON serializer — theAuthInfoobject 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 manifestBuild 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 contactnamefield. :param transports: Transports the binary serves.["mcp"],["a2a"], or["mcp", "a2a"]fortransport="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>/mcpfor 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 requiresminItems: 1so 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 respBuild 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 respBuild 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_testingis NOT a protocol — pass it via thecompliance_testingkwarg. 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_versionsexactly. When set, included on the response envelope so buyers can read what release the server actually served. Typically passed byADCPServerBuilder'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.idempotencyper AdCP #2315. Pass the output of :meth:IdempotencyStore.capability()here to declare the seller'sreplay_ttl_seconds. compliance_testing- Optional top-level
compliance_testingblock to advertise compliance-testing capabilities. When provided, emitted as a sibling ofadcpin 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 NoneLook up a token in a dict of SHA-256 hashes using :func:
hmac.compare_digestrather 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 withcompare_digestmakes 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_digestcheck on the single returned row.:param token: Raw bearer token supplied by the client. :param stored_hashes:
{sha256_hex: value}dictionary. Returnsvalueon the matching entry,Noneon 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 appCreate 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:
ToolContextfrom :class:RequestMetadata. Mirrors the MCP-sidecontext_factory=on :func:~adcp.server.create_mcp_serverso a single factory populates tenant/adapter fields on both transports. When unset, the executor falls back to derivingcaller_identityfromServerCallContext.user— preserving pre-factory behavior. See :data:~adcp.server.ContextFactoryfor the recommended contextvars pattern. task_store- Optional a2a-sdk :class:
~a2a.server.tasks.task_store.TaskStoreinstance 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.). Seeexamples/a2a_db_tasks.pyfor a reference SQLite-backed implementation anddocs/handler-authoring.mdfor the persistence caveats on the default store. push_config_store-
Optional a2a-sdk :class:
~a2a.server.tasks.push_notification_config_store.PushNotificationConfigStoreinstance for persisting push-notification configs that clients register viatasks/pushNotificationConfig/set. When unset, a2a-sdk surfaces push-notif endpoints asUnsupportedOperationError— clients cannot register subscriptions at all. Set this only when your agent is ready to accept push-notif subscriptions. Seeexamples/a2a_db_tasks.pyfor a reference SQLite-backed implementation that pairs with theSqliteTaskStorethere.Security note: unlike
TaskStore, a2a-sdk'sPushNotificationConfigStoreABC does not pass aServerCallContexttoset_info/get_info/delete_info. Scoping by principal has to happen out-of-band (via aContextVaryour auth middleware populates) or by composition with a tenant-scopedTaskStore— the reference impl shows the ContextVar pattern. middleware- Optional sequence of :data:
~adcp.server.SkillMiddlewarecallables 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.SkillMiddlewarefor the signature, composition semantics, and the exception-capture pattern audit hooks need. message_parser- Optional :data:
MessageParserfor 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:MessageParserand :meth:ADCPAgentExecutor._default_parse_requestfor 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 toFalse, which reflects only overridden methods in the agent card'sskillslist and in the executor's tool-caller registry. Turn on for spec-compliance storyboards or when the agent deliberately wants clients to see anot_supported()tool. validation- :class:
ValidationHookConfigenabling 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). PassValidationHookConfig(responses="warn")to log+continue on response drift, orvalidation=Noneto disable validation entirely. auth- Optional :class:
~adcp.server.auth.BearerTokenAuthconfig. When supplied, the agent card publishes a matchingbearerAuthsecurity scheme + requirement so a2a-sdk's client auth interceptor attaches credentials automatically. Note thatcreate_a2a_server()does not install the request-time middleware itself — auth gating is wired by :func:serve()via :class:A2ABearerAuthMiddlewareat the ASGI layer. Adopters callingcreate_a2a_server()directly must wrap the returned app with :class:A2ABearerAuthMiddlewarethemselves. public_url-
Public base URL for the A2A agent card (
/.well-known/agent-card.json). Accepts either a static string or a :data:PublicUrlResolvercallable for per-request resolution.Static string — replaces
http://localhost:{port}/in everysupported_interfacesURL. Falls back to thePUBLIC_URLenvironment variable whenpublic_urlisNone. Correct for single-host or fixed-URL deployments.Callable — receives the Starlette :class:
~starlette.requests.Requeston each card fetch and must return an absolutehttps://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_routesis bypassed in favour of an ASGI-layer intercept that builds the card per-request. TheDefaultRequestHandler's internalGetAgentCardRPC path retains alocalhostfallback card — buyers probing the well-known endpoint always receive the per-request card.The
PUBLIC_URLenv-var fallback applies only whenpublic_urlisNone; a callable takes priority. response_enhancer- Optional server-wide
:data:
~adcp.server.ResponseEnhancerapplied to every response — successes,adcp_error()envelopes, and thecomply_test_controllerskill — after the context echo and (for successes) before schema validation. Mirrors the MCP-sidecreate_mcp_server(response_enhancer=...)so a single callback stamps both transports. See :data:~adcp.server.ResponseEnhancerfor 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 mcpCreate 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_controlleras a handler tool. Sellers who want compliance-testing support should passtest_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:
ToolContextfrom 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 returnscaller_identity=Noneeffectively disables idempotency dedup. Sellers wiring their own HTTP auth middleware pass this to inject the authenticated principal intoToolContext.caller_identity. See :data:ContextFactoryfor the recommended contextvars pattern. WhenNone, handlers receive a bareToolContext(no caller identity, no tenant). middleware- Optional sequence of :data:
SkillMiddlewarecallables wrapping every tool dispatch. Symmetric with A2A'screate_a2a_server(middleware=...)— the same list works on both transports. Use for audit logging, rate limiting, tracing, activity-feed hooks. See :data:SkillMiddlewarefor 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 toFalse, which shrinkstools/listto only the tools the handler actually implements (subclass overrode the method). See :func:~adcp.server.get_tools_for_handlerfor semantics; useTruefor spec-compliance storyboards or when you deliberately want to expose anot_supported()tool. host- Network interface to bind to. Defaults to the
ADCP_HOSTenvironment 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 oneapplication/jsonresponse 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 toTrueonly 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 subsequenttools/callposts skip the per-request transport- construction tax — meaningfully faster for chatty clients, and the only mode whereStreamableHTTPSessionManager'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 StarletteRequestintoRequestMetadata.request_context; the bundled :func:~adcp.server.auth_context_factoryreads auth offrequest.stateand works in both stateless and stateful. Custom factories using :mod:contextvarsset 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 onMcp-Session-Id; setstateless_http=Trueonly when affinity isn't possible. Do not memoize per-call state onmcp.Contextor 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
Noneto disable reaping. Ignored whenstateless_http=True. Required because without itStreamableHTTPSessionManager._server_instancesgrows 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-Idcontinue. Set this on public or service-to-service sellers that need a hard ceiling against clients opening one session per operation. Ignored whenstateless_http=True. response_enhancer- Optional server-wide
:data:
~adcp.server.ResponseEnhancerapplied to successes and raised-error responses after the context echo and, for successes, before schema validation. See :data:~adcp.server.ResponseEnhancerfor 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, ormcp.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:
-
Reverse-proxy auth (simplest): the proxy (nginx, Caddy, Envoy) validates credentials and forwards only authenticated requests. The SDK trusts the proxy's decision.
-
In-process HTTP middleware: call
mcp.streamable_http_app()to get the Starlette app, thenapp.add_middleware(YourAuthMiddleware). The middleware extracts auth state per request (token, tenant, principal) into ContextVars;context_factoryreads those to build a typedToolContext. Tools in :data:adcp.server.DISCOVERY_TOOLS(get_adcp_capabilities) should bypass auth per AdCP spec. Seeexamples/mcp_with_auth_middleware.pyanddocs/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_callerfor mode semantics. pre_validation_hooks- Optional dict mapping tool name to a
(tool_name, args) -> argscallable or ordered sequence. Applied before schema + Pydantic validation. See :func:create_tool_caller. response_enhancer- Optional server-wide :data:
ResponseEnhancerapplied 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:
Tenantfor the current request.Returns
Noneoutside the middleware's request scope, or when the request isn't tenant-routed (e.g., health-check paths excluded from the middleware).Adopter
context_factorycallbacks read this and write the tenant id onto :attr:ToolContext.tenant_idso 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()fromadcp.server.helpersinstead. 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 withNOT_SUPPORTED. This keepstools/listsmall 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=Trueto 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 anot_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 responseAuto-inject context passthrough from request into response.
ADCP requires that if a request contains a
contextfield, 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_dateandupdated_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_serializeunchanged — 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:
Routeserving 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_updatedreflects 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 respBuild 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=Noneexplicitly 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. Treatrevisionas the optimistic-concurrency token clients pass back onupdate_media_buy. Treatconfirmed_atas 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=Noneis only schema-valid for AdCP 3.1+ response shapes; whenadcp_version="3.0"is requested this helper raisesValueErrorrather than emitting 3.0-invalidnull. Passadcp_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"plusmedia_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 respBuild a get_products response.
Matches GetProductsResponse schema, including beta 3 wholesale feed metadata for cache/version-aware enumeration. Pass
cache_scopeexplicitly for spec-valid wholesale responses; the dispatcher only inferspublicfor 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_TOOLSto filtertools/listper handler subclass; without registration, anADCPHandlersubclass that introduces a new specialism would fall through to its parent's tool set (typicallyADCPHandler's full-spec surface), over-advertising. Codegen targets likePlatformHandlerregister here at class definition time viaADCPHandler.__init_subclass__; hand-written custom bases call this directly beforeserve(). 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 — typicallycls.__name__from inside__init_subclass__. :param tools: Iterable of AdCP tool names this handler answers (members ofADCP_TOOL_DEFINITIONS). Order doesn't matter. :raises ValueError: whenhandler_nameis 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"] = toolRegister 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
ContextFactoryinvoked per call to build a :class:ToolContext. When set, the context is threaded into store methods that declare acontextkeyword — which is how sellers whose test runtime reads request headers (AdCPTestContext.from_headers) combine header-driven mock state with the storyboard-drivencomply_test_controllerskill. 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_ALLsentinel for tests that opt out of the gate. The comply controller applies the Phase 1 sandbox-authority gate against the resolved account: only accounts withmode in {'sandbox', 'mock'}(or legacysandbox=True) are admitted;mode='live'is denied regardless of wire signals. v6 :class:DecisioningPlatformadopters get this hooked automatically bydecisioning.serve. Adopters wiring the controller manually pass a closure over their own account store.Default fail-closed. When
NoneANDADCP_SANDBOXis unset, every comply call is denied — manually-wiredADCPHandler/ :class:ComplianceHandlerdeployments are protected by default. Tests that intentionally bypass the gate passaccount_resolver=INSECURE_ALLOW_ALL; dev servers can setADCP_SANDBOX=1instead.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, NoneResolve 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
AccountErrorfor 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 NoneResolve 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
Noneon 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
accountkey with anAccountReference. :param context: The handler's context. Must be :class:~adcp.server.AccountAwareToolContext(or a subclass of it) to receive the resolved fields. Passing a plainToolContextruns resolution for the error path but logs aUserWarning— 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.Accounttype. 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 defaultReturn 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_versionwins and is normalized to release precision; - legacy
adcp_major_versionmaps 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. - explicit
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
ADCPHandlerinstance or anADCPServerBuilder(fromadcp_server()). Builders are auto-converted viabuild_handler().This is the simplest way to run an ADCP agent. Set
transport="a2a"to serve over the A2A protocol instead of MCP, ortransport="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:
ServeConfigbundle. When supplied, all field values come from it and any individual kwargs passed alongside are ignored. Usedataclasses.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/mcpand A2A at/). Use"both"when you want adopters on either protocol to reach the same handler with sharedcontext_factory+middlewarewiring — 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:
ToolContextper tool call — see :data:ContextFactory. task_store- Optional a2a-sdk
TaskStorefor durable A2A task persistence (A2A transport only). Defaults toInMemoryTaskStore— tasks don't survive restart. Seeexamples/a2a_db_tasks.pyfor the production pattern. push_config_store- Optional a2a-sdk
PushNotificationConfigStorefor push-notif subscription persistence (A2A transport only). When unset, a2a-sdk surfaces the push-notif endpoints asUnsupportedOperationError— clients cannot register subscriptions at all. Seeexamples/a2a_db_tasks.pyfor a durable reference implementation. middleware- Optional sequence of :data:
SkillMiddlewarecallables 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:SkillMiddlewarefor 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 onstdio.Each entry is either a
(MiddlewareClass, kwargs)tuple invoked ascls(app, **kwargs), or a callable factoryf(app) -> app. Both forms can appear in the same list.Middleware sees
lifespanandwebsocketscopes in addition tohttp— 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.MessageParsercallable for alternative A2A wire shapes (A2A transport only). The default parser handlesDataPart(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/listonly shows tools the handler actually implements, which dramatically shrinks the advertised surface. Turn on for spec-compliance storyboards or when you want to signalnot_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
0to 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_HOSTenvironment 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 oneapplication/jsonresponse 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 reportoverall_status: "unreachable". Set toTrueonly if your tools genuinely emit progress notifications and your clients consume the SSE stream (MCP transports only). Note: the legacytransport="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 subsequenttools/callposts skip the transport-construction tax — meaningfully faster for chatty clients, and the only mode whereStreamableHTTPSessionManager'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 StarletteRequestintoRequestMetadata.request_contextin both modes socontext_factorycan read auth offrequest.state; the bundled :func:auth_context_factory()already does this. SetTruefor stateless deployments — multi-replica without sticky LB onMcp-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);
Nonedisables reaping. Ignored whenstateless_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-Idcontinue. Ignored whenstateless_http=True. enable_debug_endpoints- When
True, mount debug routes on the outer HTTP app.GET /_debug/trafficreturns the JSON dict fromdebug_traffic_source()— typically wired to the seller's :class:MockAdServer.get_traffic().GET /_debug/sessionsreturns the JSON dict fromsession_count_source(). Defaults toFalseso 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 whenenable_debug_endpoints=Trueand nosession_count_sourceis supplied; otherwise ignored. Typicallymock_ad_server.get_traffic. session_count_source- Zero-arg callable returning the active
MCP session snapshot for
/_debug/sessions. Required whenenable_debug_endpoints=Trueand nodebug_traffic_sourceis supplied; otherwise ignored. debug_validate_request- Optional callable used to authorize
/_debug/*requests. Receives lower-case request headers and returnsTrueto serve the debug response. Required whenenable_debug_endpoints=Trueunlessdebug_public=Trueis set. debug_public- Set
Trueonly for local/storyboard runners where the debug routes are intentionally unauthenticated. Defaults toFalseso network-reachable deployments must opt into public debug visibility explicitly or supplydebug_validate_request. base_url- Optional public origin URL for the binary, used to
populate the
urlfield of each entry in the/.well-known/adcp-agents.jsondiscovery manifest. Adopters behind a TLS-terminating reverse proxy SHOULD set this (e.g."https://sales.example.com"). WhenNonethe manifest URLs fall back tohttp://<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.discoveryfor 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
descriptionfield. validation-
:class:
ValidationHookConfigenabling schema validation of every request and response against the bundled AdCP JSON schemas.requests="strict"raisesVALIDATION_ERRORbefore 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 thepricing_optionsregression past Pydanticextra="allow"silently swallowing an unknown shape. Adopters mid-migration who need response drift to warn rather than fail passValidationHookConfig(responses="warn"); adopters who want validation off entirely passValidationHookConfig(requests="off", responses="off")orvalidation=None. Applies to both MCP and A2A transports. response_enhancer- Optional server-wide
:data:
~adcp.server.ResponseEnhancerapplied to framework-tool successes, custom-tool successes, the pre-authget_adcp_capabilitiesdiscovery 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 asVALIDATION_ERRORrather 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.ResponseEnhancerfor 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.BearerTokenAuthconfig applied to MCP, A2A, andtransport="both"legs from the same source of truth. Drives MCP's :class:~adcp.server.auth.BearerTokenAuthMiddlewareand A2A's :class:~adcp.server.auth.BearerTokenContextBuilder. On A2A,/.well-known/agent-card.jsonstays publicly accessible per A2A spec §4.1 — the agent-card route is registered separately and never invokes the builder. On stdio,adcp.server.authis ignored with a warning (no HTTP layer). For non-bearer schemes (mTLS, signed-request derivation), wire your own middleware viaasgi_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.PublicUrlResolvercallable 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:
LifespanHookzero-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 vialifespan.startup.failed. Today honored only ontransport="both"; passing on any other transport raises :class:ValueErrorat boot rather than silently dropping the hook. Seeexamples/scheduler_lifespan.py. on_shutdown: Optional sequence of :data:LifespanHookzero-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 inlogger.error. Sametransport="both"restriction ason_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 respBuild 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_scopeexplicitly for spec-valid wholesale responses; the dispatcher only inferspublicfor 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 claimedadcp_versioninstead 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 claimingadcp_major_version=2oradcp_version="2.5"automatically route through it.Calling this function emits a :class:
DeprecationWarning. Migrate by removing thepre_validation_hooks=spec_compat_hooks()argument from yourserve()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:
_serializeso loose-dict adopters who spread an inputgovernance_agents(withauthentication) orbilling_entity(withbank) 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:
_serializeso loose-dict adopters who spread an inputgovernance_agents(withauthentication) 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 respBuild an update_media_buy success response.
Matches UpdateMediaBuyResponse1 (success) schema. Auto-populates valid_actions from status if not provided.
revisionis the new optimistic-concurrency token after the update; clients should use it on their next mutatingupdate_media_buycall. Current AdCP response shapes requirerevision. Only explicit pre-3.1 compatibility output may omit it. Passadcp_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"plusmedia_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_buyactions 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_MACHINEto 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 warningsCheck 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 declarereadOnlyHint: 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
toolsis 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 _validateBuild a :data:
TokenValidatorfrom 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_digeston 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.jsonand 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 intoscope['user']and the principal intoscope['auth'], matching Starlette's :class:AuthenticationMiddlewarecontract. The default builder readsscope['user']and adapts it via :class:a2a.server.routes.common.StarletteUser, so downstream handlers seeServerCallContext.user.user_namepopulated with the principal'scaller_identitywithout a custom builder.Also populates :data:
current_principal, :data:current_tenant(), and :data:current_principal_metadatafor the duration of the downstream call — symmetric with :class:BearerTokenAuthMiddleware's contract. Adopters readingcurrent_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. Seeserve.py:_build_mcp_and_a2a_appfor 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:
ToolContextsubclass —class MyAgent(ADCPHandler[MyContext])— to get typedcontextarguments on every method signature. See :data:TContextfor the pattern.For protocol-specific handlers, use: - ContentStandardsHandler: For content standards agents - SponsoredIntelligenceHandler: For sponsored intelligence agents - GovernanceHandler: For governance agents
Tool advertisement (
advertised_toolsclass attribute):A subclass that introduces a new specialism — i.e., a custom base that needs its own
tools/listfilter 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_toolswith :func:register_handler_tools()at class definition time. Subclasses that DON'T introduce a new specialism (a customMyContentAgent(ContentStandardsHandler), for example) inherit their parent's tool set unchanged — no class attr needed.Hand-written equivalent (no
advertised_toolsdeclaration)::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
- PlatformHandler
- BrandHandler
- ComplianceHandler
- ContentStandardsHandler
- GovernanceHandler
- SponsoredIntelligenceHandler
- TmpHandler
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_idplus 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_idswaps 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 toserve().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_capabilitiesis 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_versionReturn the AdCP protocol release this server is pinned to.
Resolved at construction from the
adcp_versionkwarg, with fallback to the SDK's compile-time pin (ADCP_VERSIONpackaged 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 = NoneToolContext subclass carrying a resolved account scope.
AdCP is account-aware: many operations accept an
accountfield (:class:~adcp.types.AccountReference) that the seller resolves to a concrete account before executing the request. Handlers that needaccount_idthroughout 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_iddirectly in theircontext_factoryand 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 asAnyso sellers aren't forced to match the SDK's shape.Ancestors
Instance variables
var account : typing.Any | Nonevar 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 accountAncestors
- 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_tokencallback into both the MCP-side :class:BearerTokenAuthMiddlewareand the A2A-side :class:A2ABearerAuthMiddleware. Pass viaserve(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
401body. On A2A, requests without a valid token receive an HTTP401. 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 forBearerTokenAuth(validate_token=...)with no other knobs and you get the protocol-canonical setup — including abearerAuthHTTPAuthSecurityScheme(scheme="bearer") auto-published on the agent card so a2a-sdk-based clients attach credentials without seller-side intervention.x-adcp-authis a legacy-compat alias, not a recommended default. Some early MCP adopters baked in a customx-adcp-authheader carrying a raw token (no scheme prefix) before the spec settled. Sellers with deployed clients that can't be updated opt in additively —Authorization: Beareris 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-
Authorizationheader on the A2A leg is discouraged — buyers using non-a2a-sdk HTTP clients may not parse the resulting :class:APIKeySecuritySchemeshape, and you lose interop with off-the-shelf A2A tooling. Use only when you control every buyer client.Legacy single-knob compatibility.
header_nameandbearer_prefix_requiredare still accepted: when set, they apply to both legs and override the per-leg defaults. Setting bothheader_nameand a per-leg*_header_name(or bothbearer_prefix_requiredand 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_toolsto allow extratools/callnames through unauthenticated. The spec default is justget_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:BearerTokenAuthMiddlewaredirectly withdiscovery_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 | Nonevar a2a_header_name : str | Nonevar a2a_legacy_header_aliases : collections.abc.Sequence[str] | Nonevar allow_unauthenticated : boolvar bearer_prefix_required : bool | Nonevar header_name : str | Nonevar legacy_aliases_bearer_prefix_required : boolvar legacy_header_aliases : collections.abc.Sequence[str] | Nonevar mcp_bearer_prefix_required : bool | Nonevar mcp_discovery_tools : collections.abc.Collection[str] | Nonevar mcp_header_name : str | Nonevar mcp_legacy_header_aliases : collections.abc.Sequence[str] | Nonevar unauthenticated_response : dict[str, typing.Any] | Nonevar validate_token : SyncTokenValidator | 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 TrueEffective A2A bearer-prefix flag after legacy + default fallback.
Resolution order: legacy
bearer_prefix_required→a2a_bearer_prefix_required→True(RFC 6750 — the canonical setup isAuthorization: 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_name→a2a_header_name→"Authorization"(RFC 6750 — what a2a-sdk and every off-the-shelf HTTP library send by default). Settinga2a_header_nameto anything else is discouraged: buyers using non-a2a-sdk HTTP clients may not parse the resulting :class:APIKeySecuritySchemeshape 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_aliasesfor 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 TrueEffective MCP bearer-prefix flag after legacy + default fallback.
Resolution order: legacy
bearer_prefix_required→mcp_bearer_prefix_required→True(RFC 6750 — the canonical setup isAuthorization: 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
Noneto use the spec-mandated default (:data:adcp.server.mcp_tools.DISCOVERY_TOOLS).Returns a :class:
frozensetso 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.Nonemeans 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_name→mcp_header_name→"authorization"(RFC 6750, the protocol-canonical carrier on MCP). Adopters with legacy clients sendingx-adcp-authopt in viamcp_header_name; the default itself stays onAuthorizationbecause 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: Beareris ALWAYS accepted; this method returns the additional header names accepted alongside. Sources, in order:legacy_header_aliases(cross-leg, new-shape).mcp_legacy_header_aliases(per-leg, new-shape).- Deprecated
header_namewhen set to a non-canonical value (folded in as an alias for back-compat). - Deprecated
mcp_header_namewhen set to a non-canonical value (same).
Returns an empty list when the adopter wants spec-canonical only (no legacy clients to keep working).
- MCP:
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_middlewarewith 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_metadatafor the duration of the downstream call. On failure, returns401without invoking the handler.Discovery bypass.
initialize,notifications/initialized, andtools/list(MCP handshake) plusget_adcp_capabilities(AdCP handshake) are always exempt — these run before any client has credentials. Passdiscovery_tools=to widen thetools/callgate 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_requestto tighten the bypass (e.g. require auth ontools/list).Body is peeked, not consumed. The middleware reads the JSON-RPC payload to identify the
method/toolname 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_middlewaresupplies 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-canonicalAuthorization: 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. DefaultFalse— legacy custom-header schemes (x-adcp-auth: <token>) carry the raw token. RFC 6750Authorizationalways 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 tolegacy_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'ssecurity_baseline/probe_api_keyprobe) sendsAuthorization: Bearerand was getting 401 against valid tokens. The new behavior is ADDITIVE; passlegacy_header_aliases=[...]explicitly for new code. See #720. :param bearer_prefix_required: DEPRECATED. Maps tolegacy_aliases_bearer_prefix_requiredwhenheader_nameis also set. Ignored whenheader_nameis the canonical"authorization"(which always requiresBearer). :param discovery_tools: Optional override for the per-instancetools/calldiscovery 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_toolsTrue 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:- 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. - 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/callonly when accompanied by a specific header).
Operators who want to tighten — e.g. require auth on
tools/list— still need to subclass;discovery_toolsonly widens. - Pass
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
- ADCPHandler
- abc.ABC
- typing.Generic
Inherited members
ADCPHandler:acquire_rightsactivate_signalbuild_creativecalibrate_contentcheck_governancecomply_test_controllercontext_matchcreate_collection_listcreate_content_standardscreate_media_buycreate_property_listdelete_collection_listdelete_property_listget_account_financialsget_adcp_capabilitiesget_brand_identityget_collection_listget_content_standardsget_creative_deliveryget_creative_featuresget_media_buy_artifactsget_media_buy_deliveryget_media_buysget_plan_audit_logsget_productsget_property_listget_rightsget_signalsget_task_statusidentity_matchlist_accountslist_collection_listslist_content_standardslist_creative_formatslist_creativeslist_property_listslist_taskslist_transformerslog_eventpreview_creativeprovide_performance_feedbackreport_plan_outcomereport_usagesi_get_offeringsi_initiate_sessionsi_send_messagesi_terminate_sessionsync_accountssync_audiencessync_catalogssync_creativessync_event_sourcessync_governancesync_plansupdate_collection_listupdate_content_standardsupdate_media_buyupdate_property_listupdate_rightsvalidate_content_deliveryvalidate_inputverify_brand_claimverify_brand_claims
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:
SubdomainTenantRouterfor DB-backed lookups.The adopter passes a single callable mapping a normalized host to a :class:
Tenant(orNonefor 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_secondsceiling is the explicit knob.Negative-cache + tenant onboarding race. When caching is enabled,
Noneresults are cached too (to absorb probing for unknown hosts). This creates a race on tenant creation: if a probe foracme.example.comhits at T=0 (host doesn't exist yet) and the tenant is provisioned at T=1, the cachedNonecauses 404s for up tocache_ttl_secondsafterward. Callinvalidate(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_sizeentries. Maximum memory iscache_size × (sizeof(host_str) + sizeof(your_Tenant) + 16)wheresizeof(your_Tenant)depends on what you store in :attr:Tenant.ext— the router can't predict it. The cache never grows beyondcache_sizeentries 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:portsuffix stripped. :param cache_size: Maximum number of cached lookups.0disables caching entirely (the adopter callable is awaited on every request). Must be>= 0. :param cache_ttl_seconds: Per-entry TTL in seconds. Must be> 0whencache_size > 0. There is no "cache forever" mode — see the class docstring for rationale. :raises ValueError: Ifcache_size > 0andcache_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
hostisNone).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).
Noneclears 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
- ADCPHandler
- abc.ABC
- typing.Generic
Inherited members
ADCPHandler:acquire_rightsactivate_signalbuild_creativecalibrate_contentcheck_governancecomply_test_controllercontext_matchcreate_collection_listcreate_content_standardscreate_media_buycreate_property_listdelete_collection_listdelete_property_listget_account_financialsget_adcp_capabilitiesget_brand_identityget_collection_listget_content_standardsget_creative_deliveryget_creative_featuresget_media_buy_artifactsget_media_buy_deliveryget_media_buysget_plan_audit_logsget_productsget_property_listget_rightsget_signalsget_task_statusidentity_matchlist_accountslist_collection_listslist_content_standardslist_creative_formatslist_creativeslist_property_listslist_taskslist_transformerslog_eventpreview_creativeprovide_performance_feedbackreport_plan_outcomereport_usagesi_get_offeringsi_initiate_sessionsi_send_messagesi_terminate_sessionsync_accountssync_audiencessync_catalogssync_creativessync_event_sourcessync_governancesync_plansupdate_collection_listupdate_content_standardsupdate_media_buyupdate_property_listupdate_rightsvalidate_content_deliveryvalidate_inputverify_brand_claimverify_brand_claims
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
- ADCPHandler
- abc.ABC
- typing.Generic
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
ADCPHandler:acquire_rightsactivate_signalbuild_creativecheck_governancecomply_test_controllercontext_matchcreate_collection_listcreate_media_buycreate_property_listdelete_collection_listdelete_property_listget_account_financialsget_adcp_capabilitiesget_brand_identityget_collection_listget_creative_deliveryget_creative_featuresget_media_buy_deliveryget_media_buysget_plan_audit_logsget_productsget_property_listget_rightsget_signalsget_task_statusidentity_matchlist_accountslist_collection_listslist_creative_formatslist_creativeslist_property_listslist_taskslist_transformerslog_eventpreview_creativeprovide_performance_feedbackreport_plan_outcomereport_usagesi_get_offeringsi_initiate_sessionsi_send_messagesi_terminate_sessionsync_accountssync_audiencessync_catalogssync_creativessync_event_sourcessync_governancesync_plansupdate_collection_listupdate_media_buyupdate_property_listupdate_rightsvalidate_inputverify_brand_claimverify_brand_claims
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
- ADCPHandler
- abc.ABC
- typing.Generic
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
ADCPHandler:acquire_rightsactivate_signalbuild_creativecalibrate_contentcomply_test_controllercontext_matchcreate_collection_listcreate_content_standardscreate_media_buydelete_collection_listget_account_financialsget_adcp_capabilitiesget_brand_identityget_collection_listget_content_standardsget_creative_deliveryget_media_buy_artifactsget_media_buy_deliveryget_media_buysget_productsget_rightsget_signalsget_task_statusidentity_matchlist_accountslist_collection_listslist_content_standardslist_creative_formatslist_creativeslist_taskslist_transformerslog_eventpreview_creativeprovide_performance_feedbackreport_usagesi_get_offeringsi_initiate_sessionsi_send_messagesi_terminate_sessionsync_accountssync_audiencessync_catalogssync_creativessync_event_sourcessync_governanceupdate_collection_listupdate_content_standardsupdate_media_buyupdate_rightsvalidate_content_deliveryvalidate_inputverify_brand_claimverify_brand_claims
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.idempotencyon the seller'sget_adcp_capabilitiesresponse. Buyers read this to reason about retry-safe windows (AdCP #2315)::caps.adcp.idempotency = idempotency.capability() # → {"supported": True, "replay_ttl_seconds": 86400}supportedbecame REQUIRED in AdCP 3.0 GA — agents emitting onlyreplay_ttl_secondsfail 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 _wrappedDecorator that adds idempotency semantics to an AdCP handler method.
Supports three calling conventions the framework dispatches with:
- Positional
handler(self, params, context)— the default for non-projected tools (get_products,create_media_buy, etc.). - Keyword
handler(self, params=..., context=...)— same shape, just kwargs. - Arg-projected
handler(self, **arg_projector_kwargs, ctx=...)whereparamsis split into per-field kwargs by the framework dispatcher (e.g.update_media_buyis called ashandler(self, media_buy_id=..., patch=..., ctx=...)). In this mode the wrap searches the kwargs for a Pydantic model (patchfor 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.
paramsis normalized to a dict before hashing; the return value is coerced to a dict for caching (viamodel_dumpif 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 exposesmodel_validate). Callers that return raw dicts get dicts back. - Positional
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:
SubdomainTenantRouterfor dev / test.Backed by a static
host → Tenantdict. 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 : intvar max_active_sessions : int | Nonevar session_age_seconds : tuple[float, ...]var session_idle_seconds : tuple[float, ...]var session_idle_timeout : float | Nonevar sessions_created_last_60s : intvar stateless : boolvar 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) -> argscallable or ordered sequence. Applied before schema + Pydantic validation. See :func:create_tool_caller. response_enhancer- Optional server-wide :data:
ResponseEnhancerapplied 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.Lockto 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 concurrentget/putinterleaving.: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
- IdempotencyBackend
- abc.ABC
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:
MemoryBackendis 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 = NoneStandard 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.selfis explicitly positional-only to allowselfas a field name.Ancestors
- pydantic.main.BaseModel
Class variables
var error : adcp.types.generated_poc.core.error.Error | Nonevar model_configvar reason : strvar 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
indexof the failing hook within its chain and the resolvedhook_nameso the dispatcher can surface both in theINVALID_REQUESTmessage — 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:
TokenValidatoron success. Used to populate the transport-layerContextVars 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_identitydocstring 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-contractfor the full invariants. :param metadata: Optional extra fields the context_factory should propagate into :class:~adcp.server.ToolContext.metadata.Instance variables
var caller_identity : strvar 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 errorsBuilder 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 selfAdd 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 proposalBuild 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 selfSet 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 selfSet 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 errorsValidate 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 selfExplain 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 selfAdd 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 selfAdd 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 selfAdd 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 selfSet 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 selfAdd 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 selfSet sequence for current allocation.
-
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 selfAdd 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 = NoneResponse 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.selfis explicitly positional-only to allowselfas a field name.Ancestors
- pydantic.main.BaseModel
Class variables
var error : adcp.types.generated_poc.core.error.Error | Nonevar model_configvar proposals_supported : boolvar 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 = NonePer-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
RequestMetadataand 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 itNoneat the SDK layer today). :param request_context: The originating StarletteRequestfor HTTP-borne calls (MCP streamable-http, A2A).Nonefor stdio MCP and any path that doesn't have a Request to thread. Userequest_context.stateto read per-request state set by ASGI middleware — this works in both stateless and stateful MCP modes, where the older :mod:contextvarspattern only works in stateless (the stateful session task is a separate async task and does not see middleware-set ContextVars). Typed asAnyto keep this dataclass dependency-light; adopters cancast(Request, meta.request_context).Instance variables
var request_context : Anyvar request_id : str | Nonevar tool_name : strvar 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
UserWarningat boot.Instance variables
var advertise_all : boolvar asgi_middleware : Sequence[tuple[type, dict[str, Any]]] | Nonevar base_url : str | Nonevar context_factory : ContextFactory | Nonevar debug_public : boolvar debug_traffic_source : Callable[[], dict[str, int]] | Nonevar debug_validate_request : Callable[[dict[str, str]], bool] | Nonevar description : str | Nonevar enable_debug_endpoints : boolvar host : str | Nonevar instructions : str | Nonevar max_active_sessions : int | Nonevar max_request_size : int | Nonevar message_parser : MessageParser | Nonevar middleware : Sequence[SkillMiddleware] | Nonevar name : strvar on_shutdown : Sequence[LifespanHook] | Nonevar on_startup : Sequence[LifespanHook] | Nonevar port : int | Nonevar pre_validation_hooks : PreValidationHooks | Nonevar public_url : str | PublicUrlResolver | Nonevar push_config_store : PushNotificationConfigStore | Nonevar response_enhancer : ResponseEnhancer | Nonevar session_count_source : Callable[[], dict[str, Any]] | Nonevar session_idle_timeout : float | Nonevar specialisms : list[str] | Nonevar stateless_http : boolvar streaming_responses : boolvar task_store : TaskStore | Nonevar test_controller : TestControllerStore | Nonevar transport : strvar 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
- ADCPHandler
- abc.ABC
- typing.Generic
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
ADCPHandler:acquire_rightsactivate_signalbuild_creativecalibrate_contentcheck_governancecomply_test_controllercontext_matchcreate_collection_listcreate_content_standardscreate_media_buycreate_property_listdelete_collection_listdelete_property_listget_account_financialsget_adcp_capabilitiesget_brand_identityget_collection_listget_content_standardsget_creative_deliveryget_creative_featuresget_media_buy_artifactsget_media_buy_deliveryget_media_buysget_plan_audit_logsget_productsget_property_listget_rightsget_signalsget_task_statusidentity_matchlist_accountslist_collection_listslist_content_standardslist_creative_formatslist_creativeslist_property_listslist_taskslist_transformerslog_eventpreview_creativeprovide_performance_feedbackreport_plan_outcomereport_usagesync_accountssync_audiencessync_catalogssync_creativessync_event_sourcessync_governancesync_plansupdate_collection_listupdate_content_standardsupdate_media_buyupdate_property_listupdate_rightsvalidate_content_deliveryvalidate_inputverify_brand_claimverify_brand_claims
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:
Hostheader → :class:Tenant.Wire via
app.add_middleware(SubdomainTenantMiddleware, router=...). The middleware:- Reads the
Hostheader from the ASGI scope. - Calls the router's
resolve()method. - On hit, sets the :data:
current_tenant()contextvar for the remainder of the request's lifetime. - On miss, returns
404 Not Foundimmediately — 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:
SubdomainTenantRouterimpl. :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()returningNone. - Reads the
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
Hostheader 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:
resolveonce per request; production adopters cache hot lookups in the impl since the host header is request-scoped.Returning
Nonecauses the middleware to404the 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:
TenantforhostorNoneto 404.hostis the rawHostheader value (lower-cased by the middleware before this call). Implementations strip any:portsuffix 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_idand 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 : strvar 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
createTenantRegistryfor Python deployments. Supports two registration modes:- Eager (:meth:
register) — caller pre-builds the :class:~adcp.decisioning.DecisioningPlatformand 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:resolvecall 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:recheckfailed (transient failure). The tenant still serves (graceful-degrade).disabled— persistent failure. 503 until an operator calls :meth:recheckand 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) passNone— validation always succeeds immediately soawait_first_validation=Truetransitions the tenant tohealthywithout a network round-trip.Per-tenant locks: Each tenant gets an
asyncio.Lockon first use. Locks are removed when the tenant is unregistered. Any in-flight :meth:recheckor :meth:resolvethat held the lock beforeunregister()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:TenantResolutionvs :class:Tenant). Mypy will flag the mismatch; duck-typing andisinstancechecks 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.serveintegration)::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.platformExample (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_optionsdict 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 toserve()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.DecisioningPlatformbacked by this registry.The returned platform resolves the per-request tenant via
ctx.tenant_id(populated by the transport layer from theHostheader 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 theHostheader (via :class:~adcp.server.SubdomainTenantMiddleware) or your customcontext_factory. This value must equal thetenant_idstring 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, wiretenant_idin yourcontext_factorylike 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.idvalue (from your :class:~adcp.server.SubdomainTenantRouter) must match the key you registered —register_lazy("acme", ...)requiresTenant(id="acme", ...), notTenant(id="acme.example.com", ...). A mismatch silently producesSERVICE_UNAVAILABLEwithhealth=Noneon every request.Health gating. By default the adapter serves
healthyandunverifiedtenants, and raisesSERVICE_UNAVAILABLEforpendinganddisabledtenants. Override viaserve_statesfor fail-closed behaviour::# Serve only fully-validated tenants (fail-closed): registry.as_platform( accounts=store, serve_states=frozenset({"healthy"}), )accountsparameter. :func:~adcp.decisioning.servevalidatesplatform.accountsat boot time before any request arrives. Pass the same tenant-aware :class:~adcp.decisioning.AccountStoreyou would pass to :class:~adcp.decisioning.PlatformRouter— typically one that readstenant_idfrom the resolved account's metadata or from the transport-layercurrent_tenant()ContextVar.capabilitiesparameter. 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 accuratetools/listprojection.:param accounts: The :class:
~adcp.decisioning.AccountStorefor 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 isfrozenset({"healthy", "unverified"}). :returns: A :class:~adcp.decisioning.DecisioningPlatformsuitable 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
Nonewhen the tenant is not registered (distinct from any health state value — callers can useis Noneto 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:
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
pendingstate (factory never invoked),recheck()runs the validator against the registeredagent_urlonly. If it succeeds, health advances tohealthy— but the platform has not been built yet. :meth:resolve_by_hoststill returnsNone; use the async :meth:resolvewhich triggers the factory on first call. - For a lazy tenant that reached
disabledvia factory failure, the factory has been cleared. Callingrecheck()alone is insufficient to recover — the validator may succeed but there is no platform to serve. To retry platform construction, call :meth:register_lazyagain with the same factory, then call :meth:recheckif you also need to re-run the validator.
:raises KeyError: when
tenant_idis 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. Whenawait_first_validation=Truethe coroutine suspends until the validator resolves, then transitions tohealthyordisabledbefore returning — the next :meth:resolve_by_hostcall 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_hostlookups. :param platform: Pre-built :class:~adcp.decisioning.DecisioningPlatformfor this tenant. :param await_first_validation: WhenTrue, 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 typicalFalsedefault is correct for background hot-add where traffic is gated onhealth != '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:
resolvecall 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. Whenawait_first_validation=Truethe factory is invoked immediately, the platform is built, and validation completes before returning — the next :meth:resolvecall sees the final state without triggering the factory again.Use :meth:
resolve(async) to get a :class:TenantResolutionfor lazy-registered tenants; the synchronous :meth:resolve_by_hostreturnsNoneuntil the platform is built.Lazy and eager tenants share the same health state machine: :meth:
health, :meth:unregister, :meth:recheck, and :attr:registered_tenantswork 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: WhenTrue, 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 NoneAsync lookup by
Hostheader value; builds lazy platforms on first hit.For eager tenants (registered via :meth:
register), equivalent to :meth:resolve_by_hostat 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
Nonewhen no tenant is registered for this host, or when a lazy tenant's factory/validator fails on this call (health set todisabledin both cases).Returns a :class:
TenantResolution— which may havehealth="disabled"— when the platform was already built (eager registration, lazy +await_first_validation=True, or a previous :meth:resolvecall). Always checkresult.healthbefore serving; never gate solely onresult is None.The caller is responsible for gating traffic — the registry does not 503 automatically.
:param host: Raw
Hostheader value. Port suffixes are stripped; full URLs are also accepted. See :meth:_normalize_hostfor 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
Hostheader value.Returns
Nonewhen no tenant is registered for this host. The caller is responsible for checkingresult.healthand 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:
registerand :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
Hostheader 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 NoneAsync lookup by
tenant_id; builds lazy platforms on first hit.Equivalent to :meth:
resolvebut accepts atenant_idstring directly instead of aHostheader value. Used by the :meth:as_platformadapter to resolve per-request platforms keyed onctx.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
Nonewhen 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 checkresult.healthbefore serving.:param tenant_id: Stable tenant identifier as registered via :meth:
registeror :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_hostbefore this call) complete normally — the registry does not cancel in-flight work. Subsequent :meth:resolve_by_hostcalls for this host returnNone.Safe to call when the tenant is not registered (no-op).
- Eager (:meth:
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: DecisioningPlatformResult 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
pendinganddisabled, serve forhealthyandunverified. :param platform: The :class:~adcp.decisioning.DecisioningPlatformfor this tenant. Pass to :func:serve()or use with a :class:~adcp.decisioning.PlatformRouter.Instance variables
var health : TenantHealthStatevar platform : DecisioningPlatformvar 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_stateTyped 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 NotImplementedErrorBase 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 = Noneparameter. When present, the dispatcher threads theToolContextbuilt by the server'scontext_factoryinto the call — header-driven mock state (e.g.AdCPTestContext.from_headers) populated in the factory is readable offcontext.metadata. Stores that don't declarecontextkeep 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 NotImplementedErrorForce 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 NotImplementedErrorRegister 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 NotImplementedErrorForce 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 NotImplementedErrorForce 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 NotImplementedErrorForce 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 NotImplementedErrorResolve 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.
- Cross-account replay — raise
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 NotImplementedErrorPre-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 NotImplementedErrorPre-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 NotImplementedErrorPre-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 NotImplementedErrorPre-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 NotImplementedErrorPre-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 NotImplementedErrorPre-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 NotImplementedErrorSimulate 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 NotImplementedErrorSimulate 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 passAncestors
- ADCPHandler
- abc.ABC
- typing.Generic
Inherited members
ADCPHandler:acquire_rightsactivate_signalbuild_creativecalibrate_contentcheck_governancecomply_test_controllercontext_matchcreate_collection_listcreate_content_standardscreate_media_buycreate_property_listdelete_collection_listdelete_property_listget_account_financialsget_adcp_capabilitiesget_brand_identityget_collection_listget_content_standardsget_creative_deliveryget_creative_featuresget_media_buy_artifactsget_media_buy_deliveryget_media_buysget_plan_audit_logsget_productsget_property_listget_rightsget_signalsget_task_statusidentity_matchlist_accountslist_collection_listslist_content_standardslist_creative_formatslist_creativeslist_property_listslist_taskslist_transformerslog_eventpreview_creativeprovide_performance_feedbackreport_plan_outcomereport_usagesi_get_offeringsi_initiate_sessionsi_send_messagesi_terminate_sessionsync_accountssync_audiencessync_catalogssync_creativessync_event_sourcessync_governancesync_plansupdate_collection_listupdate_content_standardsupdate_media_buyupdate_property_listupdate_rightsvalidate_content_deliveryvalidate_inputverify_brand_claimverify_brand_claims
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 = NoneContext 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 acontext_factorypassed 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 throughmetadata. The server-side idempotency middleware composes the cache scope key from(tenant_id, caller_identity)whentenant_idis 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 tocaller_identityalone (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:ToolContextfor typed fields;metadatais 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 asNoneand 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 | Nonevar metadata : dict[str, typing.Any]var request_id : str | Nonevar resolved_adcp_version : str | Nonevar 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_UNSUPPORTEDerror details.Ancestors
- builtins.ValueError
- builtins.Exception
- builtins.BaseException