| 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. |