diff --git a/README.md b/README.md index 59f630ac..73ae2f97 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/docs/handler-authoring.md b/docs/handler-authoring.md index c9878f28..f4d5f00a 100644 --- a/docs/handler-authoring.md +++ b/docs/handler-authoring.md @@ -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 diff --git a/docs/multi-tenant-contract.md b/docs/multi-tenant-contract.md new file mode 100644 index 00000000..f5556e83 --- /dev/null +++ b/docs/multi-tenant-contract.md @@ -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. diff --git a/src/adcp/server/__init__.py b/src/adcp/server/__init__.py index cc36c3a9..be41dfed 100644 --- a/src/adcp/server/__init__.py +++ b/src/adcp/server/__init__.py @@ -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, @@ -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 @@ -128,6 +130,7 @@ async def get_products(params, context=None): __all__ = [ # Base classes + "AccountAwareToolContext", "ADCPHandler", "BrandHandler", "ComplianceHandler", @@ -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", diff --git a/src/adcp/server/base.py b/src/adcp/server/base.py index 7eca795b..3f3fdfff 100644 --- a/src/adcp/server/base.py +++ b/src/adcp/server/base.py @@ -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.""" diff --git a/src/adcp/server/helpers.py b/src/adcp/server/helpers.py index 4df40492..0e6f80f1 100644 --- a/src/adcp/server/helpers.py +++ b/src/adcp/server/helpers.py @@ -13,10 +13,13 @@ from __future__ import annotations +import warnings from collections.abc import Awaitable, Callable from datetime import datetime, timezone from typing import Any +from adcp.server.base import AccountAwareToolContext + # All 32 codes from the ADCP spec (enums/error-code.json) plus SDK extensions. # Recovery classification: transient (retry), correctable (fix request), terminal. STANDARD_ERROR_CODES: dict[str, dict[str, str]] = { @@ -243,6 +246,77 @@ async def resolve_account( return account, None +async def resolve_account_into_context( + params: dict[str, Any], + context: AccountAwareToolContext | None, + resolver: AccountResolver | None, + *, + account_id_attr: str = "account_id", +) -> dict[str, Any] | None: + """Resolve an account reference and populate an + :class:`~adcp.server.AccountAwareToolContext`. + + Collapses the standard three-line boilerplate (resolve → check error + → extract id) into one call. Returns ``None`` on success (or when + there's nothing to resolve); returns an error dict to be returned + directly from the handler otherwise:: + + async def get_products(self, params, context=None): + err = await resolve_account_into_context( + params, context, my_resolver, + ) + if err: + return err + return products_response(catalog.for_account(context.account_id)) + + :param params: The request params dict, expected to carry an + ``account`` key with an ``AccountReference``. + :param context: The handler's context. Must be + :class:`~adcp.server.AccountAwareToolContext` (or a subclass of + it) to receive the resolved fields. Passing a plain + ``ToolContext`` runs resolution for the error path but logs a + ``UserWarning`` — the silent-skip would otherwise break the + multi-tenant scope contract. + :param resolver: An :data:`AccountResolver` — same shape as + :func:`resolve_account` accepts. + :param account_id_attr: Attribute name on the resolver's account + object that holds the stable id. Defaults to ``"account_id"`` + — matches the SDK's spec-generated :class:`~adcp.types.Account` + type. Override when your resolver returns a domain object + using a different attr name. + """ + account, err = await resolve_account(params, resolver) + if err is not None: + return err + if account is None: + return None + + if not isinstance(context, AccountAwareToolContext): + warnings.warn( + "resolve_account_into_context received a context that isn't an " + "AccountAwareToolContext — account was resolved but context not " + "mutated. Populate your handler's context_factory to return " + "AccountAwareToolContext (or a subclass), or parameterise your " + "handler with ADCPHandler[AccountAwareToolContext]. Silent skip " + "means downstream cache/audit keys will scope to None.", + UserWarning, + stacklevel=2, + ) + return None + + if not hasattr(account, account_id_attr): + raise ValueError( + f"Resolved account of type {type(account).__name__!r} has no " + f"{account_id_attr!r} attribute. Pass account_id_attr= to " + f"resolve_account_into_context() if your resolver returns a " + f"domain object using a different field name." + ) + + context.account = account + context.account_id = getattr(account, account_id_attr) + return None + + # ============================================================================ # Context Passthrough # ============================================================================ diff --git a/tests/test_handler_typevar.py b/tests/test_handler_typevar.py index 5e408ca3..71e2fae6 100644 --- a/tests/test_handler_typevar.py +++ b/tests/test_handler_typevar.py @@ -333,5 +333,61 @@ def test_handler_method_signatures_preserve_parameter_order(): assert "context" in params +# --------------------------------------------------------------------------- +# AccountAwareToolContext — shipped subclass exercised through the TypeVar +# --------------------------------------------------------------------------- + + +async def test_account_aware_context_flows_through_a2a_executor(): + """End-to-end: the shipped ``AccountAwareToolContext`` must flow + through ``ADCPAgentExecutor`` dispatch preserving its subclass + identity and populated fields. This is the path salesagent exercises + and the canonical example we point sellers at — a dispatch test is + the only test that catches regressions in the transport's context + plumbing against the shipped subclass.""" + from a2a.server.agent_execution.context import RequestContext + from a2a.server.events.event_queue import EventQueue + from a2a.types import DataPart, Message, MessageSendParams, Part, Role, Task + + from adcp.server import AccountAwareToolContext + from adcp.server.a2a_server import ADCPAgentExecutor + + received: list[Any] = [] + + class _AccountAwareAgent(ADCPHandler[AccountAwareToolContext]): + _agent_type = "account-aware" + + async def get_adcp_capabilities(self, params, context=None): + received.append(context) + return {"adcp": {"major_versions": [3]}, "supported_protocols": ["media_buy"]} + + def _factory(meta): + return AccountAwareToolContext( + caller_identity="p-1", + tenant_id="t-1", + account_id="acct-42", + ) + + executor = ADCPAgentExecutor(_AccountAwareAgent(), context_factory=_factory) + msg = Message( + message_id="m-1", + role=Role.user, + parts=[Part(root=DataPart(data={"skill": "get_adcp_capabilities", "parameters": {}}))], + ) + ctx = RequestContext(request=MessageSendParams(message=msg)) + queue = EventQueue() + await executor.execute(ctx, queue) + + event = await queue.dequeue_event(no_wait=True) + assert isinstance(event, Task) + assert event.status.state == "completed" + + assert len(received) == 1 + got = received[0] + assert isinstance(got, AccountAwareToolContext) + assert got.account_id == "acct-42" + assert got.tenant_id == "t-1" + + if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/tests/test_server_helpers.py b/tests/test_server_helpers.py index ace449d9..0df53a3a 100644 --- a/tests/test_server_helpers.py +++ b/tests/test_server_helpers.py @@ -13,6 +13,7 @@ inject_context, is_terminal_status, resolve_account, + resolve_account_into_context, valid_actions_for_status, ) @@ -125,9 +126,7 @@ async def test_successful_resolution(self) -> None: async def resolver(ref: dict) -> dict: return {"id": ref["account_id"], "name": "Acme"} - account, error = await resolve_account( - {"account": {"account_id": "a1"}}, resolver - ) + account, error = await resolve_account({"account": {"account_id": "a1"}}, resolver) assert account == {"id": "a1", "name": "Acme"} assert error is None @@ -136,9 +135,7 @@ async def test_not_found(self) -> None: async def resolver(ref: dict) -> None: return None - account, error = await resolve_account( - {"account": {"account_id": "bad"}}, resolver - ) + account, error = await resolve_account({"account": {"account_id": "bad"}}, resolver) assert account is None assert error is not None assert error["errors"][0]["code"] == "ACCOUNT_NOT_FOUND" @@ -150,9 +147,7 @@ async def test_suspended_via_account_error(self) -> None: async def resolver(ref: dict) -> None: raise AccountError("ACCOUNT_SUSPENDED", "Account is suspended") - account, error = await resolve_account( - {"account": {"account_id": "a1"}}, resolver - ) + account, error = await resolve_account({"account": {"account_id": "a1"}}, resolver) assert account is None assert error is not None assert error["errors"][0]["code"] == "ACCOUNT_SUSPENDED" @@ -167,13 +162,149 @@ async def resolver(ref: dict) -> None: suggestion="Update payment at https://billing.example.com", ) - account, error = await resolve_account( - {"account": {"account_id": "a1"}}, resolver - ) + account, error = await resolve_account({"account": {"account_id": "a1"}}, resolver) assert error["errors"][0]["code"] == "ACCOUNT_PAYMENT_REQUIRED" assert "billing" in error["errors"][0]["suggestion"] +class TestResolveAccountIntoContext: + """Tests for resolve_account_into_context — the context-populating variant.""" + + @pytest.mark.asyncio + async def test_populates_from_spec_account_shape(self) -> None: + """Default attr is account_id — matches the spec's Account type.""" + from dataclasses import dataclass + + from adcp.server import AccountAwareToolContext + + @dataclass + class _SpecAccount: + account_id: str + name: str + + async def resolver(ref: dict) -> _SpecAccount: + return _SpecAccount(account_id=ref["account_id"], name="Acme") + + ctx = AccountAwareToolContext(caller_identity="alice") + err = await resolve_account_into_context({"account": {"account_id": "a1"}}, ctx, resolver) + + assert err is None + assert ctx.account_id == "a1" + assert ctx.account is not None + assert ctx.account.name == "Acme" + + @pytest.mark.asyncio + async def test_not_found_returns_error_and_leaves_context_untouched(self) -> None: + from adcp.server import AccountAwareToolContext + + async def resolver(ref: dict) -> None: + return None + + ctx = AccountAwareToolContext(caller_identity="alice") + err = await resolve_account_into_context({"account": {"account_id": "bad"}}, ctx, resolver) + + assert err is not None + assert err["errors"][0]["code"] == "ACCOUNT_NOT_FOUND" + assert ctx.account_id is None + assert ctx.account is None + + @pytest.mark.asyncio + async def test_no_account_field_is_noop(self) -> None: + from adcp.server import AccountAwareToolContext + + async def resolver(ref: dict) -> dict: + return {"id": "x"} + + ctx = AccountAwareToolContext(caller_identity="alice") + err = await resolve_account_into_context({"brief": "test"}, ctx, resolver) + + assert err is None + assert ctx.account_id is None + + @pytest.mark.asyncio + async def test_plain_tool_context_warns_on_silent_skip(self) -> None: + """Passing a plain ToolContext (not Account-aware) MUST emit a + UserWarning — silent-skip would break the multi-tenant scope + contract by scoping downstream caches on ``None``.""" + from dataclasses import dataclass + + from adcp.server import ToolContext + + @dataclass + class _Account: + account_id: str + + async def resolver(ref: dict) -> _Account: + return _Account(account_id="a1") + + ctx = ToolContext(caller_identity="alice") + with pytest.warns(UserWarning, match="AccountAwareToolContext"): + err = await resolve_account_into_context( + {"account": {"account_id": "a1"}}, + ctx, # type: ignore[arg-type] + resolver, + ) + + assert err is None + assert not hasattr(ctx, "account_id") + + @pytest.mark.asyncio + async def test_missing_id_attr_raises(self) -> None: + """Wrong account_id_attr must raise rather than silently setting + None — silent-None scopes downstream keys to None, masking bugs.""" + from dataclasses import dataclass + + from adcp.server import AccountAwareToolContext + + @dataclass + class _Account: + name: str # deliberately no id field + + async def resolver(ref: dict) -> _Account: + return _Account(name="Acme") + + ctx = AccountAwareToolContext() + with pytest.raises(ValueError, match="account_id_attr"): + await resolve_account_into_context({"account": {"account_id": "a1"}}, ctx, resolver) + + @pytest.mark.asyncio + async def test_resolver_runtime_error_propagates(self) -> None: + """Non-AccountError exceptions propagate — resolver bugs must not be + silently converted to ACCOUNT_NOT_FOUND.""" + from adcp.server import AccountAwareToolContext + + async def resolver(ref: dict) -> None: + raise RuntimeError("DB outage") + + ctx = AccountAwareToolContext() + with pytest.raises(RuntimeError, match="DB outage"): + await resolve_account_into_context({"account": {"account_id": "a1"}}, ctx, resolver) + + @pytest.mark.asyncio + async def test_custom_id_attr(self) -> None: + from dataclasses import dataclass + + from adcp.server import AccountAwareToolContext + + @dataclass + class _Account: + account_pk: str + + async def resolver(ref: dict) -> _Account: + return _Account(account_pk="pk-123") + + ctx = AccountAwareToolContext() + err = await resolve_account_into_context( + {"account": {"account_id": "a1"}}, + ctx, + resolver, + account_id_attr="account_pk", + ) + + assert err is None + assert ctx.account_id == "pk-123" + + class TestInjectContext: def test_injects_context(self) -> None: params = {"brief": "test", "context": {"correlation_id": "abc"}} @@ -212,9 +343,7 @@ def test_auto_timestamp(self) -> None: assert resp["canceled_at"].endswith("+00:00") or "Z" in resp["canceled_at"] def test_custom_timestamp(self) -> None: - resp = cancel_media_buy_response( - "mb_123", "buyer", canceled_at="2026-01-01T00:00:00Z" - ) + resp = cancel_media_buy_response("mb_123", "buyer", canceled_at="2026-01-01T00:00:00Z") assert resp["canceled_at"] == "2026-01-01T00:00:00Z" def test_invalid_canceled_by_raises(self) -> None: @@ -254,7 +383,5 @@ def test_update_response_auto_actions(self) -> None: def test_explicit_actions_override(self) -> None: from adcp.server.responses import media_buy_response - resp = media_buy_response( - "mb_1", [], status="active", valid_actions=["cancel"] - ) + resp = media_buy_response("mb_1", [], status="active", valid_actions=["cancel"]) assert resp["valid_actions"] == ["cancel"]