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
85 changes: 85 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Churnkey SDK

A production-ready cancel flow for React. Open source. Optionally connects to [Churnkey](https://churnkey.co) for analytics and AI-powered retention.

## Packages

| Package | Description |
| --- | --- |
| [`@churnkey/react`](./packages/react) | The cancel flow. Drop-in component, headless hook, or core state machine. React 18 and 19. |
| [`@churnkey/node`](./packages/node) | Server-side token generation. Only needed if Churnkey is handling billing actions for you, or if you're using the hosted embed. |
| [`@churnkey/mcp`](./packages/mcp) | Model Context Protocol server. Lets Claude, Cursor, and other AI agents query your Churnkey data (sessions, analytics, DSR) using a Data API key. |

## Quick start

```bash
npm install @churnkey/react
```

```tsx
import { CancelFlow } from '@churnkey/react'
import '@churnkey/react/styles.css'

<CancelFlow
steps={[
{
type: 'survey',
title: 'Why are you leaving?',
reasons: [
{
id: 'expensive',
label: 'Too expensive',
offer: { type: 'discount', couponId: 'STRIPE_SAVE20', percentOff: 20, durationInMonths: 3 },
},
{ id: 'not-using', label: 'Not using it enough', offer: { type: 'pause', months: 2 } },
{ id: 'missing', label: 'Missing features' },
],
},
{ type: 'feedback', title: 'Anything else?' },
{ type: 'confirm' },
]}
handleDiscount={async (offer) => myBilling.applyCoupon(offer.couponId)}
handlePause={async (offer) => myBilling.pause({ months: offer.months })}
handleCancel={async () => myBilling.cancel()}
onClose={() => setOpen(false)}
/>
```

## Documentation and resources

- [`@churnkey/react`](./packages/react/README.md) — full API reference, customization, headless usage, custom step types
- [`@churnkey/node`](./packages/node/README.md) — `createToken` and `authHash`
- [`@churnkey/mcp`](./packages/mcp/README.md) — MCP server for Claude / Cursor / Claude Desktop
- [churnkey.co](https://churnkey.co) — dashboard, hosted embed, AI retention features

## Repo layout

```
sdk/
├── packages/
│ ├── react/ @churnkey/react
│ ├── node/ @churnkey/node
│ └── mcp/ @churnkey/mcp
├── apps/
│ └── playground/ internal dev playground
└── scripts/ release + tooling helpers
```

## Local development

```bash
pnpm install
pnpm --filter @churnkey/react build
pnpm --filter @churnkey/react test
```

To work against the playground:

```bash
pnpm --filter @churnkey/react dev # rebuilds on change
pnpm --filter @churnkey/playground dev # runs the playground app
```

## License

MIT
19 changes: 19 additions & 0 deletions packages/mcp/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Changelog

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.0 — Unreleased

First public release.

### Added

- MCP server (`npx -y @churnkey/mcp`) authenticating with a Churnkey Data API key (`x-ck-app` + `x-ck-api-key`).
- Read-only tools backed by `/v1/data/*`:
- `list_sessions` — session-level detail with structured filters (enums for `saveType`/`offerType`/`billingInterval`, typed booleans/integers, ID lookups) and a `not` exclusion object for negation.
- `aggregate_sessions` — counts grouped by one or more breakdown dimensions (time series via `day`/`week`/`month`/`invoiceMonth`).
- `get_api_usage` — API call volume by date range.
- Compliance tools:
- `dsr_access` — GDPR/CCPA right-to-know.
- `dsr_delete` — GDPR/CCPA right-to-delete (destructive).
- Programmatic exports (`createServer`, `loadConfig`) for embedding the server in another Node process.
86 changes: 86 additions & 0 deletions packages/mcp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# @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.

## 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. |
| `dsr_access` | GDPR/CCPA data access by email. |
| `dsr_delete` | GDPR/CCPA data delete by email. *Destructive.* |

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

1. Get your **App ID** and **Data API Key** from `app.churnkey.co/settings/data-api`.
2. Add the server to your MCP client config.

### Claude Desktop / Claude Code

`~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `~/.claude/claude_desktop_config.json`:

```json
{
"mcpServers": {
"churnkey": {
"command": "npx",
"args": ["-y", "@churnkey/mcp"],
"env": {
"CHURNKEY_APP_ID": "your_app_id",
"CHURNKEY_API_KEY": "your_api_key"
}
}
}
}
```

### Cursor

`~/.cursor/mcp.json`:

```json
{
"mcpServers": {
"churnkey": {
"command": "npx",
"args": ["-y", "@churnkey/mcp"],
"env": {
"CHURNKEY_APP_ID": "your_app_id",
"CHURNKEY_API_KEY": "your_api_key"
}
}
}
}
```

Restart the client after editing config.

## Environment variables

| Var | Required | Default |
|-----|----------|---------|
| `CHURNKEY_APP_ID` | yes | — |
| `CHURNKEY_API_KEY` | yes | — |
| `CHURNKEY_API_URL` | no | `https://api.churnkey.co/v1` |

Use a `test_`-prefixed API key for staging data.

## Programmatic use

You can also embed the server in another Node process:

```ts
import { createServer, loadConfig } from '@churnkey/mcp'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'

const server = createServer(loadConfig())
await server.connect(new StdioServerTransport())
```

## License

MIT
43 changes: 43 additions & 0 deletions packages/mcp/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{
"name": "@churnkey/mcp",
"version": "0.1.0",
"description": "Model Context Protocol server for Churnkey. Read-only access to sessions, analytics, and DSR/unsubscribe endpoints.",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/churnkey/sdk.git",
"directory": "packages/mcp"
},
"keywords": ["churnkey", "mcp", "model-context-protocol", "claude", "cursor", "subscription", "retention"],
"type": "module",
"bin": {
"churnkey-mcp": "./dist/index.js"
},
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
}
},
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist"],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"typecheck": "tsc --noEmit",
"start": "node dist/index.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsup": "^8.0.0",
"typescript": "^5.5.0"
}
}
102 changes: 102 additions & 0 deletions packages/mcp/src/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
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
}

export class ChurnkeyClient {
constructor(private readonly config: ChurnkeyMcpConfig) {}

async get<T = unknown>(path: string, options: RequestOptions = {}): Promise<T> {
return this.request<T>('GET', path, options)
}

async post<T = unknown>(path: string, options: RequestOptions = {}): Promise<T> {
return this.request<T>('POST', path, options)
}

private async request<T>(method: 'GET' | 'POST', path: string, options: RequestOptions): Promise<T> {
const url = new URL(`${this.config.baseUrl}${path}`)
if (options.query) {
for (const [key, value] of Object.entries(options.query)) {
if (value === undefined || value === null) continue
if (Array.isArray(value)) {
for (const v of value) url.searchParams.append(key, String(v))
} else {
url.searchParams.set(key, String(value))
}
}
}

const headers: Record<string, string> = {
'x-ck-app': this.config.appId,
'x-ck-api-key': this.config.apiKey,
accept: 'application/json',
}
if (options.body !== undefined) {
headers['content-type'] = 'application/json'
}

const res = await fetch(url, {
method,
headers,
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
})

const text = await res.text()
let parsed: unknown = text
if (text) {
try {
parsed = JSON.parse(text)
} catch {
// leave as 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
}
return parsed as T
}
}

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

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.`
}
return apiMessage ?? `Churnkey API error ${status}`
}
15 changes: 15 additions & 0 deletions packages/mcp/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export interface ChurnkeyMcpConfig {
appId: string
apiKey: string
baseUrl: string
}

export function loadConfig(env: NodeJS.ProcessEnv = process.env): ChurnkeyMcpConfig {
const appId = env.CHURNKEY_APP_ID
const apiKey = env.CHURNKEY_API_KEY
if (!appId) throw new Error('CHURNKEY_APP_ID environment variable is required')
if (!apiKey) throw new Error('CHURNKEY_API_KEY environment variable is required')

const baseUrl = env.CHURNKEY_API_URL ?? 'https://api.churnkey.co/v1'
return { appId, apiKey, baseUrl: baseUrl.replace(/\/$/, '') }
}
24 changes: 24 additions & 0 deletions packages/mcp/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { fileURLToPath } from 'node:url'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { loadConfig } from './config'
import { createServer } from './server'

export type { ChurnkeyMcpConfig } from './config'
export { loadConfig } from './config'
export { createServer, SERVER_NAME, SERVER_VERSION } from './server'

async function main() {
const config = loadConfig()
const server = createServer(config)
const transport = new StdioServerTransport()
await server.connect(transport)
}

const isEntrypoint = process.argv[1] ? fileURLToPath(import.meta.url) === process.argv[1] : false

if (isEntrypoint) {
main().catch((err) => {
console.error(err instanceof Error ? err.message : err)
process.exit(1)
})
}
Loading
Loading