From 1e19e4a019ec3e892fb36f9d528579123d419481 Mon Sep 17 00:00:00 2001 From: Rob Moore Date: Thu, 7 May 2026 11:43:06 -0400 Subject: [PATCH] @churnkey/mcp 0.3.0: warehouse routes, payment recoveries, polish Tools - Add aggregate_payment_recoveries and list_payment_recoveries, 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, recovered/active. - Repoint list_sessions and aggregate_sessions to 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 on the API stay Mongo-backed and unchanged. Tool descriptions surface the ~3-hour warehouse lag. - Drop get_api_usage. API request logs aren't synced to the warehouse; the /v1/data/api-usage REST endpoint itself is unchanged. Polish from a senior-engineer review pass - Drop the speculative client-side rate limiter (sequential agent loops don't need it; the API has its own limits). - Simplify the HTTP client: drop the unused ChurnkeyApiError class, the dead {data: ...} response-unwrapping branch, and the redundant 403/ 404/422 message handlers (each just returned the catch-all default). - filters.ts: replace the 19-line manual notShape with z.object(filterShape). Drop currency/invoiceMonth from BREAKDOWN_VALUES (would silently no-op against the warehouse). Type buildQuery args properly. - session-tools description: drop misleading "case-insensitive" claim on customerEmail; rephrase the `active` describe to read directly. - Rename tool order, drop unused exports, fix package.json "start" script which pointed at dist/index.js (the library entry, doesn't run main()) instead of dist/bin.js. --- packages/mcp/CHANGELOG.md | 16 ++- packages/mcp/README.md | 7 +- packages/mcp/package.json | 6 +- packages/mcp/src/client.ts | 49 +++------ packages/mcp/src/rate-limit.ts | 23 ----- packages/mcp/src/server.ts | 10 +- packages/mcp/src/tools/dsr.ts | 7 +- packages/mcp/src/tools/filters.ts | 72 ++++--------- packages/mcp/src/tools/index.ts | 3 +- packages/mcp/src/tools/recoveries.ts | 147 +++++++++++++++++++++++++++ packages/mcp/src/tools/sessions.ts | 25 ++--- 11 files changed, 217 insertions(+), 148 deletions(-) delete mode 100644 packages/mcp/src/rate-limit.ts create mode 100644 packages/mcp/src/tools/recoveries.ts diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index 7ca046f..3270073 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -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 diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 5be40c4..d381c91 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -1,6 +1,6 @@ # @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 @@ -8,10 +8,13 @@ Model Context Protocol server for [Churnkey](https://churnkey.co). Lets AI agent |------|-------------| | `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 diff --git a/packages/mcp/package.json b/packages/mcp/package.json index ddbe95a..1934106 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -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", @@ -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", diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts index 774d81e..d9e3039 100644 --- a/packages/mcp/src/client.ts +++ b/packages/mcp/src/client.ts @@ -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 body?: unknown @@ -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)) { - 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((body as Record).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.` } diff --git a/packages/mcp/src/rate-limit.ts b/packages/mcp/src/rate-limit.ts deleted file mode 100644 index 489c658..0000000 --- a/packages/mcp/src/rate-limit.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Soft client-side limiter so a runaway agent loop doesn't hammer the upstream API. - * Hard limits live in churnkey-api; this is just a courtesy throttle. - */ -export class RateLimiter { - private timestamps: number[] = [] - - constructor( - private readonly maxRequests: number, - private readonly windowMs: number, - ) {} - - async acquire(): Promise { - const now = Date.now() - this.timestamps = this.timestamps.filter((t) => now - t < this.windowMs) - if (this.timestamps.length >= this.maxRequests) { - const wait = this.windowMs - (now - this.timestamps[0]!) - await new Promise((r) => setTimeout(r, wait)) - return this.acquire() - } - this.timestamps.push(now) - } -} diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index b6b0314..9ad8f5a 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -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( @@ -22,7 +20,6 @@ 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) @@ -30,8 +27,7 @@ export function createServer(config: ChurnkeyMcpConfig): McpServer { 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 }], diff --git a/packages/mcp/src/tools/dsr.ts b/packages/mcp/src/tools/dsr.ts index c9fc796..68bd4f7 100644 --- a/packages/mcp/src/tools/dsr.ts +++ b/packages/mcp/src/tools/dsr.ts @@ -3,10 +3,7 @@ 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({ @@ -14,7 +11,7 @@ const deleteInput = z.object({ .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.', ), }) diff --git a/packages/mcp/src/tools/filters.ts b/packages/mcp/src/tools/filters.ts index c4c3dc4..d3bd223 100644 --- a/packages/mcp/src/tools/filters.ts +++ b/packages/mcp/src/tools/filters.ts @@ -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', @@ -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', @@ -45,7 +37,6 @@ export const BREAKDOWN_VALUES = [ 'billingInterval', 'couponId', 'pauseDuration', - 'currency', 'sessionCurrency', 'bounced', 'ageMonths', @@ -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 @@ -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): Record { - const { not, breakdownBy, ...rest } = args as { - not?: Record - breakdownBy?: readonly string[] - [k: string]: unknown - } +interface BuildQueryArgs { + not?: Record + 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 { + const { not, breakdownBy, ...rest } = args const query: Record = {} for (const [k, v] of Object.entries(rest)) { diff --git a/packages/mcp/src/tools/index.ts b/packages/mcp/src/tools/index.ts index 5bb7429..1476e85 100644 --- a/packages/mcp/src/tools/index.ts +++ b/packages/mcp/src/tools/index.ts @@ -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' diff --git a/packages/mcp/src/tools/recoveries.ts b/packages/mcp/src/tools/recoveries.ts new file mode 100644 index 0000000..9d98f61 --- /dev/null +++ b/packages/mcp/src/tools/recoveries.ts @@ -0,0 +1,147 @@ +import { z } from 'zod' +import type { ChurnkeyClient } from '../client' +import { buildQuery } from './filters' +import type { ToolDefinition } from './types' + +// Breakdown dimensions accepted by /v1/data/warehouse/recovery-aggregation. Time +// dimensions (day/week/month) bucket on the failed-payment created date. +const BREAKDOWN_VALUES = [ + 'day', + 'week', + 'month', + 'recovered', + 'active', + 'cardBrand', + 'failReason', + 'outcome', + 'campaignBlueprintId', + 'currency', +] as const + +const filterShape = { + customerEmail: z + .string() + .optional() + .describe( + 'Customer email (exact match, case-insensitive). Use this to look up recovery attempts for one customer.', + ), + recovered: z + .boolean() + .optional() + .describe( + 'Whether the failed payment was successfully recovered. False matches campaigns still pending or already lost.', + ), + active: z + .boolean() + .optional() + .describe( + 'Whether the recovery campaign is still in flight. False means the campaign has settled (recovered or lost).', + ), + trial: z.boolean().optional().describe('Whether the payment failed during a trial.'), + inherited: z + .boolean() + .optional() + .describe('Whether the campaign was inherited from an earlier failed payment for the same customer.'), + campaignBlueprintId: z.string().optional().describe('Campaign blueprint ID that ran for this recovery.'), + cardBrand: z.string().optional().describe('Card brand on the failed payment (e.g. "visa", "mastercard", "amex").'), + failReason: z + .string() + .optional() + .describe( + 'Stripe-style decline reason on the failed payment (e.g. "insufficient_funds", "card_declined", "expired_card").', + ), + outcome: z.string().optional().describe('Final campaign outcome label. Free text; varies by configuration.'), + currency: z + .string() + .optional() + .describe('ISO 4217 currency of the failed payment (e.g. "usd", "eur"). Case-insensitive.'), +} + +const sharedFilters = { + startDate: z + .string() + .optional() + .describe('Inclusive lower bound on the failed payment created date. ISO 8601 date or datetime.'), + endDate: z + .string() + .optional() + .describe('Inclusive upper bound on the failed payment created date. ISO 8601 date or datetime.'), + ...filterShape, + not: z + .object(filterShape) + .optional() + .describe( + 'Exclusion filters. Each key matches "not equal" instead of equal. Example: { recovered: true } returns only pending or lost recoveries.', + ), +} + +const listInput = z.object({ + ...sharedFilters, + limit: z + .number() + .int() + .min(1) + .max(500) + .default(50) + .describe( + 'Max recoveries to return per call. Defaults to 50; capped at 500 to keep responses small for agent context. For totals, use aggregate_payment_recoveries.', + ), + skip: z.number().int().min(0).optional().describe('Pagination offset. Combine with limit to page through results.'), +}) + +const aggregateInput = z.object({ + ...sharedFilters, + breakdownBy: z + .array(z.enum(BREAKDOWN_VALUES)) + .optional() + .describe( + 'Group counts and amounts by these dimensions. Multiple dimensions produce a cross-tab; e.g. ["month","recovered"] returns one row per (month, recovered) pair. Omit for a single grand total. Time dimensions: day, week, month.', + ), +}) + +const WAREHOUSE_NOTE = + 'Data source: the Churnkey analytics warehouse, refreshed roughly every 20 minutes. Recoveries from the last few minutes may not appear yet.' + +const AMOUNT_NOTE = + 'Amount fields come back in two forms: original currency (`invoiceAmount`, `recoveredAmount`, `pendingAmount`, `lostAmount`) and USD (suffix `Usd`). Original-currency totals only make sense when filtered or grouped by a single currency; USD totals are safe to sum across currencies.' + +export function recoveryTools(client: ChurnkeyClient): ToolDefinition[] { + return [ + { + name: 'list_payment_recoveries', + title: 'List failed-payment recoveries', + description: [ + 'List individual failed-payment recovery campaigns. Each row covers one failed payment plus the campaign Churnkey ran (or is running) to recover it.', + '', + 'Use this for inspection (e.g. "show me the 10 most recent failed payments where we lost money") or per-customer lookup. For volume or rate questions, prefer aggregate_payment_recoveries.', + '', + WAREHOUSE_NOTE, + ].join('\n'), + inputSchema: listInput, + annotations: { readOnlyHint: true, openWorldHint: true }, + handler: async (args) => client.get('/data/warehouse/recoveries', { query: buildQuery(args) }), + }, + { + name: 'aggregate_payment_recoveries', + title: 'Aggregate failed-payment recoveries', + description: [ + 'Aggregate counts and dollar amounts for failed-payment recovery campaigns (dunning). Each underlying row is one failed payment and the campaign attached to it.', + '', + 'Returned metrics per group: `count`, `invoiceAmount(Usd)`, `recoveredCount`, `recoveredAmount(Usd)`, `pendingCount`, `pendingAmount(Usd)`, `lostCount`, `lostAmount(Usd)`. Recovered = succeeded; pending = still in flight; lost = campaign ended without recovery.', + '', + 'Examples:', + '- breakdownBy: [] → grand totals (recovery rate = recoveredCount / count)', + '- breakdownBy: ["month"] → monthly time series', + '- breakdownBy: ["failReason"] → which decline reasons we recover from best', + '- breakdownBy: ["cardBrand","recovered"] → recovery rate by card brand', + '', + AMOUNT_NOTE, + '', + WAREHOUSE_NOTE, + ].join('\n'), + inputSchema: aggregateInput, + annotations: { readOnlyHint: true, openWorldHint: true }, + handler: async (args) => client.get('/data/warehouse/recovery-aggregation', { query: buildQuery(args) }), + }, + ] +} diff --git a/packages/mcp/src/tools/sessions.ts b/packages/mcp/src/tools/sessions.ts index 875b1d1..cb82bac 100644 --- a/packages/mcp/src/tools/sessions.ts +++ b/packages/mcp/src/tools/sessions.ts @@ -23,14 +23,12 @@ const aggregateSessionsInput = z.object({ .array(z.enum(BREAKDOWN_VALUES)) .optional() .describe( - 'Group counts by these dimensions. Multiple dimensions produce a cross-tab; e.g. ["month","saveType"] returns one row per (month, saveType) pair. Omit for a single grand total. Time dimensions: day, week, month, invoiceMonth.', + 'Group counts by these dimensions. Multiple dimensions produce a cross-tab; e.g. ["month","saveType"] returns one row per (month, saveType) pair. Omit for a single grand total. Time dimensions: day, week, month.', ), }) -const apiUsageInput = z.object({ - startDate: z.string().optional().describe('Inclusive lower bound. ISO 8601 date or datetime.'), - endDate: z.string().optional().describe('Inclusive upper bound. ISO 8601 date or datetime.'), -}) +const WAREHOUSE_NOTE = + 'Data source: the Churnkey analytics warehouse, refreshed roughly every 3 hours. Sessions from the last few hours may not appear yet — for "did this just happen" questions, expect lag.' export function sessionTools(client: ChurnkeyClient): ToolDefinition[] { return [ @@ -44,11 +42,11 @@ export function sessionTools(client: ChurnkeyClient): ToolDefinition[] { '', 'Use this when you need session-level detail (e.g. "show me the 10 most recent sessions where a discount was offered"). For counts and breakdowns, prefer aggregate_sessions — it ships less data.', '', - 'Mode (live vs test) is determined by the API key prefix; pass a `test_`-prefixed key in your MCP server env to query test data.', + WAREHOUSE_NOTE, ].join('\n'), inputSchema: listSessionsInput, annotations: { readOnlyHint: true, openWorldHint: true }, - handler: async (args) => client.get('/data/sessions', { query: buildQuery(args) }), + handler: async (args) => client.get('/data/warehouse/sessions', { query: buildQuery(args) }), }, { name: 'aggregate_sessions', @@ -63,19 +61,12 @@ export function sessionTools(client: ChurnkeyClient): ToolDefinition[] { '- breakdownBy: ["planId"], filter not: { canceled: true } → saved-session counts per plan', '', 'Filters work the same as list_sessions.', + '', + WAREHOUSE_NOTE, ].join('\n'), inputSchema: aggregateSessionsInput, annotations: { readOnlyHint: true, openWorldHint: true }, - handler: async (args) => client.get('/data/session-aggregation', { query: buildQuery(args) }), - }, - { - name: 'get_api_usage', - title: 'Get Churnkey API usage', - description: - 'Return Churnkey API call volume for the org over a date range. Useful for confirming the embed/SDK is firing in production, or diagnosing a sudden drop in tracked sessions.', - inputSchema: apiUsageInput, - annotations: { readOnlyHint: true, openWorldHint: true }, - handler: async (args) => client.get('/data/api-usage', { query: args }), + handler: async (args) => client.get('/data/warehouse/session-aggregation', { query: buildQuery(args) }), }, ] }