Skip to content

Commit e53cd1d

Browse files
rfc: ADR-0011 webhooks inbound + ADR-0012 server-initiated sampling (epic #87 items 5 + 6) (#95)
## Summary Third and final RFC pair for epic #87 Tier B. Both ADRs concern **server-initiated MCP messages** — webhooks fan out \`notifications/event\` to clients; sampling sends \`sampling/createMessage\` to clients. Both are opt-in per client and require graceful fallback when the client doesn't cooperate. **No code in this PR.** Pure design docs. ## ADR-0011 — Webhooks inbound + MCP notifications Closes the agent feedback loop: external events surface as MCP \`notifications/event\` instead of forcing agents to poll. - **Six providers v1**: github, gitlab, cloudflare, sentry, stripe, generic - **Single listener**: \`POST /webhooks/{provider}/{token}\` on the existing Cowboy endpoint (ADR-0004 preserved) - **Per-provider signature verification** — HMAC-SHA256, JWS as appropriate; signature-rejected events never reach the notification path - **Subscription persistence** at \`~/.boj/webhooks/\` (chmod 0600); managed via 5 new bridge tools (\`boj_webhook_subscribe/list/unsubscribe/rotate/replay\`) - **Bounded replay buffer** (100 events × N subscriptions) so reconnecting clients catch up - **Fan-out by selector** — broadcast, by \`client_kind\`, or by \`peer_token\` ## ADR-0012 — Server-initiated sampling \`sampling/createMessage\` is MCP's underused reverse path. BoJ uses it for **two specific patterns**, both opt-in per call site: | Pattern | When | |---|---| | Composition router | \`boj_cartridge_invoke\` against an ambiguous intent (e.g. "deploy and monitor") — ask the LLM which cartridge fits | | Clarification | An argument could match multiple backends — ask the LLM (which knows the user) which one | **Budget-bounded**: \`BOJ_SAMPLING_BUDGET_PER_SESSION\` (default 50); exceeded → deterministic fallback. **Always has fallback**: client rejection or timeout never blocks. **Hard NO list**: never in security-critical paths, never to ask "should I proceed?", never for input validation. Per-call OTel span (depends on PR #91) so sampling activity is observable in the user's existing telemetry. ## Why this pair Both are **server-initiated**, both are **opt-in per client**, both **degrade gracefully**. Coupling them in one review surfaces the shared protocol concerns (client cooperation, fallback discipline, audit attribution) in one place. ## Review focus Recommend reading **Open questions** in both: - ADR-0011: subscription persistence across restarts; backpressure on slow clients; provider extensibility (config vs code); authorization tier for subscription creation - ADR-0012: system-prompt safety; sampling-decision caching; multi-client target heuristic; cost attribution; fallback determinism ## What this doesn't change - No code touched. - No existing tools or cartridges affected. - ADR-0004 (single listener) preserved by 0011; ADR-0007 (policy DSL) extended by both (sampling result feeds a policy-gated tool call; webhook subscriptions are policy-gated artefacts). ## Sequencing This completes epic #87 Tier B (all 6 RFCs across PRs #92, #93, this one). Tier A is complete in code (#89, #91). Tier C is the long-running proof campaigns — separate work, no RFCs needed at this stage. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a855c18 commit e53cd1d

2 files changed

Lines changed: 397 additions & 0 deletions

File tree

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
<!-- SPDX-License-Identifier: MPL-2.0 -->
2+
<!-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> -->
3+
4+
# 11. Webhooks inbound + MCP notifications — closing the agent feedback loop
5+
6+
Date: 2026-05-20
7+
8+
## Status
9+
10+
Proposed (RFC — implementation tracked in epic #87 item 5)
11+
12+
## Context
13+
14+
BoJ today is **strictly pull-based**: an MCP client calls a tool, BoJ dispatches to a cartridge, returns a response. There is no path for *external events to surface to the connected LLM*. Concretely:
15+
16+
- A GitHub Actions workflow fails → no notification surfaces; the agent must poll
17+
- Cloudflare detects a DDoS → no notification; the agent must poll
18+
- Sentry fires an alert → no notification; the agent must poll
19+
- A new PR opens on a watched repo → no notification; the agent must poll
20+
- A long-running sandbox job (ADR-0009) completes → no notification; the agent must poll
21+
22+
This forces agents into either (a) polling loops, which burn LLM context and quota, or (b) blindness to events between tool calls. Either way, BoJ ends up being a query interface, not an operator. The "agent feedback loop" that's the differentiator for multi-agent systems doesn't close.
23+
24+
MCP supports server-pushed notifications via `notifications/*` methods. BoJ declares the *capability* (`tools/listChanged`, `prompts/listChanged`) but the bridge never *emits* a notification. The infrastructure is there; the wiring isn't.
25+
26+
External webhook sources (GitHub, GitLab, Cloudflare, Sentry, Stripe-style services) are the canonical mechanism for "something happened, here it is, on your URL of choice." BoJ can sit between webhook sources and MCP clients to bridge the gap.
27+
28+
## Decision
29+
30+
Add an **inbound webhook listener** to the BoJ REST backend (port 7700 — the existing Cowboy listener per ADR-0004) and a **MCP notification fan-out** layer that surfaces matching webhook events as `notifications/*` messages to connected MCP clients.
31+
32+
### Architectural shape
33+
34+
```
35+
External source ──HTTPS──▶ BoJ REST /webhooks/{provider}/{token}
36+
37+
38+
verify signature (HMAC / OIDC)
39+
40+
41+
match against subscriptions
42+
43+
44+
┌────────────┼─────────────┐
45+
▼ ▼ ▼
46+
MCP client A MCP client B audit log
47+
(notifications/event)
48+
```
49+
50+
### Inbound endpoint
51+
52+
The REST backend grows two new endpoints (no change to the bridge's stdio surface):
53+
54+
- `POST /webhooks/{provider}/{token}` — receives external webhook payloads
55+
- `GET /webhooks/subscriptions` — operator-side, lists active subscriptions
56+
57+
Providers in v1: `github`, `gitlab`, `cloudflare`, `sentry`, `stripe`, `generic`. `generic` accepts any JSON payload and applies generic shape matching; provider-specific endpoints know the source's signature scheme and event-type taxonomy.
58+
59+
The `{token}` segment is an opaque, per-subscription secret. Loss of the token URL = subscription compromise (can spoof events). Tokens are generated by `boj_webhook_subscribe` (see below) and stored in an encrypted form on disk; rotated by `boj_webhook_rotate`.
60+
61+
### Subscription model
62+
63+
Subscriptions are first-class. Stored in a new `webhook-subscriptions.a2ml` file under `~/.boj/webhooks/`. Schema:
64+
65+
```a2ml
66+
- id: <uuid>
67+
provider: github
68+
token: <opaque> # the path segment for the inbound URL
69+
filter: # only emit notification if event matches
70+
event_types: [pull_request.opened, push, workflow_run.completed]
71+
refs: ["refs/heads/main"]
72+
repos: ["hyperpolymath/boj-server"]
73+
fan_out: # which MCP clients receive the notification
74+
- client_kind: claude # all Claude sessions
75+
- peer_token: <coord_token> # one specific coord-registered peer
76+
- "*" # broadcast to all connected MCP clients (default)
77+
audit: true # log every received event regardless of fan-out
78+
```
79+
80+
### Bridge-level tools (subscription management)
81+
82+
- `boj_webhook_subscribe` — create a subscription; returns `(url, token, subscription_id)`
83+
- `boj_webhook_list` — list active subscriptions
84+
- `boj_webhook_unsubscribe` — delete a subscription
85+
- `boj_webhook_rotate` — rotate the token for a subscription (e.g. after a leak)
86+
- `boj_webhook_replay` — replay the last N events to the calling MCP client (useful when reconnecting)
87+
88+
These wire to the same `policies/boj-default.ncl` (ADR-0007). `boj_webhook_subscribe` is tier-2 (small_write — creates a resource); `boj_webhook_unsubscribe` is tier-2; `boj_webhook_rotate` is tier-3 (impacts auth).
89+
90+
### MCP notification format
91+
92+
When a webhook event matches a subscription:
93+
94+
```jsonrpc
95+
{
96+
"jsonrpc": "2.0",
97+
"method": "notifications/event",
98+
"params": {
99+
"subscription_id": "<uuid>",
100+
"provider": "github",
101+
"event_type": "pull_request.opened",
102+
"received_at": "2026-05-20T12:34:56Z",
103+
"source_signature_verified": true,
104+
"payload": { ... raw provider payload ... },
105+
"extracted": {
106+
// provider-specific normalised fields for LLM convenience
107+
"pr_number": 42,
108+
"repo": "hyperpolymath/boj-server",
109+
"title": "Fix the thing",
110+
"author": "alice"
111+
}
112+
}
113+
}
114+
```
115+
116+
The bridge emits this to **every connected MCP client whose fan_out matches the subscription's selector**. Clients that don't want notifications can ignore them; clients that want them can react.
117+
118+
### Replay buffer
119+
120+
Bridge keeps the last 100 events (per subscription, bounded ring buffer) so a reconnecting client can request `boj_webhook_replay` and catch up. Bounded so memory stays predictable.
121+
122+
### Signature verification
123+
124+
Each provider has a documented signature scheme:
125+
126+
| Provider | Scheme |
127+
|---|---|
128+
| GitHub | HMAC-SHA256 with subscription's secret on `X-Hub-Signature-256` |
129+
| GitLab | HMAC-SHA256 on `X-Gitlab-Token` |
130+
| Cloudflare | Webhook Signature spec (JSON Web Signature) |
131+
| Sentry | HMAC-SHA256 on `Sentry-Hook-Signature` |
132+
| Stripe | `Stripe-Signature` with timestamp-windowed HMAC |
133+
| `generic` | HMAC-SHA256 on `X-Boj-Signature`; constant-time compare |
134+
135+
If signature fails, the event is rejected, logged, and **never reaches the notification path**. `source_signature_verified: true` always means "we verified it"; we never accept unverified events as notifications.
136+
137+
## Consequences
138+
139+
### Positive
140+
141+
- **Closes the agent feedback loop** — agents react to events instead of polling. Major architectural unlock.
142+
- **MCP-native, not parallel infrastructure** — events come through the same MCP stdio surface clients already speak. No second protocol.
143+
- **Composable with existing cartridges**\`github-api-mcp\` already speaks GitHub's REST; this RFC adds the *push* side. Same auth, same provider.
144+
- **Replay supports reconnection** — long-running MCP clients (Claude Code, Cursor, etc.) that lose stdio connection don't lose events; they catch up on reconnect.
145+
- **Multi-client fan-out matches the multi-agent story** — one event can wake the right peer on the BoJ coord bus, leaving others uninvolved.
146+
- **Audit by default** — every received event is logged regardless of fan-out, so post-incident forensics is straightforward.
147+
- **Signature verification is bedrock** — no path for unverified events to reach a client; eliminates whole classes of webhook-spoofing attack.
148+
149+
### Negative
150+
151+
- **Stdio + push is awkward** — MCP stdio is a duplex stream, but most MCP clients don't expect server-initiated messages. Mitigation: per the MCP spec, `notifications/*` is part of the protocol; clients that implement the spec correctly will handle it. Clients that don't will simply ignore the notification (no fatal failure).
152+
- **Public ingress surface** — the inbound webhook endpoint must be reachable from the public internet for external providers to call it. Adds firewall configuration burden. Mitigation: document Cloudflare Tunnel / Tailscale-funnel / ngrok patterns; do not require operators to expose port 7700 directly.
153+
- **Subscription state on disk**`webhook-subscriptions.a2ml` is sensitive (tokens grant impersonation). Must be `chmod 0600` and lives under \`~/.boj/webhooks/\`. Documented; enforced at write time.
154+
- **Event ordering** — webhooks from external sources may arrive out of order or be redelivered. Mitigation: include the provider's native idempotency key (delivery ID) in the notification; the LLM (or downstream tooling) deduplicates.
155+
- **Latency budget** — webhook → signature verify → match → fan-out adds milliseconds. Mitigation: signature verification + matching is in-process; fan-out is async; the external provider doesn't wait for fan-out completion.
156+
157+
## Non-goals
158+
159+
- **Not building a generic event bus** — this is webhook-in, MCP-notification-out. Not for arbitrary inter-cartridge pub-sub (that's coord-mcp's job).
160+
- **Not implementing webhook *outbound*** — BoJ doesn't *send* webhooks to external systems. It receives them. Outbound notifications are a separate problem.
161+
- **Not retaining events beyond the replay window** — 100 events × ~10 active subscriptions = bounded memory. Long-term retention is the user's external observability stack (Sentry, etc.).
162+
- **Not bridging providers** — a GitHub webhook doesn't get re-emitted as a Slack message. That's the LLM's job once it receives the notification; BoJ is the delivery layer, not the policy layer.
163+
- **Not requiring TLS termination in BoJ** — the REST backend speaks HTTPS but operators are encouraged to terminate at Cloudflare/nginx/Tailscale-funnel. BoJ accepts HTTP locally; the inbound URL is HTTPS by virtue of the operator's chosen ingress.
164+
165+
## Open questions
166+
167+
1. **Subscription persistence across restarts**\`~/.boj/webhooks/webhook-subscriptions.a2ml\` is the persistent store, but what about the replay buffer? Recommend: replay buffer is in-memory only; subscriptions persist. Replay catches reconnection within a single BoJ process lifetime, not across restarts.
168+
169+
2. **MCP client identification** — when fanning out, "all Claude sessions" requires the bridge to know each client's identity. Today MCP's \`initialize\` carries \`clientInfo\` — sufficient for fan-out matching? Recommend yes, with fallback to peer_token for fine-grained selection.
170+
171+
3. **Provider extensibility** — adding a sixth provider (e.g. Linear webhooks) means new code. Should provider definitions be configuration-driven (Nickel contract per provider) rather than code? Recommend: signature schemes stay code (cryptography); event-shape normalisation can move to Nickel.
172+
173+
4. **Backpressure on slow clients** — if one MCP client is slow to consume notifications, do others wait? Recommend per-client send queues with bounded size; slow client gets events dropped (with a `notifications/missed` summary on reconnect).
174+
175+
5. **Authorization model on subscription creation** — should creating a webhook subscription require master role (ADR-0007 tier-3)? Recommend yes — webhook URLs grant impersonation capability, so creation is master-gated by default; can be overridden in policy.
176+
177+
6. **Reusing the existing REST backend vs separate listener** — the existing REST backend handles cartridge dispatch. Should webhooks share that listener or run on a separate port? Recommend share — ADR-0004 already chose single-listener; honour that. Path-routed (`/webhooks/...`) is unambiguous.
178+
179+
## Implementation sketch
180+
181+
When this RFC is accepted:
182+
183+
1. New module `elixir/lib/boj_rest/webhooks/` with one file per provider's signature scheme.
184+
2. Persistent store at `~/.boj/webhooks/webhook-subscriptions.a2ml` (chmod 0600); Idris2-verified read/write.
185+
3. Bridge-level tools `boj_webhook_subscribe` / `list` / `unsubscribe` / `rotate` / `replay` added to \`mcp-bridge/lib/tools.js\` (5 tools).
186+
4. Bridge notification emitter — new `mcp-bridge/lib/notifications.js` that maintains the per-client send queue + bounded backpressure.
187+
5. Default policy entries (ADR-0007): `boj_webhook_subscribe` tier-3, others tier-2.
188+
6. Provider documentation: a new `docs/cartridges/WEBHOOK-PROVIDERS.md` showing how to register a webhook URL with each supported provider.
189+
7. Tests: per-provider signature verification fixtures + end-to-end replay test.
190+
191+
## Linked
192+
193+
- ADR-0004 (unified gateway) — single listener; webhooks ride the same Cowboy endpoint.
194+
- ADR-0007 (policy DSL) — webhook subscriptions are tier-gated artefacts.
195+
- ADR-0008 (cartridge marketplace) — marketplace updates can flow to MCP clients via webhook notifications (see ADR-0008 open question 4).
196+
- ADR-0009 (sandbox cartridge) — long-running sandbox completion can emit notifications via this mechanism.
197+
- ADR-0010 (federation) — federated quarantine review can emit notifications to the master's MCP client.
198+
- Epic #87 item 5 (this).

0 commit comments

Comments
 (0)