Tracking issue for the server-side implementation helper for AdCP #2308 — idempotency_key is now required on every mutating request. The spec defines the contract; this issue is about giving Python sellers a drop-in middleware so they don't have to build the storage/atomicity layer themselves.
Note: the repo is currently described as "a python library to interact with adcp servers" (client-focused). This issue expands the scope to server-side helpers, matching the TS SDK which covers both. If it's a better fit to split into a separate adcp-server-python package, happy to re-file there.
Sibling issues:
Why a middleware
Sellers have to implement four things correctly, and the atomicity bit is what trips people up:
- Extract
idempotency_key from the request
- Look up
(principal_id, idempotency_key) in a store with TTL
- If hit: compare canonical payload hash → return cached response, OR raise
IDEMPOTENCY_CONFLICT
- If miss: execute the handler, then atomically commit side effects + cache entry. A crash between "fire webhook" and "cache response" means the retry fires the webhook again.
Proposed API
from adcp_client.server import IdempotencyStore, canonical_json_sha256
idempotency = IdempotencyStore(
backend=PgBackend(engine), # or RedisBackend, or MemoryBackend for tests
ttl_seconds=86400,
hash_fn=canonical_json_sha256,
)
# Decorator composes with FastAPI / your MCP server framework
@idempotency.wrap
async def create_media_buy(req: CreateMediaBuyRequest, ctx: Context) -> CreateMediaBuyResponse:
# Business logic — same transaction as the cache write
media_buy = await db.insert_media_buy(req, ctx.tx)
await fire_webhook(media_buy)
return media_buy
The wrapper:
- Extracts
idempotency_key from the request
- Scopes lookups by
ctx.principal.id (per-principal namespace — security requirement)
- On hit with matching payload hash: returns cached response
- On hit with different hash: raises
IdempotencyConflictError → mapped to IDEMPOTENCY_CONFLICT
- On miss: runs handler inside a transaction, writes
{key, hash, response} alongside business writes, commits together
Backends
Ship three, MVP needs the first two:
MemoryBackend() — in-process dict with TTL sweeper, for tests and reference agents
PgBackend(engine, schema=None, table=None) — SQLAlchemy / asyncpg, matches transaction guarantees when business data is in Postgres
RedisBackend(client) — redis.asyncio client with TTL-keyed entries. Document the atomicity caveat (can't transact with business DB)
Capabilities integration
capabilities.adcp.idempotency = idempotency.capability()
# → { replay_ttl_seconds: 86400 }
Docs
- "Why idempotency_key and what it costs" narrative (pointing at security.mdx)
- Snippet for each backend
- Gotchas: atomicity across backend choice, per-principal scoping, TTL implications
Acceptance
Related: adcontextprotocol/adcp#2308, adcontextprotocol/adcp#2315, #181 (client side)
Tracking issue for the server-side implementation helper for AdCP #2308 — idempotency_key is now required on every mutating request. The spec defines the contract; this issue is about giving Python sellers a drop-in middleware so they don't have to build the storage/atomicity layer themselves.
Note: the repo is currently described as "a python library to interact with adcp servers" (client-focused). This issue expands the scope to server-side helpers, matching the TS SDK which covers both. If it's a better fit to split into a separate
adcp-server-pythonpackage, happy to re-file there.Sibling issues:
Why a middleware
Sellers have to implement four things correctly, and the atomicity bit is what trips people up:
idempotency_keyfrom the request(principal_id, idempotency_key)in a store with TTLIDEMPOTENCY_CONFLICTProposed API
The wrapper:
idempotency_keyfrom the requestctx.principal.id(per-principal namespace — security requirement)IdempotencyConflictError→ mapped toIDEMPOTENCY_CONFLICT{key, hash, response}alongside business writes, commits togetherBackends
Ship three, MVP needs the first two:
MemoryBackend()— in-process dict with TTL sweeper, for tests and reference agentsPgBackend(engine, schema=None, table=None)— SQLAlchemy / asyncpg, matches transaction guarantees when business data is in PostgresRedisBackend(client)—redis.asyncioclient with TTL-keyed entries. Document the atomicity caveat (can't transact with business DB)Capabilities integration
Docs
Acceptance
IdempotencyStorewithMemoryBackend+PgBackendcanonical_json_sha256hash helper (stable key order, whitespace-normalized)IdempotencyConflictErrorexception mapped toIDEMPOTENCY_CONFLICT@wrapdecorator + async support.capability()method returns the capabilities fragmentRelated: adcontextprotocol/adcp#2308, adcontextprotocol/adcp#2315, #181 (client side)