Module adcp.server.helpers
DX helpers for ADCP server builders.
Automate error responses, state transitions, account resolution, and context passthrough so developers focus on business logic.
from adcp.server.helpers import adcp_error, valid_actions_for_status
return adcp_error("BUDGET_TOO_LOW", "Budget $50 is below minimum $500",
field="budget", suggestion="Increase to at least $500")
actions = valid_actions_for_status("active")
Global variables
var ResponseEnhancer-
Server-wide callback that stamps cross-cutting fields on responses.
Configure it via
serve(response_enhancer=...)(or the matching :class:~adcp.server.ServeConfigfield). The framework calls it after the context-echo envelope is assembled and before schema validation.Coverage — paths the enhancer runs on, both MCP and A2A unless noted:
- framework-tool successes;
- custom-tool successes (
get_task_status/list_tasks); - the pre-auth
get_adcp_capabilitiesdiscovery response; - error responses produced by a raised
AdcpError/ADCPTaskError(including credential-policy errors), which the transport error finalizers stamp. The commonadcp_error()helper shape ({"errors": [...]}) carries no top-leveladcp_error()key, so a handler that returns it is stamped on the success path; - the A2A
comply_test_controllersandbox skill.
Coverage gaps — paths the enhancer does NOT run on:
- the MCP
comply_test_controllersandbox path (test_controller.py); it returns the handler result directly without a success-path enhancer call, so its A2A counterpart is stamped but its MCP counterpart is not. Sandbox-only. - a handler that returns (rather than raises) a raw AdCP L3 envelope
{"adcp_error": {...}}. The success-path guard (if "adcp_error" not in result) skips it, and a returned envelope never reaches the raised-error finalizers, so it ships un-enhanced. Raise the error (or return the{"errors": [...]}helper shape) to have it stamped.
Two arities are supported, dispatched by positional-parameter count:
- Context-blind
(result_dict) -> None— the common case; mutate the response dict in place to stamp a field on each response it runs on. - Context-aware
(method_name, result_dict, context) -> None— when the stamp depends on the tool or the caller.contextis the :class:~adcp.server.ToolContextfor this dispatch, orNonefor an unauthenticated / pre-auth discovery call.
The enhancer mutates the response dict in place; its return value is ignored. It runs synchronously (it is not awaited). A raised exception is caught and logged at
WARNING— the un-enhanced response ships rather than turning a buggy enhancer into a transport error.Because the enhancer runs after the wire response is stripped of any credential the buyer echoed in
context, it cannot re-introduce a credential into the response envelope.Idempotency note: the server-side idempotency cache commits the pre-enhancement response, so a replayed request re-runs the enhancer. Non-idempotent enhancers (timestamps, random IDs) will therefore diverge between the original response and its replays.
Functions
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 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 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).
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 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.
Classes
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