This is the security-critical surface of adcp.server. Sellers serving
more than one tenant (or more than one account within a tenant) must
satisfy every invariant below. Each invariant names the failure mode
you get if you violate it — use this as an audit checklist.
The SDK enforces some of these automatically (idempotency scope composition); others are the seller's responsibility to populate correctly. Both kinds are listed here.
AdCP models three nested identifiers. Handlers, middleware, audit logs, and cache keys all compose from these three.
| Scope | Source | Populated on |
|---|---|---|
caller_identity |
Authenticated principal (token, mTLS, signed request) | ToolContext.caller_identity |
tenant_id |
Seller's tenancy model — maps an authenticated principal to its tenant | ToolContext.tenant_id |
account_id |
Request parameter (params.account) resolved via resolve_account |
AccountAwareToolContext.account_id |
caller_identity and tenant_id are authentication-scoped — they
come from the bearer token / client cert / discovery key, and do not
change for the lifetime of the request. account_id is
request-scoped — the same principal can operate on different
accounts on successive calls.
A stable identifier means the same string for the same principal across all time. An email address is not stable — it can be recycled after account deletion, renamed, or merged.
Populate with: the principal's surrogate id from your IdP (Okta
sub claim, SCIM externalId, internal employee id, signing key
fingerprint). Never an email, display name, or any other mutable
handle.
Failure mode: cross-principal replay of idempotent responses. The
server-side idempotency store keys cached responses by
(scope_key, idempotency_key) where scope_key composes
tenant_id + caller_identity. If principal A's identity is reused
after they're deleted and assigned to principal B, B will see A's
cached responses — a confidentiality leak.
Where populated: transport layer.
- A2A:
ADCPAgentExecutorreads it fromServerCallContext.user.user_name. - MCP: the seller's FastMCP auth middleware populates a
ContextVarthatcontext_factoryreads (seeexamples/mcp_with_auth_middleware.py).
If your principal ids are only unique within a tenant — typical for
Okta group-scoped ids, SCIM per-tenant ids, seller-internal employee
ids — you must populate tenant_id so the idempotency store can
compose (tenant_id, caller_identity) into a globally-unique scope
key.
Failure mode: cross-tenant replay. Principal "alice@corp" on tenant T1 and principal "alice@corp" on tenant T2 would share cached responses. Another confidentiality leak, this time across tenants.
Single-tenant exception: if every principal id is globally unique
across your entire deployment (e.g. every principal id is a UUID, or
the service only ever serves one tenant), you can leave tenant_id
unset and the scope collapses to caller_identity alone.
Where populated: seller's context_factory. Look up the tenant
for the authenticated principal and populate the field on the
ToolContext returned by the factory. Subclass ToolContext if your
tenant model needs more than a string id.
An authenticated principal may operate on several accounts — for
example, an agency with multiple brand accounts, or a reseller managing
downstream advertisers. Every operation's account parameter carries
an AccountReference that the seller resolves to a concrete account
at the handler boundary.
Populate with: the resolved account's stable id from the seller's
own system. Same stability rule as caller_identity — no emails, no
display names.
Failure mode: authorization bypass. If you treat
context.account_id as if it were as stable as caller_identity, a
single handler invocation can end up scoping a downstream cache or
authorization check to the wrong account.
Where populated: the handler (or a middleware), per-call, via
resolve_account_into_context(params, context, resolver). The helper
populates AccountAwareToolContext.account_id if the resolver
succeeds, and returns an ACCOUNT_NOT_FOUND / ACCOUNT_SUSPENDED /
etc. error dict if it doesn't.
Enforced by IdempotencyStore — listed here so you know what the SDK
guarantees.
The cache key is (scope_key, idempotency_key) where scope_key
derives from both tenant_id and caller_identity on the request
context. You cannot bypass this by supplying your own scope key; the
store composes it internally from the ToolContext fields.
Seller's responsibility: populate caller_identity (I1) and
tenant_id (I2) correctly. The SDK does the composition.
Any seller-side cache (product catalog, resolved accounts, rate limit
counters) or audit log that stores per-principal or per-account state
MUST key on (tenant_id, caller_identity, account_id) — or a prefix of
that tuple appropriate for the data.
Failure mode: same class as I1/I2 — confidentiality or authorization leaks. The SDK idempotency store is just the most prominent example of the rule; your own storage has to follow it too.
Rule of thumb: if you cache something derived from the request context or params, the cache key must incorporate whichever of the three scopes varied to produce the cached value.
context_factory is called once per request; the returned
ToolContext is passed through every handler invocation in that
request. If your factory mutates a shared instance instead of
returning a fresh one, concurrent requests will read each other's
scopes.
Where this bites: sellers who cache their context_factory result
module-globally and mutate it.
Rule: the factory returns a new ToolContext (or subclass)
every call.
Handlers receive context as a parameter. Adopting a ContextVar
pattern for ergonomics (resetting the var in a finally block) is
fine; storing context in a module-level dict indexed by anything
other than the request's transient id is not.
Failure mode: cross-request leak. Request A's context is still in the module when request B starts — whatever B reads is A's data.
The same advice applies to application-level locks, caches, and tracing state: scope them by tenant + principal + account as in I5.
Populate caller_identity from your auth middleware. Leave tenant_id
unset (scope collapses safely).
serve(MyAgent(), name="my-agent", context_factory=my_auth_context_factory)Subclass ToolContext with your tenant model, populate it in the
factory, parameterise the handler so types flow through.
from dataclasses import dataclass
from adcp.server import ADCPHandler, ToolContext
@dataclass
class TenantContext(ToolContext):
tenant: Tenant | None = None # your tenant model
# caller_identity + tenant_id fields inherited from ToolContext
class MyAgent(ADCPHandler[TenantContext]):
async def get_products(self, params, context=None):
...The context_factory returns TenantContext(caller_identity=..., tenant_id=..., tenant=...).
Add account_id resolution per-call. Use AccountAwareToolContext
(or a subclass of it) and resolve at handler entry.
from adcp.server import (
ADCPHandler,
AccountAwareToolContext,
resolve_account_into_context,
)
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
# context.account_id is populated — safe to scope cache/audit by it
return products_response(self.catalog.for_account(context.account_id))Before shipping a multi-tenant agent, verify each of these:
-
caller_identityis populated from a stable surrogate id, not an email. -
tenant_idis populated (unless single-tenant or globally unique ids). -
context_factoryreturns a freshToolContexton every call. - No module-level dict indexes
ToolContextinstances by mutable keys. - Every seller-side cache or audit log keys on
(tenant_id, caller_identity, account_id)as appropriate for the data. - Account-scoped handlers call
resolve_account_into_contextbefore touching account-scoped storage. - Tests cover the cross-tenant and cross-principal leak paths (two concurrent requests with different scopes return different results).
examples/mcp_with_auth_middleware.py— concrete ContextVar pattern for MCP, including the discovery-method bypass.docs/handler-authoring.md— broader handler patterns, including the single-file starting point.src/adcp/server/idempotency/store.py— how scope keys are composed inside the SDK.