Skip to content

Commit ea21864

Browse files
authored
Merge pull request #232 from adcontextprotocol/bokelley/a2a-push-config-store
feat(server): pluggable PushNotificationConfigStore on A2A (#225)
2 parents b2e22a5 + 5d3eff8 commit ea21864

5 files changed

Lines changed: 656 additions & 11 deletions

File tree

docs/handler-authoring.md

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -249,16 +249,93 @@ reset; your persistent store can't:**
249249
sweep deleting tasks in `completed` / `canceled` / `failed` states
250250
older than your retention policy.
251251

252+
### Durable push-notification config storage
253+
254+
Clients subscribe to task progress by calling
255+
`tasks/pushNotificationConfig/set`. a2a-sdk's default behavior is
256+
**push-notif disabled** — the endpoint surfaces
257+
`UnsupportedOperationError` until you wire a store. Sellers that accept
258+
push-notif subscriptions pass one:
259+
260+
```python
261+
from adcp.server import serve
262+
from examples.a2a_db_tasks import (
263+
SqliteTaskStore,
264+
SqlitePushNotificationConfigStore,
265+
)
266+
267+
serve(
268+
MyAgent(),
269+
transport="a2a",
270+
task_store=SqliteTaskStore("/var/lib/myagent/tasks.db"),
271+
push_config_store=SqlitePushNotificationConfigStore(
272+
"/var/lib/myagent/push_configs.db"
273+
),
274+
)
275+
```
276+
277+
**Three things a durable push-notification config store MUST do —
278+
beyond the four from the TaskStore section above:**
279+
280+
1. **Validate the client-supplied `url` against an allowlist before
281+
persisting.** a2a-sdk's push-notif sender POSTs full task JSON to
282+
whatever URL is stored, with no built-in validation. An attacker
283+
registering `url=http://169.254.169.254/…` (cloud metadata) or
284+
`http://localhost:5432/` (internal services) gets SSRF +
285+
exfiltration in one call — the task JSON that lands on the
286+
attacker's server includes `history` and `artifacts`. The
287+
reference impl does NOT validate URLs; the seller's store (or
288+
a pre-persist hook) must. Reject non-https, reject RFC 1918 /
289+
IPv6 link-local, and require the host match an egress allowlist
290+
before `set_info` writes anything.
291+
2. **Treat `PushNotificationConfig.authentication.credentials` and
292+
`PushNotificationConfig.token` as secrets at rest.** Clients pass
293+
bearer tokens / shared secrets so the agent's callbacks can
294+
authenticate. The reference impl serialises them to plaintext JSON
295+
under `chmod 0o600` — safe on a single-user host but that
296+
guarantee doesn't survive backups, Docker bind mounts with wrong
297+
umask, DB-to-Postgres migrations, or shared-volume mounts.
298+
Production stores should envelope-encrypt those fields, or persist
299+
opaque references and keep the secrets in a dedicated backend
300+
(Vault, AWS KMS, GCP Secret Manager).
301+
3. **Scope by principal, not just by tenant.** a2a-sdk's ABC doesn't
302+
pass a `ServerCallContext` to push-config methods, so scoping has
303+
to happen out-of-band. The reference `SqlitePushNotificationConfigStore`
304+
reads a `ContextVar` your auth middleware populates and writes a
305+
`scope` column on every row. Cross-scope isolation works; **within
306+
a scope, multiple principals can still overwrite each other's
307+
configs** (same `(scope, task_id)`, client omits `config_id`, PK
308+
collision). For multi-principal-per-tenant deployments, widen the
309+
scope to include the principal (e.g. `f"{tenant}:{principal}"`) or
310+
require clients to supply an explicit `config_id`.
311+
312+
**Scoping caveat.** The reference impl's ContextVar approach has a
313+
known gap: a2a-sdk's push-notif sender runs in a background
314+
`asyncio.Task` that inherits the ContextVar snapshot from
315+
task-creation time. If the seller's auth middleware has already reset
316+
the ContextVar before the sender reads it, `get_info` returns empty
317+
and notifications silently drop. Sellers running non-blocking
318+
push-notifs must propagate scope into the sender path explicitly —
319+
either capture the scope at `set_info` time and stash it alongside
320+
the config, or override a2a-sdk's `BasePushNotificationSender` to
321+
re-set the ContextVar before calling `get_info`. Not yet addressed in
322+
the SDK.
323+
324+
**Operator-facing failure modes.** When `scope_provider` returns
325+
`None`, the reference store falls through to an `__anonymous__`
326+
bucket and emits a one-time `UserWarning`. Silent fall-through would
327+
share one push-notif bucket across every unauthenticated caller. The
328+
warning is the signal your auth middleware isn't populating the
329+
ContextVar — treat it as a P0.
330+
252331
### Known gaps
253332

254-
- Push-notification config is in-memory only — tracked at
255-
[#225](https://github.com/adcontextprotocol/adcp-client-python/issues/225).
256333
- Per-skill middleware hooks for audit logging / activity feeds don't
257334
exist yet — tracked at
258335
[#226](https://github.com/adcontextprotocol/adcp-client-python/issues/226).
259336

260-
Once #225 and #226 land, A2A adoption reaches parity with MCP for
261-
production agents.
337+
Once #226 lands, A2A adoption reaches parity with MCP for production
338+
agents.
262339

263340
## Testing
264341

0 commit comments

Comments
 (0)