Skip to content

Commit 69fd3da

Browse files
authored
Merge pull request #245 from adcontextprotocol/bokelley/account-aware-context
feat(server): AccountAwareToolContext + multi-tenant contract doc
2 parents ffa58e5 + a0a3077 commit 69fd3da

8 files changed

Lines changed: 557 additions & 18 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,8 @@ async with ADCPMultiAgentClient(
185185

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

190192
The API reference documentation is automatically generated from the code and includes:

docs/handler-authoring.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,9 +368,17 @@ exposes the fields those handlers need:
368368
`context_factory` and your handler methods `isinstance(context,
369369
MyContext)` (or `cast(MyContext, context)` if you've established the
370370
invariant via the factory) to reach the extra fields.
371+
- `AccountAwareToolContext` is a shipped subclass that adds
372+
`account_id` + `account` for handlers that need per-request account
373+
scope. Pair it with `resolve_account_into_context(params, context,
374+
resolver)` to collapse the standard three-line boilerplate.
371375

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

378+
For the full set of scope invariants — what each field means, how
379+
cache keys are composed, what leaks if you populate fields wrong — see
380+
[docs/multi-tenant-contract.md](./multi-tenant-contract.md).
381+
374382
## A2A transport
375383

376384
`serve(MyAgent(), transport="a2a")` wires the same handler through the

docs/multi-tenant-contract.md

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
# Multi-tenant contract
2+
3+
This is the security-critical surface of `adcp.server`. Sellers serving
4+
more than one tenant (or more than one account within a tenant) must
5+
satisfy every invariant below. Each invariant names the failure mode
6+
you get if you violate it — use this as an audit checklist.
7+
8+
The SDK enforces some of these automatically (idempotency scope
9+
composition); others are the seller's responsibility to populate
10+
correctly. Both kinds are listed here.
11+
12+
## The three scopes
13+
14+
AdCP models three nested identifiers. Handlers, middleware, audit logs,
15+
and cache keys all compose from these three.
16+
17+
| Scope | Source | Populated on |
18+
|-------|--------|-------------|
19+
| `caller_identity` | Authenticated principal (token, mTLS, signed request) | `ToolContext.caller_identity` |
20+
| `tenant_id` | Seller's tenancy model — maps an authenticated principal to its tenant | `ToolContext.tenant_id` |
21+
| `account_id` | Request parameter (`params.account`) resolved via `resolve_account` | `AccountAwareToolContext.account_id` |
22+
23+
`caller_identity` and `tenant_id` are **authentication-scoped** — they
24+
come from the bearer token / client cert / discovery key, and do not
25+
change for the lifetime of the request. `account_id` is
26+
**request-scoped** — the same principal can operate on different
27+
accounts on successive calls.
28+
29+
## Invariants
30+
31+
### I1. `caller_identity` MUST be stable and globally unique within the tenant
32+
33+
A *stable* identifier means the same string for the same principal
34+
across all time. An *email address is not stable* — it can be recycled
35+
after account deletion, renamed, or merged.
36+
37+
**Populate with**: the principal's surrogate id from your IdP (Okta
38+
`sub` claim, SCIM `externalId`, internal employee id, signing key
39+
fingerprint). Never an email, display name, or any other mutable
40+
handle.
41+
42+
**Failure mode**: cross-principal replay of idempotent responses. The
43+
server-side idempotency store keys cached responses by
44+
`(scope_key, idempotency_key)` where `scope_key` composes
45+
`tenant_id + caller_identity`. If principal A's identity is reused
46+
after they're deleted and assigned to principal B, B will see A's
47+
cached responses — a confidentiality leak.
48+
49+
**Where populated**: transport layer.
50+
- A2A: `ADCPAgentExecutor` reads it from `ServerCallContext.user.user_name`.
51+
- MCP: the seller's FastMCP auth middleware populates a `ContextVar`
52+
that `context_factory` reads (see `examples/mcp_with_auth_middleware.py`).
53+
54+
### I2. `tenant_id` MUST be populated when principal ids are tenant-scoped
55+
56+
If your principal ids are *only unique within a tenant* — typical for
57+
Okta group-scoped ids, SCIM per-tenant ids, seller-internal employee
58+
ids — you **must** populate `tenant_id` so the idempotency store can
59+
compose `(tenant_id, caller_identity)` into a globally-unique scope
60+
key.
61+
62+
**Failure mode**: cross-tenant replay. Principal "alice@corp" on tenant
63+
T1 and principal "alice@corp" on tenant T2 would share cached
64+
responses. Another confidentiality leak, this time across tenants.
65+
66+
**Single-tenant exception**: if every principal id is globally unique
67+
across your entire deployment (e.g. every principal id is a UUID, or
68+
the service only ever serves one tenant), you can leave `tenant_id`
69+
unset and the scope collapses to `caller_identity` alone.
70+
71+
**Where populated**: seller's `context_factory`. Look up the tenant
72+
for the authenticated principal and populate the field on the
73+
`ToolContext` returned by the factory. Subclass `ToolContext` if your
74+
tenant model needs more than a string id.
75+
76+
### I3. `account_id` is request-scoped, not authentication-scoped
77+
78+
An authenticated principal may operate on several accounts — for
79+
example, an agency with multiple brand accounts, or a reseller managing
80+
downstream advertisers. Every operation's `account` parameter carries
81+
an `AccountReference` that the seller resolves to a concrete account
82+
at the handler boundary.
83+
84+
**Populate with**: the resolved account's stable id from the seller's
85+
own system. Same stability rule as `caller_identity` — no emails, no
86+
display names.
87+
88+
**Failure mode**: authorization bypass. If you treat
89+
`context.account_id` as if it were as stable as `caller_identity`, a
90+
single handler invocation can end up scoping a downstream cache or
91+
authorization check to the wrong account.
92+
93+
**Where populated**: the handler (or a middleware), per-call, via
94+
`resolve_account_into_context(params, context, resolver)`. The helper
95+
populates `AccountAwareToolContext.account_id` if the resolver
96+
succeeds, and returns an `ACCOUNT_NOT_FOUND` / `ACCOUNT_SUSPENDED` /
97+
etc. error dict if it doesn't.
98+
99+
### I4. Idempotency scope keys MUST include tenant + principal
100+
101+
Enforced by `IdempotencyStore` — listed here so you know what the SDK
102+
guarantees.
103+
104+
The cache key is `(scope_key, idempotency_key)` where `scope_key`
105+
derives from both `tenant_id` and `caller_identity` on the request
106+
context. You cannot bypass this by supplying your own scope key; the
107+
store composes it internally from the `ToolContext` fields.
108+
109+
**Seller's responsibility**: populate `caller_identity` (I1) and
110+
`tenant_id` (I2) correctly. The SDK does the composition.
111+
112+
### I5. Caches and audit logs MUST key on the same three scopes
113+
114+
Any seller-side cache (product catalog, resolved accounts, rate limit
115+
counters) or audit log that stores per-principal or per-account state
116+
MUST key on `(tenant_id, caller_identity, account_id)` — or a prefix of
117+
that tuple appropriate for the data.
118+
119+
**Failure mode**: same class as I1/I2 — confidentiality or
120+
authorization leaks. The SDK idempotency store is just the most
121+
prominent example of the rule; your own storage has to follow it too.
122+
123+
**Rule of thumb**: if you cache something derived from the request
124+
context or params, the cache key must incorporate whichever of the
125+
three scopes varied to produce the cached value.
126+
127+
### I6. `context_factory` MUST NOT hold mutable per-request state
128+
129+
`context_factory` is called once per request; the returned
130+
`ToolContext` is passed through every handler invocation in that
131+
request. If your factory mutates a shared instance instead of
132+
returning a fresh one, concurrent requests will read each other's
133+
scopes.
134+
135+
**Where this bites**: sellers who cache their `context_factory` result
136+
module-globally and mutate it.
137+
138+
**Rule**: the factory returns a **new** `ToolContext` (or subclass)
139+
every call.
140+
141+
### I7. Never stash the context in a module-level variable
142+
143+
Handlers receive `context` as a parameter. Adopting a ContextVar
144+
pattern for ergonomics (resetting the var in a `finally` block) is
145+
fine; storing `context` in a module-level dict indexed by anything
146+
other than the request's transient id is not.
147+
148+
**Failure mode**: cross-request leak. Request A's context is still in
149+
the module when request B starts — whatever B reads is A's data.
150+
151+
The same advice applies to application-level locks, caches, and
152+
tracing state: scope them by tenant + principal + account as in I5.
153+
154+
## Wiring it up
155+
156+
### Single-tenant agent (simplest)
157+
158+
Populate `caller_identity` from your auth middleware. Leave `tenant_id`
159+
unset (scope collapses safely).
160+
161+
```python
162+
serve(MyAgent(), name="my-agent", context_factory=my_auth_context_factory)
163+
```
164+
165+
### Multi-tenant agent (typical)
166+
167+
Subclass `ToolContext` with your tenant model, populate it in the
168+
factory, parameterise the handler so types flow through.
169+
170+
```python
171+
from dataclasses import dataclass
172+
from adcp.server import ADCPHandler, ToolContext
173+
174+
@dataclass
175+
class TenantContext(ToolContext):
176+
tenant: Tenant | None = None # your tenant model
177+
# caller_identity + tenant_id fields inherited from ToolContext
178+
179+
class MyAgent(ADCPHandler[TenantContext]):
180+
async def get_products(self, params, context=None):
181+
...
182+
```
183+
184+
The `context_factory` returns `TenantContext(caller_identity=..., tenant_id=..., tenant=...)`.
185+
186+
### Account-scoped operations
187+
188+
Add `account_id` resolution per-call. Use `AccountAwareToolContext`
189+
(or a subclass of it) and resolve at handler entry.
190+
191+
```python
192+
from adcp.server import (
193+
ADCPHandler,
194+
AccountAwareToolContext,
195+
resolve_account_into_context,
196+
)
197+
198+
class MyAgent(ADCPHandler[AccountAwareToolContext]):
199+
async def get_products(self, params, context=None):
200+
err = await resolve_account_into_context(params, context, my_resolver)
201+
if err:
202+
return err
203+
# context.account_id is populated — safe to scope cache/audit by it
204+
return products_response(self.catalog.for_account(context.account_id))
205+
```
206+
207+
## Audit checklist
208+
209+
Before shipping a multi-tenant agent, verify each of these:
210+
211+
- [ ] `caller_identity` is populated from a stable surrogate id, not an email.
212+
- [ ] `tenant_id` is populated (unless single-tenant or globally unique ids).
213+
- [ ] `context_factory` returns a fresh `ToolContext` on every call.
214+
- [ ] No module-level dict indexes `ToolContext` instances by mutable keys.
215+
- [ ] Every seller-side cache or audit log keys on
216+
`(tenant_id, caller_identity, account_id)` as appropriate for the data.
217+
- [ ] Account-scoped handlers call `resolve_account_into_context` before
218+
touching account-scoped storage.
219+
- [ ] Tests cover the cross-tenant and cross-principal leak paths
220+
(two concurrent requests with different scopes return different results).
221+
222+
## Related reading
223+
224+
- `examples/mcp_with_auth_middleware.py` — concrete ContextVar pattern
225+
for MCP, including the discovery-method bypass.
226+
- `docs/handler-authoring.md` — broader handler patterns, including
227+
the single-file starting point.
228+
- `src/adcp/server/idempotency/store.py` — how scope keys are composed
229+
inside the SDK.

src/adcp/server/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ async def get_products(params, context=None):
5555
from adcp.capabilities import validate_capabilities
5656
from adcp.server.a2a_server import ADCPAgentExecutor, create_a2a_server
5757
from adcp.server.base import (
58+
AccountAwareToolContext,
5859
ADCPHandler,
5960
NotImplementedResponse,
6061
TContext,
@@ -78,6 +79,7 @@ async def get_products(params, context=None):
7879
inject_context,
7980
is_terminal_status,
8081
resolve_account,
82+
resolve_account_into_context,
8183
valid_actions_for_status,
8284
)
8385
from adcp.server.idempotency import IdempotencyStore, MemoryBackend
@@ -128,6 +130,7 @@ async def get_products(params, context=None):
128130

129131
__all__ = [
130132
# Base classes
133+
"AccountAwareToolContext",
131134
"ADCPHandler",
132135
"BrandHandler",
133136
"ComplianceHandler",
@@ -177,6 +180,7 @@ async def get_products(params, context=None):
177180
"inject_context",
178181
"is_terminal_status",
179182
"resolve_account",
183+
"resolve_account_into_context",
180184
"valid_actions_for_status",
181185
# Response builders
182186
"activate_signal_response",

src/adcp/server/base.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,45 @@ class ToolContext:
120120
metadata: dict[str, Any] = field(default_factory=dict)
121121

122122

123+
@dataclass
124+
class AccountAwareToolContext(ToolContext):
125+
"""ToolContext subclass carrying a resolved account scope.
126+
127+
AdCP is account-aware: many operations accept an ``account`` field
128+
(:class:`~adcp.types.AccountReference`) that the seller resolves to
129+
a concrete account before executing the request. Handlers that need
130+
``account_id`` throughout their business logic shouldn't have to
131+
re-derive it on every call — this subclass carries the resolved
132+
result on the context itself.
133+
134+
The typical flow::
135+
136+
class MyAgent(ADCPHandler[AccountAwareToolContext]):
137+
async def get_products(self, params, context=None):
138+
err = await resolve_account_into_context(
139+
params, context, my_resolver,
140+
)
141+
if err:
142+
return err # ACCOUNT_NOT_FOUND / SUSPENDED / etc.
143+
# context.account_id is now populated
144+
return products_response(self.catalog.for_account(context.account_id))
145+
146+
Sellers whose account scope is fixed by the authenticated principal
147+
(e.g. per-tenant API keys that map 1:1 to an account) can populate
148+
``account_id`` directly in their ``context_factory`` and skip the
149+
per-call resolution entirely.
150+
151+
:param account_id: The resolved, stable account identifier. Safe to
152+
use as a cache key, audit log field, or authorization scope.
153+
:param account: The resolver's opaque account object — whatever the
154+
seller's :func:`resolve_account` resolver returned. Typed as
155+
``Any`` so sellers aren't forced to match the SDK's shape.
156+
"""
157+
158+
account_id: str | None = None
159+
account: Any | None = None
160+
161+
123162
class NotImplementedResponse(BaseModel):
124163
"""Standard response for operations not supported by this handler."""
125164

0 commit comments

Comments
 (0)