Official Python client for the Ad Context Protocol (AdCP)
Project description
adcp - Python Client for Ad Context Protocol
Official Python SDK for the Ad Context Protocol (AdCP). Build and connect to advertising agents that work synchronously OR asynchronously with the same code.
Building an AdCP Agent
The fastest path to a working agent: subclass ADCPHandler, use response builders, call serve().
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)
# implement create_media_buy, get_media_buys, sync_creatives, etc.
serve(MySeller(), name="my-seller")
Validate with storyboards:
python agent.py &
npx @adcp/client storyboard run http://localhost:3001/mcp media_buy_seller --json
| Agent type | Skill | Storyboard | Steps |
|---|---|---|---|
| Seller (publisher, SSP, retail media) | skills/build-seller-agent/ |
media_buy_seller |
9 |
| Signals (audience data, CDP) | skills/build-signals-agent/ |
signal_owned |
4 |
| Creative (ad server, CMP) | skills/build-creative-agent/ |
creative_lifecycle |
6 |
For compliance testing, add a TestControllerStore so storyboards can force state transitions:
from adcp.server.test_controller import TestControllerStore
serve(MySeller(), name="my-seller", test_controller=MyStore())
Each skill file in skills/ contains the complete pattern, response shapes, and validation loop for coding agents (Claude, Codex) to generate passing servers.
Multi-agent discovery manifest
Every HTTP transport (streamable-http, a2a, both) automatically
serves the AdCP multi-agent topology manifest at
/.well-known/adcp-agents.json. Buyers, conformance runners, and
tooling fetch this once per origin to discover which agents the host
serves and over which transports — no out-of-band configuration.
curl http://localhost:3001/.well-known/adcp-agents.json
Set base_url, specialisms, and description to populate the
manifest with your public origin and AdCP specialisms:
serve(
MySeller(),
name="my-seller",
transport="both",
base_url="https://sales.example.com",
specialisms=["sales-non-guaranteed", "sales-guaranteed"],
description="Premium publisher inventory.",
)
Connecting to AdCP Agents
The Core Concept
AdCP operations are distributed and asynchronous by default. An agent might:
- Complete your request immediately (synchronous)
- Need time to process and send results via webhook (asynchronous)
- Ask for clarifications before proceeding
- Send periodic status updates as work progresses
Your code stays the same. You write handlers once, and they work for both sync completions and webhook deliveries.
Installation
pip install adcp
Note: This client requires Python 3.10 or later and supports both synchronous and asynchronous workflows.
Quick Start: Test Helpers
The fastest way to get started is using pre-configured test agents with the .simple API:
from adcp.testing import test_agent
# Zero configuration - just import and call with kwargs!
products = await test_agent.simple.get_products(
brief='Coffee subscription service for busy professionals',
buying_mode='brief',
)
print(f"Found {len(products.products)} products")
Simple vs. Standard API
Every ADCPClient includes both API styles via the .simple accessor:
Simple API (client.simple.*) - Recommended for examples/prototyping:
from adcp.testing import test_agent
# Kwargs and direct return - raises on error
products = await test_agent.simple.get_products(brief='Coffee brands', buying_mode='brief')
print(products.products[0].name)
Standard API (client.*) - Recommended for production:
from adcp.testing import test_agent
from adcp import GetProductsRequest
# Explicit request objects and TaskResult wrapper
request = GetProductsRequest(brief='Coffee brands', buying_mode='brief')
result = await test_agent.get_products(request)
if result.success and result.data:
print(result.data.products[0].name)
else:
print(f"Error: {result.error}")
When to use which:
- Simple API (
.simple): Quick testing, documentation, examples, notebooks - Standard API: Production code, complex error handling, webhook workflows
Available Test Helpers
Pre-configured agents (all include .simple accessor):
test_agent: MCP test agent with authenticationtest_agent_a2a: A2A test agent with authenticationtest_agent_no_auth: MCP test agent without authenticationtest_agent_a2a_no_auth: A2A test agent without authenticationcreative_agent: Reference creative agent for preview functionalitytest_agent_client: Multi-agent client with both protocols
Note: Test agents are rate-limited and for testing/examples only. DO NOT use in production.
See examples/simple_api_demo.py for a complete comparison.
Tip: Import types from the main
adcppackage (e.g.,from adcp import GetProductsRequest) rather thanadcp.types.generatedfor better API stability.
Quick Start: Distributed Operations
For production use, configure your own agents:
from adcp import ADCPMultiAgentClient, AgentConfig, GetProductsRequest
# Configure agents and handlers (context manager ensures proper cleanup)
async with ADCPMultiAgentClient(
agents=[
AgentConfig(
id="agent_x",
agent_uri="https://agent-x.com",
protocol="a2a"
),
AgentConfig(
id="agent_y",
agent_uri="https://agent-y.com/mcp/",
protocol="mcp"
)
],
# Webhook URL template (macros: {agent_id}, {task_type}, {operation_id})
webhook_url_template="https://myapp.com/webhook/{task_type}/{agent_id}/{operation_id}",
# Activity callback - fires for ALL events
on_activity=lambda activity: print(f"[{activity.type}] {activity.task_type}"),
# Status change handlers
handlers={
"on_get_products_status_change": lambda response, metadata: (
db.save_products(metadata.operation_id, response.products)
if metadata.status == "completed" else None
)
}
) as client:
# Execute operation - library handles operation IDs, webhook URLs, context management
agent = client.agent("agent_x")
request = GetProductsRequest(brief="Coffee brands", buying_mode="brief")
result = await agent.get_products(request)
# Check result
if result.status == "completed":
# Agent completed synchronously!
print(f"✅ Sync completion: {len(result.data.products)} products")
if result.status == "submitted":
# Agent will send webhook when complete
print(f"⏳ Async - webhook registered at: {result.submitted.webhook_url}")
# Connections automatically cleaned up here
Documentation
- API Reference - Complete API documentation with type signatures and examples
- Protocol Spec - Ad Context Protocol specification
- Handler authoring - Building an AdCP-compliant agent on
adcp.server - Multi-tenant contract - Scope invariants every multi-tenant agent must satisfy
- Examples - Code examples and usage patterns
The API reference documentation is automatically generated from the code and includes:
- Full type signatures for all methods
- Field descriptions from JSON Schema
- Method documentation with examples
- Searchable interface
Features
Test Helpers
Pre-configured test agents for instant prototyping and testing:
from adcp.testing import (
test_agent, test_agent_a2a,
test_agent_no_auth, test_agent_a2a_no_auth,
creative_agent, test_agent_client, create_test_agent
)
from adcp import GetProductsRequest, PreviewCreativeRequest
# 1. Single agent with authentication (MCP)
result = await test_agent.get_products(
GetProductsRequest(brief="Coffee brands", buying_mode="brief")
)
# 2. Single agent with authentication (A2A)
result = await test_agent_a2a.get_products(
GetProductsRequest(brief="Coffee brands", buying_mode="brief")
)
# 3. Single agent WITHOUT authentication (MCP)
# Useful for testing unauthenticated behavior
result = await test_agent_no_auth.get_products(
GetProductsRequest(brief="Coffee brands", buying_mode="brief")
)
# 4. Single agent WITHOUT authentication (A2A)
result = await test_agent_a2a_no_auth.get_products(
GetProductsRequest(brief="Coffee brands", buying_mode="brief")
)
# 5. Creative agent (preview functionality, no auth required)
result = await creative_agent.preview_creative(
PreviewCreativeRequest(
manifest={"format_id": "banner_300x250", "assets": {...}}
)
)
# 6. Multi-agent (parallel execution with both protocols)
results = await test_agent_client.get_products(
GetProductsRequest(brief="Coffee brands", buying_mode="brief")
)
# 7. Custom configuration
from adcp.client import ADCPClient
config = create_test_agent(id="my-test", timeout=60.0)
client = ADCPClient(config)
Use cases:
- Quick prototyping and experimentation
- Example code and documentation
- Integration testing without mock servers
- Testing authentication behavior (comparing auth vs no-auth results)
- Learning AdCP concepts
Important: Test agents are public, rate-limited, and for testing only. Never use in production.
Full Protocol Support
- A2A Protocol: Native support for Agent-to-Agent protocol
- MCP Protocol: Native support for Model Context Protocol
- Auto-detection: Automatically detect which protocol an agent uses
Type Safety
Full type hints with Pydantic validation and auto-generated types from the AdCP spec. All commonly-used types are exported from the main adcp package for convenience:
from adcp import (
GetProductsRequest,
BrandReference,
Package,
CpmFixedRatePricingOption,
MediaBuyStatus,
)
# All methods require typed request objects
request = GetProductsRequest(brief="Coffee brands", buying_mode="brief", max_results=10)
result = await agent.get_products(request)
# result: TaskResult[GetProductsResponse]
if result.success:
for product in result.data.products:
print(product.name, product.pricing_options) # Full IDE autocomplete!
# Type-safe pricing with discriminators
pricing = CpmFixedRatePricingOption(
pricing_option_id="cpm_usd",
pricing_model="cpm",
is_fixed=True, # Literal[True] - type checked!
currency="USD",
rate=5.0
)
# Type-safe status enums
if media_buy.status == MediaBuyStatus.active:
print("Media buy is active")
Exported from main package:
- Core domain types:
BrandReference,Creative,CreativeManifest,MediaBuy,Package,PackageRequest,TargetingOverlay - AdCP status enums:
CreativeStatus,DeliveryStatus,MediaBuyStatus,PricingModel - All 9 pricing options:
CpcPricingOption,CpmFixedRatePricingOption,VcpmAuctionPricingOption, etc. - Request/Response types: All 16 operations with full request/response types
For types not on the top-level surface, import from adcp.types (e.g., from adcp.types import AssetStatus). If a type you need isn't in adcp.types, open an issue — we'll add an alias. The adcp.types.generated_poc.* modules are internal; class names and module paths shift on every schema regeneration and are not a supported API.
Semantic Type Aliases
For discriminated union types (success/error responses), use semantic aliases for clearer code:
from adcp import (
CreateMediaBuySuccessResponse, # Clear: this is the success case
CreateMediaBuyErrorResponse, # Clear: this is the error case
)
def handle_response(
response: CreateMediaBuySuccessResponse | CreateMediaBuyErrorResponse
) -> None:
if isinstance(response, CreateMediaBuySuccessResponse):
print(f"✅ Media buy created: {response.media_buy_id}")
else:
print(f"❌ Errors: {response.errors}")
Available semantic aliases:
- Response types:
*SuccessResponse/*ErrorResponse(e.g.,CreateMediaBuySuccessResponse) - Request variants:
*FormatRequest/*ManifestRequest(e.g.,PreviewCreativeFormatRequest) - Preview renders:
PreviewRenderImage/PreviewRenderHtml/PreviewRenderIframe - Activation keys:
PropertyIdActivationKey/PropertyTagActivationKey
See examples/type_aliases_demo.py for more examples.
Import guidelines:
- ✅ DO: Import from main package:
from adcp import GetProductsRequest - ✅ DO: Use semantic aliases:
from adcp import CreateMediaBuySuccessResponse - ⚠️ AVOID: Import from
adcp.types.generated_poc.*— paths and class names (including numberedAssets*variants) change on every schema regeneration.
The main package exports provide a stable API while internal generated types may change.
Multi-Agent Operations
Execute across multiple agents simultaneously:
from adcp import GetProductsRequest
# Parallel execution across all agents
request = GetProductsRequest(brief="Coffee brands", buying_mode="brief")
results = await client.get_products(request)
for result in results:
if result.status == "completed":
print(f"Sync: {len(result.data.products)} products")
elif result.status == "submitted":
print(f"Async: webhook to {result.submitted.webhook_url}")
Webhook Handling
Single endpoint handles all webhooks:
from fastapi import FastAPI, Request
app = FastAPI()
@app.post("/webhook/{task_type}/{agent_id}/{operation_id}")
async def webhook(task_type: str, agent_id: str, operation_id: str, request: Request):
payload = await request.json()
payload["task_type"] = task_type
payload["operation_id"] = operation_id
# Route to agent client - handlers fire automatically
agent = client.agent(agent_id)
await agent.handle_webhook(
payload,
request.headers.get("x-adcp-signature")
)
return {"received": True}
Security
Webhook signature verification built-in:
client = ADCPMultiAgentClient(
agents=agents,
webhook_secret=os.getenv("WEBHOOK_SECRET")
)
# Signatures verified automatically on handle_webhook()
Signed webhooks (AdCP 3.0): receiver quickstart
AdCP 3.0 webhooks are signed under the RFC 9421 profile
(adcp/webhook-signing/v1) and carry a required idempotency_key for
at-least-once dedup. The WebhookReceiver packages verify + dedupe + parse
into one call so you don't have to re-derive the normative checklist:
from flask import Flask, request, Response
from adcp.server.idempotency import MemoryBackend, WebhookDedupStore
from adcp.signing import StaticJwksResolver
from adcp.webhooks import (
WebhookReceiver,
WebhookReceiverConfig,
WebhookVerifyOptions,
)
# One resolver per publisher. In production, wire an async JWKS fetcher
# pointed at the publisher's `adagents.json`.
jwks = StaticJwksResolver(publisher_jwks_dict)
receiver = WebhookReceiver(
config=WebhookReceiverConfig(
verify_options=WebhookVerifyOptions(jwks_resolver=jwks),
dedup=WebhookDedupStore(MemoryBackend(), ttl_seconds=86400),
),
)
app = Flask(__name__)
@app.post("/webhooks/adcp")
async def hook():
outcome = await receiver.receive(
method=request.method, url=request.url,
headers=dict(request.headers), body=request.get_data(),
)
if outcome.rejected:
return Response(status=401, headers=outcome.response_headers)
# Spec: MUST return 2xx on duplicates so the at-least-once sender stops
# retrying. A duplicate is a no-op, not an error.
if outcome.duplicate:
return Response(status=200)
process(outcome.payload) # typed McpWebhookPayload
return Response(status=200)
Legacy HMAC-SHA256 fallback (3.x only, removed in 4.0). The shortcut constructor covers the "one publisher, one shared secret" case:
from adcp.webhooks import LegacyHmacFallback
config = WebhookReceiverConfig(
verify_options=WebhookVerifyOptions(jwks_resolver=jwks),
dedup=WebhookDedupStore(MemoryBackend(), ttl_seconds=86400),
legacy_hmac=LegacyHmacFallback.from_shared_secret(
secret=os.environ["WEBHOOK_SHARED_SECRET"].encode(),
sender_identity="publisher-buyerco",
),
)
By default the fallback only fires when no 9421 headers are present — this prevents a MITM from stripping a valid 9421 signature and substituting a forged HMAC one.
Signed webhooks: sender quickstart
from adcp.webhooks import WebhookSender
# One sender per private key; reuses a pooled httpx client under the hood.
sender = WebhookSender.from_jwk(webhook_signing_jwk_with_private_d)
async with sender:
result = await sender.send_mcp(
url="https://buyer.example.com/webhooks/adcp/create_media_buy/op_abc",
task_id="task_456",
task_type="create_media_buy",
status="completed",
result={"media_buy_id": "mb_1"},
)
if not result.ok:
# resend() replays the exact same bytes under a fresh signature —
# preserves idempotency_key AND every other payload field, so the
# receiver dedupes against the original event.
retry = await sender.resend(result)
WebhookSender handles payload construction, byte-exact JSON serialization,
9421 signing, and the httpx POST in one call. send_raw(...) is an escape
hatch for custom payload shapes; dedicated methods exist for every webhook
kind (send_revocation_notification, send_artifact_webhook,
send_collection_list_changed, send_property_list_changed).
The webhook-signing JWK MUST be published in your adagents.json with
adcp_use: "webhook-signing" — distinct from your request-signing key so
neither signature can be replayed as the other. WebhookSender.from_jwk
refuses to construct from a JWK with the wrong adcp_use to fail fast at
setup rather than at receiver verification.
Debug Mode
Enable debug mode to see full request/response details:
agent_config = AgentConfig(
id="agent_x",
agent_uri="https://agent-x.com",
protocol="mcp",
debug=True # Enable debug mode
)
result = await client.agent("agent_x").get_products(brief="Coffee brands", buying_mode="brief")
# Access debug information
if result.debug_info:
print(f"Duration: {result.debug_info.duration_ms}ms")
print(f"Request: {result.debug_info.request}")
print(f"Response: {result.debug_info.response}")
Or use the CLI:
uvx adcp --debug myagent get_products '{"brief":"TV ads"}'
Resource Management
Why use async context managers?
- Ensures HTTP connections are properly closed, preventing resource leaks
- Handles cleanup even when exceptions occur
- Required for production applications with connection pooling
- Prevents issues with async task group cleanup in MCP protocol
The recommended pattern uses async context managers:
from adcp import ADCPClient, AgentConfig, GetProductsRequest
# Recommended: Automatic cleanup with context manager
config = AgentConfig(id="agent_x", agent_uri="https://...", protocol="a2a")
async with ADCPClient(config) as client:
request = GetProductsRequest(brief="Coffee brands", buying_mode="brief")
result = await client.get_products(request)
# Connection automatically closed on exit
# Multi-agent client also supports context managers
async with ADCPMultiAgentClient(agents) as client:
# Execute across all agents in parallel
results = await client.get_products(request)
# All agent connections closed automatically (even if some failed)
Manual cleanup is available for special cases (e.g., managing client lifecycle manually):
# Use manual cleanup when you need fine-grained control over lifecycle
client = ADCPClient(config)
try:
result = await client.get_products(request)
finally:
await client.close() # Explicit cleanup
When to use manual cleanup:
- Managing client lifecycle across multiple functions
- Testing scenarios requiring explicit control
- Integration with frameworks that manage resources differently
In most cases, prefer the context manager pattern.
Error Handling
The library provides a comprehensive exception hierarchy with helpful error messages:
from adcp.exceptions import (
ADCPError, # Base exception
ADCPConnectionError, # Connection failed
ADCPAuthenticationError, # Auth failed (401, 403)
ADCPTimeoutError, # Request timed out
ADCPProtocolError, # Invalid response format
ADCPToolNotFoundError, # Tool not found
ADCPWebhookSignatureError # Invalid webhook signature
)
try:
result = await client.agent("agent_x").get_products(brief="Coffee", buying_mode="brief")
except ADCPAuthenticationError as e:
# Exception includes agent context and helpful suggestions
print(f"Auth failed for {e.agent_id}: {e.message}")
print(f"Suggestion: {e.suggestion}")
except ADCPTimeoutError as e:
print(f"Request timed out after {e.timeout}s")
except ADCPConnectionError as e:
print(f"Connection failed: {e.message}")
print(f"Agent URI: {e.agent_uri}")
except ADCPError as e:
# Catch-all for other AdCP errors
print(f"AdCP error: {e.message}")
All exceptions include:
- Contextual information: agent ID, URI, and operation details
- Actionable suggestions: specific steps to fix common issues
- Error classification: proper HTTP status code handling
Idempotency and retries
AdCP 3.0 requires an idempotency_key on every mutating request (create_media_buy, sync_creatives, and 26 others). The client handles this for you — you pass a key (or let the SDK generate one) and get back the key the seller cached under, plus a replayed flag indicating whether the seller served a cached response:
import uuid
from adcp import CreateMediaBuyRequest
# Pass a fresh UUID v4 on each new logical operation — the request schema
# requires idempotency_key at construction time.
request = CreateMediaBuyRequest(
idempotency_key=uuid.uuid4().hex,
account=..., brand=..., start_time=..., end_time=..., packages=[...]
)
result = await client.create_media_buy(request)
print(result.idempotency_key) # The key the SDK sent
print(result.replayed) # True if the seller returned a cached response
Retrying the same logical operation — wrap the retry loop in use_idempotency_key so every attempt sends the same key. Otherwise each retry gets a new UUID and defeats the whole point.
stored = uuid.uuid4().hex
for attempt in range(3):
try:
with client.use_idempotency_key(stored):
result = await client.create_media_buy(request)
break
except TimeoutError:
continue
Bring your own key when you persist keys across process restarts (e.g., storing alongside a campaign row in your DB):
with client.use_idempotency_key(campaign.stored_key):
result = await client.create_media_buy(request)
The pinned key is scoped to this client instance — a sibling ADCPClient running inside the same with block generates a fresh key (per AdCP §2315: keys must be unique per (seller, request) pair to prevent cross-seller correlation). The pinned key is also single-use within the scope: if you asyncio.gather two sibling calls inside the block, only the first gets the pinned key, the rest get fresh UUIDs — preventing accidental payload drift.
Typed errors for the two idempotency-specific failure modes:
from adcp import IdempotencyConflictError, IdempotencyExpiredError
try:
await client.create_media_buy(request)
except IdempotencyConflictError:
# Same key, different payload. Either mint a fresh uuid.uuid4() or resend original.
...
except IdempotencyExpiredError:
# Replay window closed; reconcile via a read before resubmitting with a new key.
...
Strict mode refuses mutating calls against sellers that don't declare adcp.idempotency.replay_ttl_seconds in capabilities:
client = ADCPClient(agent, strict_idempotency=True) # default: False
# First mutating call fetches capabilities and raises IdempotencyUnsupportedError
# if the seller is silent. Set False to opt-out; you then own reconciliation.
Security note on logs. The SDK redacts idempotency_key in its own debug captures, but the underlying httpx/httpcore loggers log full request bodies at DEBUG. If you enable logging.basicConfig(level=logging.DEBUG) in production, raise those two loggers back to INFO — otherwise full keys end up in logs during the seller's replay TTL window and become a retry-pattern oracle for anyone who can read them.
Building a seller: idempotency middleware
If you're building an AdCP seller, the companion middleware handles the (principal, key, canonical-hash) bookkeeping so you don't hand-roll it per tool handler. Drop @idempotency.wrap above each mutating handler and declare your replay window in capabilities:
from adcp.server import ADCPHandler, IdempotencyStore, MemoryBackend, serve
from adcp.server.responses import capabilities_response
idempotency = IdempotencyStore(
backend=MemoryBackend(), # PgBackend with transactional commit is a follow-up
ttl_seconds=86400, # 24h, spec-recommended floor
)
class MySeller(ADCPHandler):
async def get_adcp_capabilities(self, params, context=None):
return capabilities_response(
["media_buy"],
idempotency=idempotency.capability(),
)
@idempotency.wrap
async def create_media_buy(self, params, context=None):
# Same key + canonical-equivalent payload → this body is skipped,
# the cached response is returned. Same key + different payload →
# IdempotencyConflictError raised before this runs, which the
# framework translates to IDEMPOTENCY_CONFLICT on the wire.
return my_business_logic(params)
serve(MySeller(), name="my-seller")
What the middleware does for you:
- Extracts
idempotency_keyfromparams, scopes lookups bycontext.caller_identity(per-principal — a security requirement from AdCP §2315) - Hashes the payload with RFC 8785 JCS + SHA-256, stripping the spec's closed exclusion list (
idempotency_key,context,governance_context,push_notification_config.authentication.credentials) - On cache hit with matching hash: returns the cached response verbatim, skips your handler (deep-copied so caller mutation can't poison future replays)
- On cache hit with different hash: raises
IdempotencyConflictError, which the framework surfaces asIDEMPOTENCY_CONFLICTon both MCP (is_error=true+ text) and A2A (failed task withadcp_errorDataPart) - On cache miss: runs your handler, then commits the response
Backends: MemoryBackend ships now (tests, single-process agents). PgBackend is scaffolded — it raises NotImplementedError with a pointer to the follow-up issue. For production use across multiple workers, implement your own IdempotencyBackend subclass against Redis, Postgres, etc.
Atomicity caveat: MemoryBackend commits the cache entry AFTER your handler returns, so a crash between handler success and cache commit causes the retry to re-execute. PgBackend (follow-up) will commit the cache row in the same transaction as your business writes. Read the module docstring at adcp.server.idempotency before shipping this against a production database.
How caller identity gets populated. The middleware scopes its cache by (caller_identity, idempotency_key) — same key from two buyers must hit different cache slots, and a buyer's retry must replay only against its own prior call. caller_identity comes from ToolContext, which the transport layer builds per request:
-
A2A — the framework derives
caller_identityfromServerCallContext.user.user_namewhen the user is authenticated. Wire your a2a-sdk auth middleware (bearer tokens, mTLS, OAuth) and@idempotency.wrapworks automatically. Unauthenticated requests → no identity → dedup is skipped (fail-closed, with a one-timeUserWarningso you notice). -
MCP — FastMCP exposes a session
client_idbut not an authenticated principal. The SDK does NOT auto-populatecaller_identityfor MCP tools today. If you're serving via MCP, wire your own FastMCP auth middleware and populateToolContext.caller_identitybefore the idempotency middleware runs — either by overridingadcp.server.mcp_tools.create_tool_calleror by wrapping your handlers directly. Without this,@idempotency.wrapis a no-op on MCP (you'll get the one-time warning above).
Principal contract. caller_identity MUST be a stable, globally-unique identifier per tenant — an opaque buyer ID, not an email or display name. Email reuse after account deletion would cause cross-principal cache collisions. The value is logged at DEBUG (prefix-truncated) and keyed on in the cache; treat it as you would any user-scoping identifier.
AdCP 3.0.0-rc.4 migration
plan.budget.authority_level removed. The single enum (agent_full / agent_limited / human_required) is replaced by two orthogonal fields on plan.budget, plus a new top-level flag on plan:
| Old (removed) | New |
|---|---|
budget.authority_level: agent_full |
budget.reallocation_unlimited: true |
budget.authority_level: agent_limited |
budget.reallocation_threshold: <amount> (in budget.currency) |
budget.authority_level: human_required |
Set plan.human_review_required: true and budget.reallocation_threshold: 0 |
reallocation_threshold and reallocation_unlimited are mutually exclusive — pick one. plan.human_review_required is a separate field governing decisions that affect data subjects (targeting, creative, delivery) under GDPR Art 22 / EU AI Act Annex III; set it independently from the budget reallocation autonomy.
# Before (rc.3 and earlier)
plan = SyncPlansRequest(plans=[{
"plan_id": "...",
"budget": {"total": 100000, "currency": "USD", "authority_level": "agent_limited"},
}])
# After (rc.4+)
plan = SyncPlansRequest(plans=[{
"plan_id": "...",
"budget": {
"total": 100000,
"currency": "USD",
"reallocation_threshold": 5000, # agent may reallocate up to $5K per change
},
"human_review_required": False, # defaults to False; set True for GDPR Art 22 gating
}])
Any hand-coded plan.budget payload using authority_level will fail Pydantic validation against the rc.4 schema with extra fields not permitted. The SDK itself has no code references to the old enum; downstream consumers need to update their payloads.
update_rights task added. Buyers can now modify an existing rights acquisition without re-acquiring. See client.update_rights(request) or the MCP/A2A update_rights tool.
Available Tools
All AdCP tools with full type safety:
Media Buy Lifecycle:
get_products()- Discover advertising productslist_creative_formats()- Get supported creative formatscreate_media_buy()- Create new media buyupdate_media_buy()- Update existing media buysync_creatives()- Upload/sync creative assetslist_creatives()- List creative assetsget_media_buy_delivery()- Get delivery performance
Creative Management:
preview_creative()- Preview creative before buildingbuild_creative()- Generate production-ready creative assets
Discovery & Accounts:
get_adcp_capabilities()- Discover agent capabilities and authorized publisherslist_accounts()- List billing accounts
Audience & Targeting:
get_signals()- Get audience signalsactivate_signal()- Activate audience signalsprovide_performance_feedback()- Send performance feedback
Workflow Examples
Complete Media Buy Workflow
A typical media buy workflow involves discovering products, creating the buy, and managing creatives:
from adcp import ADCPClient, AgentConfig, GetProductsRequest, CreateMediaBuyRequest
from adcp import BrandReference, PublisherPropertiesAll
# 1. Connect to agent
config = AgentConfig(id="sales_agent", agent_uri="https://...", protocol="mcp")
async with ADCPClient(config) as client:
# 2. Discover available products
products_result = await client.get_products(
GetProductsRequest(brief="Premium video inventory for coffee brand", buying_mode="brief")
)
if products_result.success:
product = products_result.data.products[0]
print(f"Found product: {product.name}")
# 3. Create media buy reservation
media_buy_result = await client.create_media_buy(
CreateMediaBuyRequest(
brand=BrandReference(domain="coffeeco.com"),
packages=[{
"package_id": product.packages[0].package_id,
"quantity": 1000000, # impressions
}],
publisher_properties=PublisherPropertiesAll(
selection_type="all", # Target all authorized properties
),
)
)
if media_buy_result.success:
media_buy_id = media_buy_result.data.media_buy_id
print(f"✅ Media buy created: {media_buy_id}")
# 4. Update media buy if needed
from adcp import UpdateMediaBuyPackagesRequest
update_result = await client.update_media_buy(
UpdateMediaBuyPackagesRequest(
media_buy_id=media_buy_id,
packages=[{
"package_id": product.packages[0].package_id,
"quantity": 1500000 # Increase budget
}]
)
)
if update_result.success:
print("✅ Media buy updated")
Complete Creative Workflow
Build and deliver production-ready creatives:
from adcp import ADCPClient, AgentConfig
from adcp import PreviewCreativeFormatRequest, BuildCreativeRequest
from adcp import CreativeManifest, PlatformDeployment
# 1. Connect to creative agent
config = AgentConfig(id="creative_agent", agent_uri="https://...", protocol="mcp")
async with ADCPClient(config) as client:
# 2. List available formats
formats_result = await client.list_creative_formats()
if formats_result.success:
format_id = formats_result.data.formats[0].format_id
print(f"Using format: {format_id.id}")
# 3. Preview creative (test before building)
preview_result = await client.preview_creative(
PreviewCreativeFormatRequest(
target_format_id=format_id.id,
inputs={
"headline": "Fresh Coffee Daily",
"cta": "Order Now"
},
output_format="url" # Get preview URL
)
)
if preview_result.success:
preview_url = preview_result.data.renders[0].url
print(f"Preview at: {preview_url}")
# 4. Build production creative
build_result = await client.build_creative(
BuildCreativeRequest(
manifest=CreativeManifest(
format_id=format_id,
brand_url="https://coffeeco.com",
# ... creative content
),
target_format_id=format_id.id,
deployment=PlatformDeployment(
type="platform",
platform_id="google_admanager"
)
)
)
if build_result.success:
vast_url = build_result.data.assets[0].url
print(f"✅ Creative ready: {vast_url}")
Integrated Workflow: Media Buy + Creatives
Combine both workflows for a complete campaign setup:
from adcp import ADCPMultiAgentClient, AgentConfig, BrandReference, PublisherPropertiesAll
from adcp import BuildCreativeRequest, CreateMediaBuyRequest
# Connect to both sales and creative agents
async with ADCPMultiAgentClient(
agents=[
AgentConfig(id="sales", agent_uri="https://sales-agent.com", protocol="mcp"),
AgentConfig(id="creative", agent_uri="https://creative-agent.com", protocol="mcp"),
]
) as client:
# 1. Get products from sales agent
sales_agent = client.agent("sales")
products = await sales_agent.simple.get_products(
brief="Premium video inventory",
buying_mode="brief",
)
# 2. Get creative formats from creative agent
creative_agent = client.agent("creative")
formats = await creative_agent.simple.list_creative_formats()
# 3. Build creative asset
creative_result = await creative_agent.build_creative(
BuildCreativeRequest(
manifest=creative_manifest,
target_format_id=formats.formats[0].format_id.id,
)
)
# 4. Create media buy with creative
media_buy_result = await sales_agent.create_media_buy(
CreateMediaBuyRequest(
brand=BrandReference(domain="coffeeco.com"),
packages=[{"package_id": products.products[0].packages[0].package_id}],
publisher_properties=PublisherPropertiesAll(selection_type="all"),
creative_urls=[creative_result.data.assets[0].url],
)
)
print(f"✅ Campaign live: {media_buy_result.data.media_buy_id}")
Property Discovery (AdCP v2.2.0)
Build agent registries by discovering properties agents can sell:
from adcp.discovery import PropertyCrawler, get_property_index
# Crawl agents to discover properties
crawler = PropertyCrawler()
await crawler.crawl_agents([
{"agent_url": "https://agent-x.com", "protocol": "a2a"},
{"agent_url": "https://agent-y.com/mcp/", "protocol": "mcp"}
])
index = get_property_index()
# Query 1: Who can sell this property?
matches = index.find_agents_for_property("domain", "cnn.com")
# Query 2: What can this agent sell?
auth = index.get_agent_authorizations("https://agent-x.com")
# Query 3: Find by tags
premium = index.find_agents_by_property_tags(["premium", "ctv"])
Publisher Authorization Validation
Verify sales agents are authorized to sell publisher properties via adagents.json:
from adcp import (
fetch_adagents,
verify_agent_authorization,
verify_agent_for_property,
)
# Fetch and parse adagents.json from publisher
adagents_data = await fetch_adagents("publisher.com")
# Verify agent authorization for a property
is_authorized = verify_agent_authorization(
adagents_data=adagents_data,
agent_url="https://sales-agent.example.com",
property_type="website",
property_identifiers=[{"type": "domain", "value": "publisher.com"}]
)
# Or use convenience wrapper (fetch + verify in one call)
is_authorized = await verify_agent_for_property(
publisher_domain="publisher.com",
agent_url="https://sales-agent.example.com",
property_identifiers=[{"type": "domain", "value": "publisher.com"}],
property_type="website"
)
Domain Matching Rules:
- Exact match:
example.commatchesexample.com - Common subdomains:
www.example.commatchesexample.com - Wildcards:
api.example.commatches*.example.com - Protocol-agnostic:
http://agent.commatcheshttps://agent.com
Use Cases:
- Sales agents verify authorization before accepting media buys
- Publishers test their adagents.json files
- Developer tools build authorization validators
See examples/adagents_validation.py for complete examples.
Authorization Discovery
Discover which publishers have authorized your agent using two approaches:
1. "Push" Approach - Ask the agent (recommended, fastest):
from adcp import ADCPClient, GetAdcpCapabilitiesRequest
async with ADCPClient(agent_config) as client:
# Single API call to agent
result = await client.get_adcp_capabilities(GetAdcpCapabilitiesRequest())
if result.success and result.data.media_buy:
portfolio = result.data.media_buy.portfolio
print(f"Authorized for: {portfolio.publisher_domains}")
2. "Pull" Approach - Check publisher adagents.json files (when you need property details):
from adcp import fetch_agent_authorizations
# Check specific publishers (fetches in parallel)
contexts = await fetch_agent_authorizations(
"https://our-sales-agent.com",
["nytimes.com", "wsj.com", "cnn.com"]
)
for domain, ctx in contexts.items():
print(f"{domain}:")
print(f" Property IDs: {ctx.property_ids}")
print(f" Tags: {ctx.property_tags}")
When to use which:
- Push: Quick discovery, portfolio overview, high-level authorization check
- Pull: Property-level details, specific publisher list, works offline
See examples/fetch_agent_authorizations.py for complete examples.
Request Signing (AdCP 3.0 optional, 4.0 required)
AdCP defines an optional transport-layer request-signing profile based on RFC 9421 HTTP Message Signatures. A valid signature proves the request came from the agent whose key signed it. See the spec profile and the conformance vectors.
Generate a keypair
python -m adcp.signing.keygen --alg ed25519 --out signing-key.pem
# prints the JWK to stdout — publish it at your agent's jwks_uri
ES256 is also supported: --alg es256. Ed25519 is the recommended default.
Sign an outgoing request
from cryptography.hazmat.primitives import serialization
from adcp.signing import sign_request
private_key = serialization.load_pem_private_key(
open("signing-key.pem", "rb").read(), password=None
)
signed = sign_request(
method="POST",
url="https://seller.example.com/adcp/create_media_buy",
headers={"Content-Type": "application/json"},
body=body,
private_key=private_key,
key_id="adcp-ed25519-20260418",
alg="ed25519",
cover_content_digest=True, # required by sellers that set covers_content_digest="required"
)
httpx.post(url, content=body, headers={**headers, **signed.as_dict()})
Auto-sign on ADCPClient
The high-level client wires the signing event hook for you when you pass a SigningConfig:
from adcp.client import ADCPClient
from adcp.signing import SigningConfig, load_private_key_pem
signing = SigningConfig(
private_key=load_private_key_pem(open("signing-key.pem", "rb").read()),
key_id="my-agent-2026",
)
client = ADCPClient(agent_config, signing=signing)
# Outbound calls are signed automatically per the seller's request_signing capability.
Auto-sign on raw httpx (no ADCPClient)
For adapters that integrate against a seller via raw httpx, install the same hook on your own client:
import httpx
from adcp.signing import SigningConfig, install_signing_event_hook, signing_operation
client = httpx.AsyncClient()
install_signing_event_hook(
client,
signing=signing,
seller_capability=seller_caps.request_signing,
)
async with client:
with signing_operation("create_media_buy"):
resp = await client.post("https://seller.example.com/mcp", json=payload)
Verify incoming requests (FastAPI)
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from adcp.signing import (
CachingJwksResolver, SignatureVerificationError,
VerifierCapability, VerifyOptions,
unauthorized_response_headers, verify_starlette_request,
)
jwks = CachingJwksResolver("https://buyer.example.com/.well-known/jwks.json")
capability = VerifierCapability(
covers_content_digest="either",
required_for=frozenset({"create_media_buy"}),
)
@app.post("/adcp/create_media_buy")
async def create_media_buy(request: Request):
options = VerifyOptions(
now=time.time(),
capability=capability,
operation="create_media_buy",
jwks_resolver=jwks,
)
# `replay_store` defaults to a fresh InMemoryReplayStore when omitted.
# Wire an explicit shared store (PgReplayStore via [pg] extra, or your
# own ReplayStore Protocol implementation) for multi-replica deployments.
try:
signer = await verify_starlette_request(request, options=options)
except SignatureVerificationError as exc:
return JSONResponse(
{"error": exc.code},
status_code=401,
headers=unauthorized_response_headers(exc),
)
# signer.key_id is the verified caller's key identity
...
Flask has an equivalent synchronous helper verify_flask_request.
Migration & rollout
Rolling signing out against an existing integration is a staged exercise — bootstrap, then advance each operation through supported_for → warn_for → required_for. See docs/request-signing-migration.md for the full walkthrough including key rotation, common pitfalls, and a pre-enforcement checklist.
Conformance
The verifier passes all 28 AdCP request-signing conformance vectors (8 positive, 20 negative). Run them against your signer or verifier:
pytest tests/conformance/signing/
CLI Tool
The adcp command-line tool provides easy interaction with AdCP agents without writing code.
Installation
# Install globally
pip install adcp
# Or use uvx to run without installing
uvx adcp --help
Quick Start
# Save agent configuration
uvx adcp --save-auth myagent https://agent.example.com mcp
# List tools available on agent
uvx adcp myagent list_tools
# Execute a tool
uvx adcp myagent get_products '{"brief":"TV ads"}'
# Use from stdin
echo '{"brief":"TV ads"}' | uvx adcp myagent get_products
# Use from file
uvx adcp myagent get_products @request.json
# Get JSON output
uvx adcp --json myagent get_products '{"brief":"TV ads"}'
# Enable debug mode
uvx adcp --debug myagent get_products '{"brief":"TV ads"}'
Using Test Agents from CLI
The CLI provides easy access to public test agents without configuration:
# Use test agent with authentication (MCP)
uvx adcp https://test-agent.adcontextprotocol.org/mcp/ \
--auth 1v8tAhASaUYYp4odoQ1PnMpdqNaMiTrCRqYo9OJp6IQ \
get_products '{"brief":"Coffee brands"}'
# Use test agent WITHOUT authentication (MCP)
uvx adcp https://test-agent.adcontextprotocol.org/mcp/ \
get_products '{"brief":"Coffee brands"}'
# Use test agent with authentication (A2A)
uvx adcp --protocol a2a \
--auth 1v8tAhASaUYYp4odoQ1PnMpdqNaMiTrCRqYo9OJp6IQ \
https://test-agent.adcontextprotocol.org \
get_products '{"brief":"Coffee brands"}'
# Save test agent for easier access
uvx adcp --save-auth test-agent https://test-agent.adcontextprotocol.org/mcp/ mcp
# Enter token when prompted: 1v8tAhASaUYYp4odoQ1PnMpdqNaMiTrCRqYo9OJp6IQ
# Now use saved config
uvx adcp test-agent get_products '{"brief":"Coffee brands"}'
# Use creative agent (no auth required)
uvx adcp https://creative.adcontextprotocol.org/mcp \
preview_creative @creative_manifest.json
Test Agent Details:
- URL (MCP):
https://test-agent.adcontextprotocol.org/mcp/ - URL (A2A):
https://test-agent.adcontextprotocol.org - Auth Token:
1v8tAhASaUYYp4odoQ1PnMpdqNaMiTrCRqYo9OJp6IQ(optional, public token) - Rate Limited: For testing only, not for production
- No Auth Mode: Omit
--authflag to test unauthenticated behavior
### Configuration Management
```bash
# Save agent with authentication
uvx adcp --save-auth myagent https://agent.example.com mcp
# Prompts for optional auth token
# List saved agents
uvx adcp --list-agents
# Remove saved agent
uvx adcp --remove-agent myagent
# Show config file location
uvx adcp --show-config
Direct URL Access
# Use URL directly without saving
uvx adcp https://agent.example.com/mcp list_tools
# Override protocol
uvx adcp --protocol a2a https://agent.example.com list_tools
# Pass auth token
uvx adcp --auth YOUR_TOKEN https://agent.example.com list_tools
Examples
# Get products from saved agent
uvx adcp myagent get_products '{"brief":"Coffee brands for digital video"}'
# Create media buy
uvx adcp myagent create_media_buy '{
"name": "Q4 Campaign",
"budget": 50000,
"start_date": "2024-01-01",
"end_date": "2024-03-31"
}'
# List creative formats with JSON output
uvx adcp --json myagent list_creative_formats | jq '.data'
# Debug connection issues
uvx adcp --debug myagent list_tools
Configuration File
Agent configurations are stored in ~/.adcp/config.json:
{
"agents": {
"myagent": {
"agent_uri": "https://agent.example.com",
"protocol": "mcp",
"auth_token": "optional-token"
}
}
}
Environment Configuration
# .env
WEBHOOK_URL_TEMPLATE="https://myapp.com/webhook/{task_type}/{agent_id}/{operation_id}"
WEBHOOK_SECRET="your-webhook-secret"
ADCP_AGENTS='[
{
"id": "agent_x",
"agent_uri": "https://agent-x.com",
"protocol": "a2a",
"auth_token_env": "AGENT_X_TOKEN"
}
]'
AGENT_X_TOKEN="actual-token-here"
# Auto-discover from environment
client = ADCPMultiAgentClient.from_env()
Development
# Install with dev dependencies
pip install -e ".[dev]"
# Run tests
pytest
# Type checking
mypy src/
# Format code
black src/ tests/
ruff check src/ tests/
Contributing
Contributions welcome! See CONTRIBUTING.md for guidelines. All contributors must agree to the AgenticAdvertising.Org IPR Policy — the bot prompts new contributors on their first PR and a single signature covers all AAO repositories.
License
Apache 2.0 License - see LICENSE file for details.
Support
- API Reference: adcontextprotocol.github.io/adcp-client-python
- Protocol Documentation: docs.adcontextprotocol.org
- Issues: GitHub Issues
- Protocol Spec: AdCP Specification
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file adcp-4.4.2.tar.gz.
File metadata
- Download URL: adcp-4.4.2.tar.gz
- Upload date:
- Size: 2.2 MB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.10.20
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a463f069500be69a255087808663f4c49697780c0e58d1190a7dd159fa13b5a6
|
|
| MD5 |
75c4c9152da04055c34961e88e1aab39
|
|
| BLAKE2b-256 |
206000a60cb9c8f3041bf333c0ac2431a6dd39ca051ee073d236578d98583b9d
|
File details
Details for the file adcp-4.4.2-py3-none-any.whl.
File metadata
- Download URL: adcp-4.4.2-py3-none-any.whl
- Upload date:
- Size: 2.2 MB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.10.20
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
64b4646a4f9cfdb5484f5e87399a1795f3c33e62a849fe0889e8309a610ded21
|
|
| MD5 |
74e83ee96123d06cc03d6b7cbb0cc2e9
|
|
| BLAKE2b-256 |
ed70f45c1232be23ab56f84fb53a40a48c2b4bb11280c289974791b7b8428092
|