Shuffle MCP integration — per-customer notification routing
Planning doc for the Shuffle integration. Lives here while the feature is in flight; gets folded into the architecture set or removed when the work ships and the user-facing docs land.
Branch: feat/shuffle-notifications
Status: Planning — no code yet.
Problem
Today, every Talon investigation writes back to the CoPilot database (job, report, IOCs). That's it. There's no per-customer notification fan-out — if one customer wants a Slack message on every true positive and another wants an Outlook email on Critical-only, neither path exists.
Goals:
- Let each customer pick their own notification destinations (Slack, Outlook, Teams, email — eventually any of Shuffle's 3,000+ integrations).
- Route per-customer, with severity thresholds and trigger types.
- Don't change Talon's existing prompts or per-alert templates.
- Keep the MCP boundary uniform — the agent should reach notifications via
the same stdio MCP pattern it already uses for
mysql,opensearch, etc.
Non-goals:
- Replacing existing CoPilot integrations (Shuffle is for outbound notifications, not for swapping out our connectors).
- Building our own integration catalog. We're consuming Shuffle's hosted MCP layer, not reinventing it.
Architecture overview
┌──────────────────────────────────────────────────────────────────┐
│ CoPilot frontend (Vue) │
│ │
│ Customers → [Acme] → Notifications tab │
│ ┌───────────────────────────────────────────┐ │
│ │ Connected integrations ← <ShuffleMCP> │ │
│ │ • Slack • Outlook • Teams │ │
│ ├───────────────────────────────────────────┤ │
│ │ Routing rules │ │
│ │ • Critical+ → Slack #soc-alerts │ │
│ │ • High+ → Outlook ir@corp.com │ │
│ └───────────────────────────────────────────┘ │
└────────────────────────┬─────────────────────────────────────────┘
│ REST (CoPilot DB)
▼
┌──────────────────────────────────────────────────────────────────┐
│ CoPilot backend (FastAPI) │
│ │
│ New tables: │
│ customer_shuffle_integrations (per-customer Shuffle keys) │
│ customer_notification_routes (severity → app → destination) │
│ notification_dispatch_log (idempotency + audit) │
│ │
│ New routes: │
│ GET/POST /customers/{code}/shuffle_integrations │
│ GET/POST /customers/{code}/notification_routes │
│ GET /customers/{code}/notification_dispatch_log │
└────────────────────────┬─────────────────────────────────────────┘
│ read-only MCP (mysql)
▼
┌──────────────────────────────────────────────────────────────────┐
│ Talon (NanoClaw) │
│ │
│ groups/copilot/.mcp.json gains: │
│ "shuffle": "/workspace/extra/shuffle-mcp/shuffle-mcp.sh" │
│ │
│ shuffle-mcp/shuffle-mcp.py exposes: │
│ shuffle_list_apps(customer_code) │
│ shuffle_invoke(customer_code, app, input) │
│ shuffle_dispatch_notifications(customer_code, alert_id, │
│ trigger, severity, summary) │
│ │
│ groups/copilot/CLAUDE.md gains a single new instruction: │
│ "After report write-back, call │
│ shuffle_dispatch_notifications(...) — best effort, log │
│ failures, never fail the investigation." │
└────────────────────────┬─────────────────────────────────────────┘
│ HTTP (JSON-RPC)
▼
┌──────────────────────────────────────────────────────────────────┐
│ Shuffle (hosted) │
│ https://shuffler.io/api/v1/apps/{app}/mcp │
│ Authorization: Bearer <per-customer Shuffle key> │
└──────────────────────────────────────────────────────────────────┘
Phase 1 — Manual webhooks, no Shuffle yet
Why first: ships value before any third-party dependency. Validates the table shape, the dispatch loop, and the agent's "after write-back, fan out" instruction.
Backend
Tables
class CustomerNotificationRoute(SQLModel, table=True):
__tablename__ = "customer_notification_routes"
id: int | None = Field(default=None, primary_key=True)
customer_code: str = Field(foreign_key="customers.customer_code", index=True)
name: str # human label, e.g. "SOC team Slack #alerts"
trigger: str # 'investigation_true_positive', 'severity_critical', ...
channel: str # Phase 1 set: 'smtp_email' only. Phase 2 adds 'shuffle'.
destination: str # webhook URL or email address
min_severity: str # 'Critical' | 'High' | 'Medium' | 'Low' | 'Informational'
format_template: str | None = None # optional Jinja override
enabled: bool = True
last_dispatched_at: datetime | None = None # denorm for UI list
dispatch_count: int = 0 # denorm counter
created_by: str | None = None # CoPilot user who added it
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
class NotificationDispatchLog(SQLModel, table=True):
__tablename__ = "notification_dispatch_log"
__table_args__ = (
UniqueConstraint(
"customer_code", "alert_id", "route_id", "trigger",
name="uq_notif_dispatch_idem",
),
)
id: int | None = Field(default=None, primary_key=True)
customer_code: str = Field(index=True)
alert_id: int = Field(index=True)
route_id: int = Field(foreign_key="customer_notification_routes.id")
trigger: str
dispatched_at: datetime = Field(default_factory=datetime.utcnow)
status: str # 'sent' | 'failed' | 'skipped'
error_message: str | None = None
latency_ms: int | None = None
payload_preview: str | None = None # first 500 chars, debugging
Deferred / dropped
anonymize— dropped. Recipients are SOC analysts who already see deanonymized reports in the UI; toggle has no consumer.tags,payload_filter,rate_limit_per_minute— deferred.trigger+min_severitycovers the 80% case. Phase 4 can layer on richer filters / rate limits without touching this schema (rate limit derivable from a windowed count over the dispatch log).
Wiring
- Alembic migration creates both tables
- Pydantic schemas in
app/notifications/schema.py - CRUD service + REST routes (
/customers/{code}/notification_routes) - Initial dispatch helper:
dispatch_smtp_email(to, subject, body)— SMTP only. Slack/Teams/etc. arrive in Phase 2 via Shuffle's hosted MCP rather than as raw webhook URLs in CoPilot, since Phase 2's picker-based OAuth replaces the manual-paste UX entirely. Shippingslack_webhookas a Phase 1 channel would have been throwaway UI. - Logger writes to
notification_dispatch_logwith the unique-index upsert pattern for idempotency
Frontend
- Customer detail page → new "Notifications" tab
- Form: pick channel (Slack/email), enter webhook URL or email, severity threshold, trigger type
- List view of existing routes with enable/disable toggle
- Dispatch log viewer (read-only)
Talon
- Add a single new section to
groups/copilot/CLAUDE.md: > After report write-back, querycustomer_notification_routesfor the > alert'scustomer_codefiltered by trigger and severity. For each > enabled row, format the summary per the route's template (default: > severity + alert link + summary) and POST the webhook / send the email. > Notifications are best-effort — log success/failure to >notification_dispatch_logkeyed by(customer_code, alert_id, route_id, > trigger). Skip if the log already has a row for that key (idempotency). > Do not fail the investigation on dispatch errors. - The agent uses its existing tools (MySQL MCP for the route lookup + log write, Bash with curl for the webhook POST). No new Talon-side MCP yet.
Acceptance
- Set
SMTP_HOST/SMTP_PORT/SMTP_FROM(and creds if required) in CoPilot's environment - Configure an SMTP route on one customer (e.g.
severity_critical_or_high→soc@example.com) - Trigger an investigation that resolves Critical or High
- Email arrives within ~10s of report write-back
notification_dispatch_loghas the row- Re-running the same investigation does not re-fire the email
Phase 2 — Shuffle proxy MCP in Talon
Why: unlocks the 3,000+ catalog without forcing the agent to learn each
provider's REST API. Single stdio MCP, same boundary as mysql-mcp.sh.
Talon
- New directory:
nanoclaw/shuffle-mcp/shuffle-mcp.sh— bash wrapper (loads.env, exec's the python entry)shuffle-mcp.py— stdio MCP server using the standard MCP Python SDKsetup.sh— install/activate per the existing patternCLAUDE.md— short tool-selection guide for the agent
- Tools exposed:
| Tool | Purpose |
|------|---------|
| 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. |
| 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. |
| 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. |
- API key sourcing: the MCP queries CoPilot's MySQL for
customer_shuffle_integrations.api_key WHERE customer_code = ?. Never trusts acustomer_codeparameter from the agent for cross-tenant lookups — the MCP enforces the tenant boundary, not the prompt. - Container build: install
shuffle-mcpinto a venv viacontainer/Dockerfile, like the existingopensearch-mcp/mempalacepattern.
CoPilot backend
- Alembic migration:
customer_shuffle_integrationsid,customer_code(FK),app(text, e.g. "slack"),display_name,api_key(encrypted),connected_at,last_used_at,enabled
- REST: CRUD for integrations + a "test" route that calls Shuffle to verify
the key works (
tools/listagainst the app's MCP endpoint) customer_notification_routesgains ashuffle_appcolumn referencing the integration. Phase 1's manualchannel/destinationcolumns become optional — routes use one or the other.
Talon prompt change
Replace Phase 1's "POST the webhook" instruction with:
After report write-back, call
shuffle_dispatch_notifications(customer_code, alert_id, trigger, severity, summary). The MCP handles routing, formatting, and the dispatch log internally. Best-effort — failures already logged.
Single tool call from the agent's perspective. The MCP owns the per-channel formatting + idempotency + tenant scoping.
Acceptance
- Manually insert a row in
customer_shuffle_integrationsfor one customer with a real Shuffle API key - Investigation completes → agent calls
shuffle_dispatch_notifications→ Slack message arrives via Shuffle (not via raw webhook) - Verify the dispatch log entry shows
app=slackand references the Shuffle integration row, not a raw URL
Phase 3 — Shuffle picker in CoPilot frontend
Why: removes the manual API key paste. Customers self-serve via the embedded picker.
Frontend
- Install
@shuffleio/shuffle-mcps(peer deps already met by Vue side via the Vue export@singulio/singul/vue) - Replace the manual "API key" input on the Notifications tab with the
<ShuffleMCP>(or Vue equivalent) component - On
onAppSelected, kick off Shuffle's OAuth flow, capture the resulting Bearer key, POST it to CoPilot's/customers/{code}/shuffle_integrations - Show connected integrations as cards; "Disconnect" button revokes the CoPilot row (does not revoke at Shuffle — admin must do that themselves via shuffler.io)
Backend
- No schema change — the picker just writes through the existing
customer_shuffle_integrationsendpoint - Optional: webhook receiver for Shuffle revocation events (later)
Acceptance
- A customer admin opens the Notifications tab → clicks "+ Add integration" → picker shows 3,000+ apps → picks Slack → OAuth pops → returns a key stored in CoPilot
- A new notification route can immediately reference this integration
Phase 4 — Hardening
- Per-channel format templates: Slack gets compact + thread, email gets
full markdown, Teams gets adaptive card. Default templates in
shuffle-mcp/templates/{channel}.j2. Routes can override viaformat_template. - Retry semantics: failed dispatches retry once after 30s, then mark
failed. Logged in
notification_dispatch_log.status='failed'with the upstream error. - Audit trail UI: Customer → Notifications → Dispatch log tab shows recent fires, status, retry count.
- Rate limiting per customer: prevent runaway dispatch storms (e.g. a detection rule firing 100x/min) — coalesce to 1 dispatch per minute per route, summarize the rest.
Cross-cutting concerns
| Concern | Decision |
|---|---|
| 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. |
| Shuffle outage | Notification step wrapped in try/except; failure does not fail the investigation. Logged to notification_dispatch_log.status='failed'. |
| 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. |
| 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. |
| Format mismatch | Default templates per channel. Custom override per route via format_template. Phase 4 ships the default template set. |
| 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. |
| Failure mode visibility | notification_dispatch_log is the source of truth. Frontend surfaces it. |
Open questions
- Shuffle key revocation — does Shuffle expose a webhook when a user revokes upstream? Need this for clean state in CoPilot.
- Shuffle's
tools/listschema — does each app expose typed tool schemas, or only the natural-languagetool_name+inputshape? If typed, Phase 2 can register N tools per app instead of one genericshuffle_invoke. Worth a 30-min spike before locking Phase 2's design.
Out of scope (for now)
- Inbound: Talon receiving messages back through Shuffle (Shuffle → Talon). Possible future use: slash commands in Slack to trigger investigations. Not Phase 1–4.
- Replacing CoPilot's existing alerting (Graylog → CoPilot).
- Bidirectional state (closing an alert from Slack).
Summary of phasing
| Phase | Duration estimate | Ships |
|---|---|---|
| 1 | ~3 days | Working notifications via plain webhooks/SMTP. Schema + agent loop validated. |
| 2 | ~3 days | Shuffle MCP in Talon. 3,000+ apps reachable via the catalog. |
| 3 | ~2 days | Picker in CoPilot. Customer self-service. |
| 4 | ~3 days | Templates, anonymize, retry, audit, rate limit. |
Total ~11 working days end-to-end. Phase 1 is the only one that materially touches Talon's prompt; Phases 2–4 are additive on the MCP / DB / UI sides.