main
md 368 lines 18.6 KB
Rendered Raw
1 # Shuffle MCP integration — per-customer notification routing
2
3 Planning doc for the Shuffle integration. Lives here while the feature is in
4 flight; gets folded into the architecture set or removed when the work
5 ships and the user-facing docs land.
6
7 **Branch:** `feat/shuffle-notifications`
8 **Status:** Planning — no code yet.
9
10 ---
11
12 ## Problem
13
14 Today, every Talon investigation writes back to the CoPilot database (job,
15 report, IOCs). That's it. There's no per-customer notification fan-out —
16 if one customer wants a Slack message on every true positive and another
17 wants an Outlook email on Critical-only, neither path exists.
18
19 Goals:
20
21 1. Let each customer pick their own notification destinations (Slack, Outlook,
22 Teams, email — eventually any of Shuffle's 3,000+ integrations).
23 2. Route per-customer, with severity thresholds and trigger types.
24 3. Don't change Talon's existing prompts or per-alert templates.
25 4. Keep the MCP boundary uniform — the agent should reach notifications via
26 the same stdio MCP pattern it already uses for `mysql`, `opensearch`, etc.
27
28 Non-goals:
29
30 - Replacing existing CoPilot integrations (Shuffle is for outbound notifications,
31 not for swapping out our connectors).
32 - Building our own integration catalog. We're consuming Shuffle's hosted MCP
33 layer, not reinventing it.
34
35 ---
36
37 ## Architecture overview
38
39 ```
40 ┌──────────────────────────────────────────────────────────────────┐
41 │ CoPilot frontend (Vue) │
42 │ │
43 │ Customers → [Acme] → Notifications tab │
44 │ ┌───────────────────────────────────────────┐ │
45 │ │ Connected integrations ← <ShuffleMCP> │ │
46 │ │ • Slack • Outlook • Teams │ │
47 │ ├───────────────────────────────────────────┤ │
48 │ │ Routing rules │ │
49 │ │ • Critical+ → Slack #soc-alerts │ │
50 │ │ • High+ → Outlook ir@corp.com │ │
51 │ └───────────────────────────────────────────┘ │
52 └────────────────────────┬─────────────────────────────────────────┘
53 │ REST (CoPilot DB)
54
55 ┌──────────────────────────────────────────────────────────────────┐
56 │ CoPilot backend (FastAPI) │
57 │ │
58 │ New tables: │
59 │ customer_shuffle_integrations (per-customer Shuffle keys) │
60 │ customer_notification_routes (severity → app → destination) │
61 │ notification_dispatch_log (idempotency + audit) │
62 │ │
63 │ New routes: │
64 │ GET/POST /customers/{code}/shuffle_integrations │
65 │ GET/POST /customers/{code}/notification_routes │
66 │ GET /customers/{code}/notification_dispatch_log │
67 └────────────────────────┬─────────────────────────────────────────┘
68 │ read-only MCP (mysql)
69
70 ┌──────────────────────────────────────────────────────────────────┐
71 │ Talon (NanoClaw) │
72 │ │
73 │ groups/copilot/.mcp.json gains: │
74 │ "shuffle": "/workspace/extra/shuffle-mcp/shuffle-mcp.sh" │
75 │ │
76 │ shuffle-mcp/shuffle-mcp.py exposes: │
77 │ shuffle_list_apps(customer_code) │
78 │ shuffle_invoke(customer_code, app, input) │
79 │ shuffle_dispatch_notifications(customer_code, alert_id, │
80 │ trigger, severity, summary) │
81 │ │
82 │ groups/copilot/CLAUDE.md gains a single new instruction: │
83 │ "After report write-back, call │
84 │ shuffle_dispatch_notifications(...) — best effort, log │
85 │ failures, never fail the investigation." │
86 └────────────────────────┬─────────────────────────────────────────┘
87 │ HTTP (JSON-RPC)
88
89 ┌──────────────────────────────────────────────────────────────────┐
90 │ Shuffle (hosted) │
91 │ https://shuffler.io/api/v1/apps/{app}/mcp │
92 │ Authorization: Bearer <per-customer Shuffle key> │
93 └──────────────────────────────────────────────────────────────────┘
94 ```
95
96 ---
97
98 ## Phase 1 — Manual webhooks, no Shuffle yet
99
100 **Why first:** ships value before any third-party dependency. Validates the
101 table shape, the dispatch loop, and the agent's "after write-back, fan out"
102 instruction.
103
104 ### Backend
105
106 #### Tables
107
108 ```python
109 class CustomerNotificationRoute(SQLModel, table=True):
110 __tablename__ = "customer_notification_routes"
111
112 id: int | None = Field(default=None, primary_key=True)
113 customer_code: str = Field(foreign_key="customers.customer_code", index=True)
114
115 name: str # human label, e.g. "SOC team Slack #alerts"
116 trigger: str # 'investigation_true_positive', 'severity_critical', ...
117 channel: str # Phase 1 set: 'smtp_email' only. Phase 2 adds 'shuffle'.
118 destination: str # webhook URL or email address
119 min_severity: str # 'Critical' | 'High' | 'Medium' | 'Low' | 'Informational'
120 format_template: str | None = None # optional Jinja override
121
122 enabled: bool = True
123
124 last_dispatched_at: datetime | None = None # denorm for UI list
125 dispatch_count: int = 0 # denorm counter
126 created_by: str | None = None # CoPilot user who added it
127
128 created_at: datetime = Field(default_factory=datetime.utcnow)
129 updated_at: datetime = Field(default_factory=datetime.utcnow)
130 ```
131
132 ```python
133 class NotificationDispatchLog(SQLModel, table=True):
134 __tablename__ = "notification_dispatch_log"
135 __table_args__ = (
136 UniqueConstraint(
137 "customer_code", "alert_id", "route_id", "trigger",
138 name="uq_notif_dispatch_idem",
139 ),
140 )
141
142 id: int | None = Field(default=None, primary_key=True)
143 customer_code: str = Field(index=True)
144 alert_id: int = Field(index=True)
145 route_id: int = Field(foreign_key="customer_notification_routes.id")
146 trigger: str
147
148 dispatched_at: datetime = Field(default_factory=datetime.utcnow)
149 status: str # 'sent' | 'failed' | 'skipped'
150 error_message: str | None = None
151 latency_ms: int | None = None
152 payload_preview: str | None = None # first 500 chars, debugging
153 ```
154
155 #### Deferred / dropped
156
157 - **`anonymize`** — dropped. Recipients are SOC analysts who already see
158 deanonymized reports in the UI; toggle has no consumer.
159 - **`tags`**, **`payload_filter`**, **`rate_limit_per_minute`** — deferred.
160 `trigger` + `min_severity` covers the 80% case. Phase 4 can layer on
161 richer filters / rate limits without touching this schema (rate limit
162 derivable from a windowed count over the dispatch log).
163
164 #### Wiring
165
166 - Alembic migration creates both tables
167 - Pydantic schemas in `app/notifications/schema.py`
168 - CRUD service + REST routes (`/customers/{code}/notification_routes`)
169 - Initial dispatch helper: `dispatch_smtp_email(to, subject, body)`
170 SMTP only. Slack/Teams/etc. arrive in Phase 2 via Shuffle's hosted
171 MCP rather than as raw webhook URLs in CoPilot, since Phase 2's
172 picker-based OAuth replaces the manual-paste UX entirely. Shipping
173 `slack_webhook` as a Phase 1 channel would have been throwaway UI.
174 - Logger writes to `notification_dispatch_log` with the unique-index
175 upsert pattern for idempotency
176
177 ### Frontend
178
179 - Customer detail page → new "Notifications" tab
180 - Form: pick channel (Slack/email), enter webhook URL or email, severity
181 threshold, trigger type
182 - List view of existing routes with enable/disable toggle
183 - Dispatch log viewer (read-only)
184
185 ### Talon
186
187 - Add a single new section to `groups/copilot/CLAUDE.md`:
188 > **After report write-back**, query `customer_notification_routes` for the
189 > alert's `customer_code` filtered by trigger and severity. For each
190 > enabled row, format the summary per the route's template (default:
191 > severity + alert link + summary) and POST the webhook / send the email.
192 > Notifications are **best-effort** — log success/failure to
193 > `notification_dispatch_log` keyed by `(customer_code, alert_id, route_id,
194 > trigger)`. Skip if the log already has a row for that key (idempotency).
195 > Do **not** fail the investigation on dispatch errors.
196 - The agent uses its existing tools (MySQL MCP for the route lookup + log
197 write, Bash with curl for the webhook POST). No new Talon-side MCP yet.
198
199 ### Acceptance
200
201 - Set `SMTP_HOST` / `SMTP_PORT` / `SMTP_FROM` (and creds if required) in CoPilot's environment
202 - Configure an SMTP route on one customer (e.g. `severity_critical_or_high``soc@example.com`)
203 - Trigger an investigation that resolves Critical or High
204 - Email arrives within ~10s of report write-back
205 - `notification_dispatch_log` has the row
206 - Re-running the same investigation does not re-fire the email
207
208 ---
209
210 ## Phase 2 — Shuffle proxy MCP in Talon
211
212 **Why:** unlocks the 3,000+ catalog without forcing the agent to learn each
213 provider's REST API. Single stdio MCP, same boundary as `mysql-mcp.sh`.
214
215 ### Talon
216
217 - New directory: `nanoclaw/shuffle-mcp/`
218 - `shuffle-mcp.sh` — bash wrapper (loads `.env`, exec's the python entry)
219 - `shuffle-mcp.py` — stdio MCP server using the standard MCP Python SDK
220 - `setup.sh` — install/activate per the existing pattern
221 - `CLAUDE.md` — short tool-selection guide for the agent
222 - Tools exposed:
223
224 | Tool | Purpose |
225 |------|---------|
226 | `shuffle_list_apps(customer_code)` | Return the customer's authenticated apps + a one-line description from the Shuffle catalog. Used at runtime so the agent picks intelligently. |
227 | `shuffle_invoke(customer_code, app, input)` | POST to `https://shuffler.io/api/v1/apps/{app}/mcp` with the customer's Bearer key. `input` is the natural-language string Shuffle expects. |
228 | `shuffle_dispatch_notifications(customer_code, alert_id, trigger, severity, summary)` | High-level convenience: looks up routes for the customer, formats per channel, calls `shuffle_invoke` for each, writes the dispatch log. Idempotent. |
229
230 - API key sourcing: the MCP queries CoPilot's MySQL for
231 `customer_shuffle_integrations.api_key WHERE customer_code = ?`. **Never**
232 trusts a `customer_code` parameter from the agent for cross-tenant lookups
233 — the MCP enforces the tenant boundary, not the prompt.
234 - Container build: install `shuffle-mcp` into a venv via
235 `container/Dockerfile`, like the existing `opensearch-mcp` / `mempalace`
236 pattern.
237
238 ### CoPilot backend
239
240 - Alembic migration: `customer_shuffle_integrations`
241 - `id`, `customer_code` (FK), `app` (text, e.g. "slack"),
242 `display_name`, `api_key` (encrypted), `connected_at`, `last_used_at`,
243 `enabled`
244 - REST: CRUD for integrations + a "test" route that calls Shuffle to verify
245 the key works (`tools/list` against the app's MCP endpoint)
246 - `customer_notification_routes` gains a `shuffle_app` column referencing
247 the integration. Phase 1's manual `channel`/`destination` columns become
248 optional — routes use one or the other.
249
250 ### Talon prompt change
251
252 Replace Phase 1's "POST the webhook" instruction with:
253
254 > **After report write-back**, call
255 > `shuffle_dispatch_notifications(customer_code, alert_id, trigger,
256 > severity, summary)`. The MCP handles routing, formatting, and the
257 > dispatch log internally. Best-effort — failures already logged.
258
259 Single tool call from the agent's perspective. The MCP owns the per-channel
260 formatting + idempotency + tenant scoping.
261
262 ### Acceptance
263
264 - Manually insert a row in `customer_shuffle_integrations` for one customer
265 with a real Shuffle API key
266 - Investigation completes → agent calls
267 `shuffle_dispatch_notifications` → Slack message arrives via Shuffle (not
268 via raw webhook)
269 - Verify the dispatch log entry shows `app=slack` and references the
270 Shuffle integration row, not a raw URL
271
272 ---
273
274 ## Phase 3 — Shuffle picker in CoPilot frontend
275
276 **Why:** removes the manual API key paste. Customers self-serve via the
277 embedded picker.
278
279 ### Frontend
280
281 - Install `@shuffleio/shuffle-mcps` (peer deps already met by Vue side via
282 the Vue export `@singulio/singul/vue`)
283 - Replace the manual "API key" input on the Notifications tab with the
284 `<ShuffleMCP>` (or Vue equivalent) component
285 - On `onAppSelected`, kick off Shuffle's OAuth flow, capture the resulting
286 Bearer key, POST it to CoPilot's `/customers/{code}/shuffle_integrations`
287 - Show connected integrations as cards; "Disconnect" button revokes the
288 CoPilot row (does not revoke at Shuffle — admin must do that themselves
289 via shuffler.io)
290
291 ### Backend
292
293 - No schema change — the picker just writes through the existing
294 `customer_shuffle_integrations` endpoint
295 - Optional: webhook receiver for Shuffle revocation events (later)
296
297 ### Acceptance
298
299 - A customer admin opens the Notifications tab → clicks "+ Add integration"
300 → picker shows 3,000+ apps → picks Slack → OAuth pops → returns a key
301 stored in CoPilot
302 - A new notification route can immediately reference this integration
303
304 ---
305
306 ## Phase 4 — Hardening
307
308 - **Per-channel format templates:** Slack gets compact + thread, email gets
309 full markdown, Teams gets adaptive card. Default templates in
310 `shuffle-mcp/templates/{channel}.j2`. Routes can override via
311 `format_template`.
312 - **Retry semantics:** failed dispatches retry once after 30s, then mark
313 failed. Logged in `notification_dispatch_log.status='failed'` with the
314 upstream error.
315 - **Audit trail UI:** Customer → Notifications → Dispatch log tab shows
316 recent fires, status, retry count.
317 - **Rate limiting per customer:** prevent runaway dispatch storms (e.g. a
318 detection rule firing 100x/min) — coalesce to 1 dispatch per minute per
319 route, summarize the rest.
320
321 ---
322
323 ## Cross-cutting concerns
324
325 | Concern | Decision |
326 |---------|----------|
327 | **Tenant isolation** | The Shuffle MCP itself is a stateless adapter — same `/apps/slack` URL for every customer. Isolation lives in two CoPilot-side places: (1) which `customer_shuffle_integrations.api_key` row gets fetched (Bearer token differs per customer's OAuth-issued workspace), (2) which `customer_notification_routes.destination` (channel name / email) is used. Both are filtered by `customer_code` at lookup time — single SQLAlchemy boundary. Cross-tenant leak risk is "did the lookup pull the right customer's row" — covered by the FK + an explicit test. |
328 | **Shuffle outage** | Notification step wrapped in try/except; failure does not fail the investigation. Logged to `notification_dispatch_log.status='failed'`. |
329 | **PII** | Recipients are SOC analysts who already see deanonymized reports in the CoPilot UI. No anonymization layer needed. The `anonymize` column from earlier drafts has been dropped. |
330 | **Idempotency** | Unique index on `(customer_code, alert_id, route_id, trigger)`. Agent's instruction is "skip if log row already exists." Re-runs are safe. |
331 | **Format mismatch** | Default templates per channel. Custom override per route via `format_template`. Phase 4 ships the default template set. |
332 | **Cost** | Shuffle's per-call pricing exists but isn't blocking — revisit once we have real volume. Phase 4's coalescing/rate-limit work covers it preemptively if needed. |
333 | **Failure mode visibility** | `notification_dispatch_log` is the source of truth. Frontend surfaces it. |
334
335 ---
336
337 ## Open questions
338
339 1. **Shuffle key revocation** — does Shuffle expose a webhook when a user
340 revokes upstream? Need this for clean state in CoPilot.
341 2. **Shuffle's `tools/list` schema** — does each app expose typed tool
342 schemas, or only the natural-language `tool_name` + `input` shape? If
343 typed, Phase 2 can register N tools per app instead of one generic
344 `shuffle_invoke`. Worth a 30-min spike before locking Phase 2's design.
345
346 ---
347
348 ## Out of scope (for now)
349
350 - Inbound: Talon receiving messages back through Shuffle (Shuffle → Talon).
351 Possible future use: slash commands in Slack to trigger investigations.
352 Not Phase 1–4.
353 - Replacing CoPilot's existing alerting (Graylog → CoPilot).
354 - Bidirectional state (closing an alert from Slack).
355
356 ---
357
358 ## Summary of phasing
359
360 | Phase | Duration estimate | Ships |
361 |-------|-------------------|-------|
362 | **1** | ~3 days | Working notifications via plain webhooks/SMTP. Schema + agent loop validated. |
363 | **2** | ~3 days | Shuffle MCP in Talon. 3,000+ apps reachable via the catalog. |
364 | **3** | ~2 days | Picker in CoPilot. Customer self-service. |
365 | **4** | ~3 days | Templates, anonymize, retry, audit, rate limit. |
366
367 Total ~11 working days end-to-end. Phase 1 is the only one that materially
368 touches Talon's prompt; Phases 2–4 are additive on the MCP / DB / UI sides.