Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,8 @@ async with ADCPMultiAgentClient(

- **[API Reference](https://adcontextprotocol.github.io/adcp-client-python/)** - Complete API documentation with type signatures and examples
- **[Protocol Spec](https://github.com/adcontextprotocol/adcp)** - Ad Context Protocol specification
- **[Handler authoring](docs/handler-authoring.md)** - Building an AdCP-compliant agent on `adcp.server`
- **[Multi-tenant contract](docs/multi-tenant-contract.md)** - Scope invariants every multi-tenant agent must satisfy
- **[Examples](examples/)** - Code examples and usage patterns

The API reference documentation is automatically generated from the code and includes:
Expand Down
8 changes: 8 additions & 0 deletions docs/handler-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -368,9 +368,17 @@ exposes the fields those handlers need:
`context_factory` and your handler methods `isinstance(context,
MyContext)` (or `cast(MyContext, context)` if you've established the
invariant via the factory) to reach the extra fields.
- `AccountAwareToolContext` is a shipped subclass that adds
`account_id` + `account` for handlers that need per-request account
scope. Pair it with `resolve_account_into_context(params, context,
resolver)` to collapse the standard three-line boilerplate.

When in doubt, subclass: `metadata: dict[str, Any]` loses type safety.

For the full set of scope invariants — what each field means, how
cache keys are composed, what leaks if you populate fields wrong — see
[docs/multi-tenant-contract.md](./multi-tenant-contract.md).

## A2A transport

`serve(MyAgent(), transport="a2a")` wires the same handler through the
Expand Down
229 changes: 229 additions & 0 deletions docs/multi-tenant-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
# Multi-tenant contract

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.

## The three scopes

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.

## Invariants

### I1. `caller_identity` MUST be stable and globally unique within the tenant

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: `ADCPAgentExecutor` reads it from `ServerCallContext.user.user_name`.
- MCP: the seller's FastMCP auth middleware populates a `ContextVar`
that `context_factory` reads (see `examples/mcp_with_auth_middleware.py`).

### I2. `tenant_id` MUST be populated when principal ids are tenant-scoped

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.

### I3. `account_id` is request-scoped, not authentication-scoped

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.

### I4. Idempotency scope keys MUST include tenant + principal

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.

### I5. Caches and audit logs MUST key on the same three scopes

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.

### I6. `context_factory` MUST NOT hold mutable per-request state

`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.

### I7. Never stash the context in a module-level variable

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.

## Wiring it up

### Single-tenant agent (simplest)

Populate `caller_identity` from your auth middleware. Leave `tenant_id`
unset (scope collapses safely).

```python
serve(MyAgent(), name="my-agent", context_factory=my_auth_context_factory)
```

### Multi-tenant agent (typical)

Subclass `ToolContext` with your tenant model, populate it in the
factory, parameterise the handler so types flow through.

```python
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=...)`.

### Account-scoped operations

Add `account_id` resolution per-call. Use `AccountAwareToolContext`
(or a subclass of it) and resolve at handler entry.

```python
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))
```

## Audit checklist

Before shipping a multi-tenant agent, verify each of these:

- [ ] `caller_identity` is populated from a stable surrogate id, not an email.
- [ ] `tenant_id` is populated (unless single-tenant or globally unique ids).
- [ ] `context_factory` returns a fresh `ToolContext` on every call.
- [ ] No module-level dict indexes `ToolContext` instances 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_context` before
touching account-scoped storage.
- [ ] Tests cover the cross-tenant and cross-principal leak paths
(two concurrent requests with different scopes return different results).

## Related reading

- `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.
4 changes: 4 additions & 0 deletions src/adcp/server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ async def get_products(params, context=None):
from adcp.capabilities import validate_capabilities
from adcp.server.a2a_server import ADCPAgentExecutor, create_a2a_server
from adcp.server.base import (
AccountAwareToolContext,
ADCPHandler,
NotImplementedResponse,
TContext,
Expand All @@ -78,6 +79,7 @@ async def get_products(params, context=None):
inject_context,
is_terminal_status,
resolve_account,
resolve_account_into_context,
valid_actions_for_status,
)
from adcp.server.idempotency import IdempotencyStore, MemoryBackend
Expand Down Expand Up @@ -128,6 +130,7 @@ async def get_products(params, context=None):

__all__ = [
# Base classes
"AccountAwareToolContext",
"ADCPHandler",
"BrandHandler",
"ComplianceHandler",
Expand Down Expand Up @@ -177,6 +180,7 @@ async def get_products(params, context=None):
"inject_context",
"is_terminal_status",
"resolve_account",
"resolve_account_into_context",
"valid_actions_for_status",
# Response builders
"activate_signal_response",
Expand Down
39 changes: 39 additions & 0 deletions src/adcp/server/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,45 @@ class ToolContext:
metadata: dict[str, Any] = field(default_factory=dict)


@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 = None


class NotImplementedResponse(BaseModel):
"""Standard response for operations not supported by this handler."""

Expand Down
Loading
Loading