MCP push subscriptions
Subscribe a connected MCP client to Pact event topics and receive live notifications via MCP's notifications/* protocol — the first push-based MCP substrate in market.
Every CRM has webhooks: HTTP push to a URL you operate. Pact ships the same
idea native to the Model Context Protocol — any MCP client (Claude
Desktop, Cursor, Cline, custom) can subscribe to Pact event topics and the
server pushes a notifications/pact/event JSON-RPC frame the moment a
matching event fires. No webhook receiver to stand up, no public URL to
manage, no shared secret to rotate.
This is the first push-based MCP substrate in market. The MCP specification allows server-initiated notifications; nobody else productizes them. Pact is first.
Why
Pull-only MCP means an AI agent has to poll ("are there any new deals at Closed Won?") or be fired manually ("check this account for me"). Push means:
- Real-time copilots. Claude Desktop pops a desktop notification when a deal closes. Cursor surfaces a CodeLens when a customer file is open and their account health flips to at risk. A custom MCP client wired to Slack / Phone / email gets zero-polling notifications.
- Cost honesty. Polling is expensive — every poll is a billed tool call.
Push fires once per event. The cost dashboard at
/pact-admin/mcp/subscriptionsshows you exactly what your subscribers consumed. - Consent-native. Every event passes the same consent gate that protects Pact's webhooks and SSE substrate. A withdrawn data subject is never pushed, no matter who subscribed.
How it works
┌───────────────────────────────────────────┐
│ Pact event log (Wave AT, durable) │
│ → emit() writes one row to domain_events │
└───────────────────┬───────────────────────┘
│ tail() by id-cursor
▼
┌──────────────────────────────────────┐
│ core.mcp_outbound dispatcher │
│ • match topic glob per subscription │
│ • apply filter clauses │
│ • format MCP JSON-RPC notification │
│ • backpressure ring buffer (100) │
└──────────────┬───────────────────────┘
│ SSE
▼
/mcp/push/sse ──→ Your MCP client (Claude Desktop, Cursor, …)
Handles notifications/pact/event
Handles notifications/pact/subscription.lag
Handles notifications/pact/heartbeat
Pact's MCP server at /mcp uses stateless Streamable-HTTP for tool calls
(simpler to scale, no connection affinity). Push needs a connection — so the
server opens a side-channel Server-Sent Events stream at /mcp/push/sse
that you keep alive alongside your MCP session. Same bearer token, same
tenant. The wire format is straight MCP JSON-RPC inside SSE data: frames.
The control plane: four MCP tools
Subscriptions are managed through ordinary MCP tools on /mcp — your client
calls them just like any other tool. The server resolves your tenant from
the access token; you never pass a tenant_id argument.
subscribe(topic, filter?, client_id, client_label?, consent_purpose?)
Register interest in one or more event topics.
| Param | Type | Required | What it is |
|---|---|---|---|
topic | string[] | yes | Topic patterns (fnmatch globs). E.g. ["deal.*", "workflow.run.*"], ["account.health_at_risk"]. |
client_id | string | yes | Stable identifier for your client process — e.g. "claude-desktop-mbp-dean" or "cursor-machine-12345". Subscriptions persist under this id across reconnects. |
filter | object | no | Filter dict — keys map to payload fields. See Filters below. |
client_label | string | no | Human-readable label for the admin dashboard. |
consent_purpose | string | no | Scope visibility by consent purpose (e.g. "marketing"). |
Idempotent. Re-subscribing with the same topics + filter under the same
client_id returns the existing subscription — your client can call
subscribe on every startup without growing the row count.
Returns:
{
"subscription_id": "8b2a1f04-…",
"client_id": "claude-desktop-mbp-dean",
"topic_patterns": ["deal.*"],
"filter": { "account_id": "acc_uuid" },
"events_delivered": 0,
"push_endpoint": "/mcp/push/sse",
"push_endpoint_hint": "Open a Server-Sent Events stream at /mcp/push/sse …"
}
unsubscribe(subscription_id)
Soft-delete one subscription. The row is kept (rolling counters + audit), but no further events push. Re-subscribing with the same topic+filter reactivates the row.
list_subscriptions(client_id, include_deleted=false)
Read every subscription this client has wired.
list_topics(category?)
The canonical event topic catalog with per-topic filter examples. Pass
category to scope to one of crm, marketing, service, ai,
workflow, integration, mcp, consent, platform, custom.
Filters
The filter dict is a friendly shorthand for the Wave AT event-stream filter grammar. Keys map to event payload fields; suffixes encode the operator.
| Key shape | Example | What it does |
|---|---|---|
key: value | {"owner": "rep@demo"} | Equality on payload.owner. |
key: [v1, v2] | {"stage_to": ["Closed Won", "Closed Lost"]} | IN match — any of the listed values. |
key_gte: n | {"amount_gte": 100000} | payload.amount >= 100000. |
key_lte, key_gt, key_lt | {"score_gt": 80} | Numeric comparison. |
key_not: v | {"owner_not": "system"} | != match. |
key_in / key_nin | {"priority_in": ["p1", "p2"]} | Explicit IN / NOT IN. |
key_prefix: s | {"email_prefix": "dean@"} | String prefix. |
key_contains: s | {"name_contains": "Acme"} | Substring. |
key_exists: true | {"correlation_id_exists": true} | Field is present. |
A handful of common keys are aliased to canonical payload paths so you don't have to learn the per-topic payload shape:
| Friendly key | Maps to |
|---|---|
account_id | payload.account_id |
deal_id | payload.deal_id |
stage_to | payload.new_stage |
stage_from | payload.old_stage |
health_band_to | payload.new_band |
owner, actor, actor_id, status, … | sensible payload defaults |
Anything not aliased falls through to payload.<key> — the natural reading.
The push channel: /mcp/push/sse
Once you've called subscribe, open a Server-Sent Events stream with the
same bearer token you use for /mcp:
GET /mcp/push/sse?client_id=claude-desktop-mbp-dean
Authorization: Bearer pact_live_…
Accept: text/event-stream
Each SSE data: frame is one MCP JSON-RPC notification. The SSE event:
field carries the method name (slashes flattened to dots) so clients that
want to subscribe by name can; clients that just parse the JSON envelope can
ignore it.
Frames you'll see
notifications/pact/subscription.open — handshake on connect.
{
"jsonrpc": "2.0",
"method": "notifications/pact/subscription.open",
"params": {
"client_id": "claude-desktop-mbp-dean",
"subscriptions": [ /* every active subscription this client has */ ],
"cursor": 0
}
}
notifications/pact/event — a matched event.
{
"jsonrpc": "2.0",
"method": "notifications/pact/event",
"params": {
"subscription_id": "8b2a1f04-…",
"event_id": 1287,
"topic": "deal.won",
"category": "crm",
"aggregate_type": "deal",
"aggregate_id": "deal_uuid",
"actor_id": "rep@demo",
"correlation_id": "req_abc",
"occurred_at": "2026-06-12T10:00:00",
"payload": {
"account_id": "acc_uuid",
"amount": 50000,
"new_stage": "Closed Won"
}
}
}
notifications/pact/subscription.lag — backpressure warning. Each
connection has a 100-event ring buffer; on overflow we drop oldest and emit
one warning per overflow flurry.
{
"jsonrpc": "2.0",
"method": "notifications/pact/subscription.lag",
"params": {
"subscription_id": "8b2a1f04-…",
"dropped_count": 17,
"buffer_capacity": 100,
"last_event_id": 1287,
"detail": "Per-connection event buffer is overflowing — dropping oldest events. Reduce filter scope or consume faster; resume from last_event_id on reconnect."
}
}
notifications/pact/heartbeat — every 15 seconds. Keeps proxies from
idling out the stream.
{ "jsonrpc": "2.0", "method": "notifications/pact/heartbeat",
"params": { "cursor": 1287 } }
Wire it up: Claude Desktop sample
Add a Pact MCP server config to ~/Library/Application Support/Claude/claude_desktop_config.json
(macOS) or the equivalent on your OS:
{
"mcpServers": {
"pact": {
"command": "npx",
"args": ["@pact/mcp-client", "stdio-proxy"],
"env": {
"PACT_API_KEY": "pact_live_…",
"PACT_MCP_ENDPOINT": "https://api.pact.place/mcp",
"PACT_PUSH_ENDPOINT": "https://api.pact.place/mcp/push/sse",
"PACT_CLIENT_ID": "claude-desktop-mbp-dean"
}
}
}
}
Then, from any Claude conversation:
"Subscribe me to deal closures over $100k on account
acc_uuid."
Claude calls the MCP tool:
await pact.subscribe({
topic: ["deal.won"],
filter: { account_id: "acc_uuid", amount_gte: 100000 },
client_id: "claude-desktop-mbp-dean",
client_label: "Dean's Claude Desktop",
});
…and the next time a matching deal closes, Claude sees a
notifications/pact/event frame and surfaces it in your conversation
without you typing a thing.
Cursor sample
import { PactClient } from "@pact/mcp-client";
const pact = new PactClient({ apiKey: process.env.PACT_API_KEY! });
// Subscribe + open the push stream once at editor startup.
await pact.subscribe({
topic: ["account.health_at_risk"],
client_id: "cursor-machine-12345",
});
for await (const frame of pact.pushStream({ clientId: "cursor-machine-12345" })) {
if (frame.method === "notifications/pact/event") {
const accountId = frame.params.payload.account_id;
cursor.codelens.add({
file: workspace.findCustomerFile(accountId),
label: `⚠ ${accountId} flipped to at_risk — review notes`,
});
}
}
Workflows can fire custom topics (Wave AC)
Subscribers can listen for tenant-defined custom.* topics. Any Wave AC
workflow can fire one via the push_event action node:
- type: push_event
config:
topic: custom.deal_at_risk
payload:
account_id: "{{ deal.account_id }}"
reason: "no activity in 14 days"
severity: "warning"
The event lands in domain_events, fans out to webhooks, wakes any SSE/WS
consumer, and pushes to every MCP client subscribed to custom.* or
custom.deal_at_risk — one emit, all transports.
Guarantees
- Tenant isolation. Every subscription, every push, every audit row is scoped to the tenant the bearer token resolves to. Cross-tenant access is structurally impossible.
- Consent-native. Each event passes the Wave AT consent gate before the dispatcher sees it. A withdrawn or suppressed data subject is never pushed.
- Idempotent. Re-subscribing with the same intent returns the existing subscription — safe to call on every client startup.
- Backpressure-safe. A slow client doesn't block the dispatcher: the
ring buffer caps at 100 events per connection and a single
subscription.lagnotification covers an overflow flurry. - Audit-trailed. Every delivery writes a row to
mcp_push_deliveries; rolling counters on the subscription row feed the/pact-admin/mcp/subscriptionsdashboard. - Cost-honest. Per-event push cost is ~0.01¢ (one INSERT + one UPDATE). The dashboard surfaces it even though the absolute number is tiny.
Observability
Pact admins see every active subscription, rolling counters, and an
estimated cost line at
/pact-admin/mcp/subscriptions. Read-only —
subscriptions are owned by the connected client and torn down through
unsubscribe(). The page reads through GET /v1/admin/mcp/outbound/summary
/subscriptions(admin role + theadminmodule).
See also
- Event taxonomy — the full list of built-in topics.
- Webhooks 2.0 — Pact's HTTP push if you're not using MCP.
- Wave AC workflow builder — fire
custom.*topics from a no-code workflow. - Consent model — what the gate does and why push respects it.