Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion packages/mcp/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,21 @@

Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Expect breaking changes in minor versions while we're pre-1.0.

## 0.1.1 — Unreleased
## 0.3.0 — Unreleased

### Added

- `aggregate_payment_recoveries` and `list_payment_recoveries` tools, backed by `/v1/data/warehouse/recovery-aggregation` and `/v1/data/warehouse/recoveries`. Aggregation returns count, invoice/recovered/pending/lost amounts in original currency and USD, with breakdowns by time, card brand, decline reason, outcome, blueprint, currency, and recovered/active state.

### Changed

- `list_sessions` and `aggregate_sessions` now point at the new warehouse-backed routes (`/v1/data/warehouse/sessions` and `/v1/data/warehouse/session-aggregation`). The legacy `/v1/data/sessions` and `/v1/data/session-aggregation` routes are unchanged on the API side and continue to serve real-time Mongo data; the MCP just chooses the warehouse path because lag is acceptable for agent use cases and warehouse queries scale better. Tool descriptions surface the ~3-hour lag.

### Removed (BREAKING)

- `get_api_usage` tool. All remaining tools read from the warehouse; API request logs are not synced there. The `/v1/data/api-usage` REST endpoint is unchanged.

## 0.1.1 — 2026-05-06

### Fixed

Expand Down
7 changes: 5 additions & 2 deletions packages/mcp/README.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
# @churnkey/mcp

Model Context Protocol server for [Churnkey](https://churnkey.co). Lets AI agents (Claude Code, Cursor, Claude Desktop, etc.) read your sessions, run analytics queries, and handle GDPR/unsubscribe requests.
Model Context Protocol server for [Churnkey](https://churnkey.co). Lets AI agents (Claude Code, Cursor, Claude Desktop, etc.) read your sessions, run analytics queries, and handle GDPR requests.

## Tools

| Tool | Description |
|------|-------------|
| `list_sessions` | Cancel/dunning sessions, with filters for date range, customer, outcome (saveType/canceled/aborted), plan, segment, A/B test, etc. Negation via `not: { ... }`. Default 50 / max 500 per call. |
| `aggregate_sessions` | Session counts, optionally grouped by `breakdownBy` dimensions (saveType, offerType, planId, day/week/month, …). Same filter set as `list_sessions`. |
| `get_api_usage` | API call volume — useful for "is the embed firing?" debugging. |
| `aggregate_payment_recoveries` | Failed-payment recovery (dunning) counts and dollar amounts — invoice / recovered / pending / lost, in original currency and USD. Group by time, card brand, decline reason, outcome, blueprint, currency, recovered/active state. |
| `list_payment_recoveries` | Individual failed-payment recovery campaigns. Same filter set as the aggregation. |
| `dsr_access` | GDPR/CCPA data access by email. |
| `dsr_delete` | GDPR/CCPA data delete by email. *Destructive.* |

Session and recovery tools read from the Churnkey analytics warehouse — sessions refresh every ~3 hours, recoveries every ~20 minutes. DSR tools read/write the operational store directly (no lag).

Each tool's input schema is fully described to the MCP client — enums for `saveType` / `offerType` / `billingInterval` / breakdown dimensions, `not` object for exclusions, structured types for booleans and numbers. Mode (live vs test) is set by the API key prefix; pass a `test_`-prefixed key to query test data.

## Setup
Expand Down
6 changes: 3 additions & 3 deletions packages/mcp/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@churnkey/mcp",
"version": "0.1.1",
"description": "Model Context Protocol server for Churnkey. Read-only access to sessions, analytics, and DSR/unsubscribe endpoints.",
"version": "0.3.0",
"description": "Model Context Protocol server for Churnkey. Lets agents query sessions, payment-recovery analytics, and DSR endpoints with a Data API key.",
"license": "MIT",
"repository": {
"type": "git",
Expand Down Expand Up @@ -29,7 +29,7 @@
"build": "tsup",
"dev": "tsup --watch",
"typecheck": "tsc --noEmit",
"start": "node dist/index.js"
"start": "node dist/bin.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4",
Expand Down
49 changes: 12 additions & 37 deletions packages/mcp/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,5 @@
import type { ChurnkeyMcpConfig } from './config'

export class ChurnkeyApiError extends Error {
readonly status: number
readonly body: unknown

constructor(status: number, message: string, body: unknown) {
super(message)
this.name = 'ChurnkeyApiError'
this.status = status
this.body = body
}
}

export interface RequestOptions {
query?: Record<string, unknown>
body?: unknown
Expand Down Expand Up @@ -57,44 +45,31 @@ export class ChurnkeyClient {
})

const text = await res.text()
let parsed: unknown = text
if (text) {
try {
parsed = JSON.parse(text)
} catch {
// leave as text
}
}
const parsed = parseJson(text)

if (!res.ok) {
throw new ChurnkeyApiError(res.status, mapErrorMessage(res.status, parsed), parsed)
}

if (parsed && typeof parsed === 'object' && 'data' in (parsed as Record<string, unknown>)) {
return (parsed as { data: T }).data
throw new Error(mapErrorMessage(res.status, parsed))
}
return parsed as T
}
}

function parseJson(text: string): unknown {
if (!text) return text
try {
return JSON.parse(text)
} catch {
return text
}
}

function mapErrorMessage(status: number, body: unknown): string {
const apiMessage =
body && typeof body === 'object' && 'message' in (body as Record<string, unknown>)
? String((body as Record<string, unknown>).message)
: null
body && typeof body === 'object' && 'message' in body ? String((body as { message: unknown }).message) : null

if (status === 401) {
return 'Churnkey API rejected the credentials. Check CHURNKEY_APP_ID and CHURNKEY_API_KEY in your MCP server config.'
}
if (status === 403) {
return apiMessage ?? 'Churnkey API forbids this action for the supplied API key.'
}
if (status === 404) {
return apiMessage ?? 'Resource not found.'
}
if (status === 422) {
return apiMessage ?? 'Invalid request parameters.'
}
if (status >= 500) {
return apiMessage ?? `Churnkey API returned ${status}. Try again or check status.churnkey.co.`
}
Expand Down
23 changes: 0 additions & 23 deletions packages/mcp/src/rate-limit.ts

This file was deleted.

10 changes: 3 additions & 7 deletions packages/mcp/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { ChurnkeyApiError, ChurnkeyClient } from './client'
import { ChurnkeyClient } from './client'
import type { ChurnkeyMcpConfig } from './config'
import { RateLimiter } from './rate-limit'
import { allTools } from './tools'

export const SERVER_NAME = 'churnkey-mcp'
export const SERVER_VERSION = '0.1.1'
export const SERVER_VERSION = '0.3.0'

export function createServer(config: ChurnkeyMcpConfig): McpServer {
const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION })
const client = new ChurnkeyClient(config)
const limiter = new RateLimiter(10, 1000)

for (const tool of allTools(client)) {
server.registerTool(
Expand All @@ -22,16 +20,14 @@ export function createServer(config: ChurnkeyMcpConfig): McpServer {
annotations: tool.annotations,
},
async (args: unknown) => {
await limiter.acquire()
try {
const parsed = tool.inputSchema.parse(args ?? {})
const result = await tool.handler(parsed)
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
}
} catch (err) {
const message =
err instanceof ChurnkeyApiError ? err.message : err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err)
return {
isError: true,
content: [{ type: 'text', text: message }],
Expand Down
7 changes: 2 additions & 5 deletions packages/mcp/src/tools/dsr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,15 @@ import type { ChurnkeyClient } from '../client'
import type { ToolDefinition } from './types'

const accessInput = z.object({
email: z
.string()
.email()
.describe('Customer email (exact match) to fetch all stored Churnkey data for. Case-insensitive.'),
email: z.string().email().describe('Customer email to fetch all stored Churnkey data for.'),
})

const deleteInput = z.object({
email: z
.string()
.email()
.describe(
'Customer email (exact match) to permanently delete from Churnkey. All sessions, feedback, and PII associated with this email are removed.',
'Customer email to permanently delete from Churnkey. All sessions, feedback, and PII associated with this email are removed.',
),
})

Expand Down
72 changes: 20 additions & 52 deletions packages/mcp/src/tools/filters.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { z } from 'zod'

/**
* Mirrors src/helpers/shared.js OFFER_TYPE_LIST in churnkey-api.
* Keep in sync if new offer types are added there.
*/
// Mirrors OFFER_TYPE_LIST in churnkey-api (src/helpers/shared.js).
// Keep the two in sync if new offer types are added.
export const OFFER_TYPE_VALUES = [
'PAUSE',
'DISCOUNT',
Expand All @@ -14,25 +12,19 @@ export const OFFER_TYPE_VALUES = [
'CUSTOM',
] as const

/**
* `saveType` is derived: null when canceled=true, otherwise the offerType the
* customer accepted, or 'ABANDON' if the session ended without a decision.
*/
// `saveType` is derived: null when canceled, otherwise the accepted offerType,
// otherwise 'ABANDON' (customer left without deciding).
export const SAVE_TYPE_VALUES = [...OFFER_TYPE_VALUES, 'ABANDON'] as const

/** Common Stripe billing intervals. Other values pass through as strings. */
export const BILLING_INTERVAL_VALUES = ['day', 'week', 'month', 'year'] as const

/**
* Breakdown dimensions accepted by /v1/data/session-aggregation.
* Time dimensions (day/week/month/invoiceMonth) produce time series;
* combine with attribute dimensions to break a series down further.
*/
// Breakdown dimensions accepted by /v1/data/warehouse/session-aggregation. Time
// dimensions (day/week/month) produce time series; combine with attribute
// dimensions to break a series down further.
export const BREAKDOWN_VALUES = [
'day',
'week',
'month',
'invoiceMonth',
'saveType',
'offerType',
'response',
Expand All @@ -45,7 +37,6 @@ export const BREAKDOWN_VALUES = [
'billingInterval',
'couponId',
'pauseDuration',
'currency',
'sessionCurrency',
'bounced',
'ageMonths',
Expand All @@ -64,7 +55,6 @@ const dateRange = {
endDate: z.string().optional().describe('Inclusive upper bound on session createdAt. ISO 8601 date or datetime.'),
}

/** Filters supported by both list_sessions and aggregate_sessions. */
const filterShape = {
sessionId: z.string().optional().describe('Single Churnkey session ID. Returns the matching session only.'),
customerEmail: z
Expand Down Expand Up @@ -108,50 +98,28 @@ const filterShape = {
ageYears: z.number().int().optional().describe('Customer account age in years at session start.'),
}

/** Negation versions of the same filters — pass "not: { saveType: 'DISCOUNT' }" etc. */
const notShape = {
sessionId: filterShape.sessionId,
customerEmail: filterShape.customerEmail,
customerId: filterShape.customerId,
segmentId: filterShape.segmentId,
abtest: filterShape.abtest,
saveType: filterShape.saveType,
offerType: filterShape.offerType,
response: filterShape.response,
aborted: filterShape.aborted,
canceled: filterShape.canceled,
trial: filterShape.trial,
bounced: filterShape.bounced,
planId: filterShape.planId,
billingInterval: filterShape.billingInterval,
couponId: filterShape.couponId,
pauseDuration: filterShape.pauseDuration,
sessionCurrency: filterShape.sessionCurrency,
ageYears: filterShape.ageYears,
}

export const sharedFilterFields = {
...dateRange,
...filterShape,
not: z
.object(notShape)
.partial()
.object(filterShape)
.optional()
.describe(
'Exclusion filters. Each key is the same as the top-level filter but matches "not equal" instead. Example: { saveType: "ABANDON" } returns only saved sessions.',
'Exclusion filters. Each key matches "not equal" instead of equal. Example: { saveType: "ABANDON" } returns only saved sessions.',
),
}

/**
* Convert structured input ({ saveType, not: { canceled }, breakdownBy }) into the
* flat query-string shape the API expects ({ saveType, '-canceled', breakdown }).
*/
export function buildQuery(args: Record<string, unknown>): Record<string, unknown> {
const { not, breakdownBy, ...rest } = args as {
not?: Record<string, unknown>
breakdownBy?: readonly string[]
[k: string]: unknown
}
interface BuildQueryArgs {
not?: Record<string, unknown>
breakdownBy?: readonly string[]
[k: string]: unknown
}

// Convert the structured tool input into the flat query-string shape the
// underlying /v1/data/warehouse/* endpoints expect: nested `not` keys become `-key`,
// and `breakdownBy` arrays are joined with `-`.
export function buildQuery(args: BuildQueryArgs): Record<string, unknown> {
const { not, breakdownBy, ...rest } = args

const query: Record<string, unknown> = {}
for (const [k, v] of Object.entries(rest)) {
Expand Down
3 changes: 2 additions & 1 deletion packages/mcp/src/tools/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { ChurnkeyClient } from '../client'
import { dsrTools } from './dsr'
import { recoveryTools } from './recoveries'
import { sessionTools } from './sessions'
import type { ToolDefinition } from './types'

export function allTools(client: ChurnkeyClient): ToolDefinition[] {
return [...sessionTools(client), ...dsrTools(client)]
return [...sessionTools(client), ...recoveryTools(client), ...dsrTools(client)]
}

export type { ToolDefinition } from './types'
Loading
Loading