PPactDocs
MCP & AI agents

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/subscriptions shows 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

code
                  ┌───────────────────────────────────────────┐
                  │  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.

Register interest in one or more event topics.

ParamTypeRequiredWhat it is
topicstring[]yesTopic patterns (fnmatch globs). E.g. ["deal.*", "workflow.run.*"], ["account.health_at_risk"].
client_idstringyesStable identifier for your client process — e.g. "claude-desktop-mbp-dean" or "cursor-machine-12345". Subscriptions persist under this id across reconnects.
filterobjectnoFilter dict — keys map to payload fields. See Filters below.
client_labelstringnoHuman-readable label for the admin dashboard.
consent_purposestringnoScope 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:

json
{
  "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 shapeExampleWhat 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 keyMaps to
account_idpayload.account_id
deal_idpayload.deal_id
stage_topayload.new_stage
stage_frompayload.old_stage
health_band_topayload.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:

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

json
{
  "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.

json
{
  "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.

json
{
  "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.

json
{ "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:

json
{
  "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:

ts
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

ts
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:

yaml
- 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.lag notification 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/subscriptions dashboard.
  • 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 + the admin module).

See also