Skip to content

Commit 2ea7c0e

Browse files
hyperpolymathclaude
andcommitted
rfc(decisions): ADR-0011 webhooks inbound + ADR-0012 server-initiated sampling
Third coupled RFC pair for epic #87 Tier B (items 5 + 6). Coupled because both are server-initiated MCP message types — webhooks fan out notifications/event to clients; sampling sends sampling/createMessage to clients. Both opt-in per client; both require graceful fallback. ADR-0011 — Webhooks inbound + MCP notifications - Closes the agent feedback loop: external events surface as MCP notifications/event instead of forcing agents to poll - POST /webhooks/{provider}/{token} on the existing Cowboy listener (ADR-0004 single-listener policy preserved) - Six providers in v1: github, gitlab, cloudflare, sentry, stripe, generic - Per-provider signature verification (HMAC-SHA256, JWS as appropriate) - Subscription state persists at ~/.boj/webhooks/ (chmod 0600) - Fan-out by client_kind / peer_token / "*"; per-subscription filter - Bounded replay buffer (100 events × N subscriptions) for reconnecting clients - 5 new bridge tools: boj_webhook_subscribe/list/unsubscribe/rotate/replay - Audit by default; signature-rejected events never reach the notification path ADR-0012 — Server-initiated sampling - Two patterns only: composition_router (which cartridge?) and clarification (which option does the user mean?) - Budget-bounded: BOJ_SAMPLING_BUDGET_PER_SESSION default 50; exceeded → deterministic fallback (first option in list) - Always-available 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 - OTel span per sampling request with pattern + candidates_count + result + chosen + budget_remaining (depends on PR #91) - Caching: per-session LRU on (pattern, question-hash); same intent → same choice within a session Both RFCs include consequences (positive + negative), explicit non-goals, and open questions calling out decisions deferred to implementation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 76e15b1 commit 2ea7c0e

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)