apphud-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,934 @@
1
+ import { ApphudMcpError, isApphudMcpError } from "../errors/toolError.js";
2
+ import { extractBreakdown, extractMetricValue, extractTimeseries, } from "./apphudClient.js";
3
+ const ANALYTICS_ONLY_TOOLS = "Analytics tools use Apphud dashboard analytics endpoints directly";
4
+ function normalizeEventTypeFilter(value) {
5
+ if (!value) {
6
+ return undefined;
7
+ }
8
+ const normalized = value
9
+ .trim()
10
+ .toLowerCase()
11
+ .replace(/[^a-z0-9]+/g, "_")
12
+ .replace(/^_+|_+$/g, "");
13
+ return normalized.length > 0 ? normalized : undefined;
14
+ }
15
+ function safeIso(value) {
16
+ const parsed = new Date(value);
17
+ if (Number.isNaN(parsed.getTime())) {
18
+ return value;
19
+ }
20
+ return parsed.toISOString();
21
+ }
22
+ function toPointSummary(points) {
23
+ if (points.length === 0) {
24
+ return { total: 0, average: null };
25
+ }
26
+ const total = points.reduce((sum, point) => sum + point.value, 0);
27
+ return {
28
+ total,
29
+ average: Number((total / points.length).toFixed(4)),
30
+ };
31
+ }
32
+ function mapShares(rows) {
33
+ const total = rows.reduce((sum, row) => sum + row.value, 0);
34
+ return rows.map((row) => ({
35
+ key: row.key,
36
+ value: row.value,
37
+ share: total <= 0 ? 0 : Number((row.value / total).toFixed(6)),
38
+ }));
39
+ }
40
+ function asRetentionRate(value) {
41
+ if (value <= 1) {
42
+ return Number(value.toFixed(6));
43
+ }
44
+ return Number((value / 100).toFixed(6));
45
+ }
46
+ function periodIndexFromLabel(label) {
47
+ const match = label.match(/(\d+)/);
48
+ if (!match) {
49
+ return 0;
50
+ }
51
+ const parsed = Number(match[1]);
52
+ return Number.isFinite(parsed) ? parsed : 0;
53
+ }
54
+ function shiftPeriod(from, to) {
55
+ const fromDate = new Date(from);
56
+ const toDate = new Date(to);
57
+ if (Number.isNaN(fromDate.getTime()) || Number.isNaN(toDate.getTime())) {
58
+ return { from, to };
59
+ }
60
+ const diff = toDate.getTime() - fromDate.getTime();
61
+ const prevTo = new Date(fromDate.getTime() - 1);
62
+ const prevFrom = new Date(prevTo.getTime() - diff);
63
+ return {
64
+ from: prevFrom.toISOString(),
65
+ to: prevTo.toISOString(),
66
+ };
67
+ }
68
+ export class AnalyticsService {
69
+ appService;
70
+ apphudClient;
71
+ constructor(appService, apphudClient) {
72
+ this.appService = appService;
73
+ this.apphudClient = apphudClient;
74
+ }
75
+ async appsList(auth, input = {}) {
76
+ this.assertRawAccess(auth, input.include_raw);
77
+ const apps = await this.apphudClient.listDashboardApps();
78
+ return {
79
+ source: "apphud_analytics_api",
80
+ count: apps.apps.length,
81
+ apps: apps.apps,
82
+ source_used: apps.sourcePath,
83
+ retrieved_at: new Date().toISOString(),
84
+ raw_payload: this.selectRawPayload(auth, input.include_raw, apps.rawPayload),
85
+ };
86
+ }
87
+ async eventsList(auth, input) {
88
+ const app = await this.resolveApp(auth, input.app_id, input.apphud_app_id);
89
+ this.assertRawAccess(auth, input.include_raw);
90
+ const limit = Math.min(Math.max(input.limit ?? 100, 1), 500);
91
+ const filters = input.filters ?? {};
92
+ const normalizedEventType = normalizeEventTypeFilter(filters.event_type);
93
+ const response = await this.apphudClient.fetchDashboardEvents(app, {
94
+ apphudAppId: input.apphud_app_id,
95
+ from: input.from,
96
+ to: input.to,
97
+ limit,
98
+ cursor: input.cursor,
99
+ eventType: filters.event_type,
100
+ userId: filters.user_id,
101
+ productId: filters.product_id,
102
+ country: filters.country,
103
+ platform: filters.platform,
104
+ });
105
+ const filteredEvents = response.events.filter((event) => {
106
+ if (normalizedEventType && normalizeEventTypeFilter(event.event_type) !== normalizedEventType) {
107
+ return false;
108
+ }
109
+ if (filters.user_id && event.user_id !== filters.user_id) {
110
+ return false;
111
+ }
112
+ if (filters.product_id && event.product_id !== filters.product_id) {
113
+ return false;
114
+ }
115
+ if (filters.country && event.country?.toLowerCase() !== filters.country.toLowerCase()) {
116
+ return false;
117
+ }
118
+ if (filters.platform && event.platform !== filters.platform) {
119
+ return false;
120
+ }
121
+ return true;
122
+ });
123
+ const events = filteredEvents.slice(0, limit).map((event) => ({
124
+ event_id: event.event_id,
125
+ event_type: event.event_type,
126
+ occurred_at: event.occurred_at,
127
+ user_id: event.user_id,
128
+ product_id: event.product_id,
129
+ original_transaction_id: event.original_transaction_id,
130
+ subscription_status: event.subscription_status,
131
+ country: event.country,
132
+ platform: event.platform,
133
+ price: event.price,
134
+ currency: event.currency,
135
+ raw: input.include_raw && auth.role === "admin" ? event.raw : undefined,
136
+ }));
137
+ const hasMore = response.hasMore ?? (response.nextCursor ? true : undefined);
138
+ const warnings = [];
139
+ if (events.length === 0) {
140
+ warnings.push("No events extracted from Apphud dashboard response. Try apphud.analytics.query.raw with include_raw=true to inspect endpoint payload.");
141
+ }
142
+ return {
143
+ app_id: app.appId,
144
+ apphud_app_id: response.apphudAppId,
145
+ from: safeIso(input.from),
146
+ to: safeIso(input.to),
147
+ limit,
148
+ cursor: input.cursor,
149
+ filters: {
150
+ event_type: filters.event_type,
151
+ user_id: filters.user_id,
152
+ product_id: filters.product_id,
153
+ country: filters.country,
154
+ platform: filters.platform,
155
+ },
156
+ events,
157
+ events_count: events.length,
158
+ next_cursor: response.nextCursor,
159
+ has_more: hasMore,
160
+ source: "apphud_analytics_api",
161
+ source_used: response.sourcePath,
162
+ extracted_from: response.extractionPath,
163
+ warnings,
164
+ retrieved_at: new Date().toISOString(),
165
+ raw_payload: this.selectRawPayload(auth, input.include_raw, response.rawPayload),
166
+ };
167
+ }
168
+ async activeSubscriptions(auth, input) {
169
+ const app = await this.resolveApp(auth, input.app_id, input.apphud_app_id);
170
+ this.assertRawAccess(auth, input.include_raw);
171
+ const analytics = await this.apphudClient.fetchActiveSubscriptions(app, {
172
+ apphudAppId: input.apphud_app_id,
173
+ platform: input.platform,
174
+ });
175
+ return {
176
+ app_id: app.appId,
177
+ apphud_app_id: analytics.apphudAppId,
178
+ metric_key: "active_subs",
179
+ platform: input.platform,
180
+ value: analytics.value,
181
+ source: "apphud_analytics_api",
182
+ extracted_from: analytics.extractedFrom,
183
+ retrieved_at: new Date().toISOString(),
184
+ raw_payload: this.selectRawPayload(auth, input.include_raw, analytics.rawPayload),
185
+ };
186
+ }
187
+ async capabilities(auth, input) {
188
+ const app = await this.resolveApp(auth, input.app_id, input.apphud_app_id);
189
+ this.assertRawAccess(auth, input.include_raw);
190
+ const metrics = await this.apphudClient.listAnalyticsMetrics(app, {
191
+ apphudAppId: input.apphud_app_id,
192
+ });
193
+ return {
194
+ app_id: app.appId,
195
+ apphud_app_id: metrics.apphudAppId,
196
+ analytics_available: true,
197
+ source: "apphud_analytics_api",
198
+ supported_tools: [
199
+ "apphud.analytics.events.list",
200
+ "apphud.analytics.metrics.list",
201
+ "apphud.analytics.metric.value",
202
+ "apphud.analytics.metric.timeseries",
203
+ "apphud.analytics.metric.breakdown",
204
+ "apphud.analytics.revenue.summary",
205
+ "apphud.analytics.subscriptions.summary",
206
+ "apphud.analytics.conversion.trial_to_paid",
207
+ "apphud.analytics.cohorts.retention",
208
+ "apphud.analytics.cohorts.ltv",
209
+ "apphud.analytics.query.raw",
210
+ ],
211
+ supported_shapes: ["value", "timeseries", "breakdown", "raw"],
212
+ metrics_count: metrics.metrics.length,
213
+ metrics_sample: metrics.metrics.slice(0, 20),
214
+ source_used: metrics.sourcePath,
215
+ warning: ANALYTICS_ONLY_TOOLS,
216
+ retrieved_at: new Date().toISOString(),
217
+ raw_payload: this.selectRawPayload(auth, input.include_raw, metrics.rawPayload),
218
+ };
219
+ }
220
+ async metricsList(auth, input) {
221
+ const app = await this.resolveApp(auth, input.app_id, input.apphud_app_id);
222
+ this.assertRawAccess(auth, input.include_raw);
223
+ const metrics = await this.apphudClient.listAnalyticsMetrics(app, {
224
+ apphudAppId: input.apphud_app_id,
225
+ });
226
+ return {
227
+ app_id: app.appId,
228
+ apphud_app_id: metrics.apphudAppId,
229
+ metrics: metrics.metrics,
230
+ count: metrics.metrics.length,
231
+ source_used: metrics.sourcePath,
232
+ retrieved_at: new Date().toISOString(),
233
+ raw_payload: this.selectRawPayload(auth, input.include_raw, metrics.rawPayload),
234
+ };
235
+ }
236
+ async metricValue(auth, input) {
237
+ const app = await this.resolveApp(auth, input.app_id, input.apphud_app_id);
238
+ this.assertRawAccess(auth, input.include_raw);
239
+ const resolved = await this.resolveMetricValue(app, {
240
+ apphudAppId: input.apphud_app_id,
241
+ metricKey: input.metric_key,
242
+ from: input.from,
243
+ to: input.to,
244
+ platform: input.platform,
245
+ filters: input.filters,
246
+ includeRaw: input.include_raw,
247
+ auth,
248
+ });
249
+ return {
250
+ app_id: app.appId,
251
+ apphud_app_id: input.apphud_app_id ?? app.appId,
252
+ metric_key: input.metric_key,
253
+ from: input.from,
254
+ to: input.to,
255
+ platform: input.platform,
256
+ value: resolved.value,
257
+ source: "apphud_analytics_api",
258
+ source_used: resolved.source_used,
259
+ extracted_from: resolved.extracted_from,
260
+ warnings: resolved.warnings,
261
+ retrieved_at: new Date().toISOString(),
262
+ raw_payload: this.selectRawPayload(auth, input.include_raw, resolved.raw_payload),
263
+ };
264
+ }
265
+ async metricTimeseries(auth, input) {
266
+ const app = await this.resolveApp(auth, input.app_id, input.apphud_app_id);
267
+ this.assertRawAccess(auth, input.include_raw);
268
+ const warnings = [];
269
+ let sourceUsed = "/dash/range";
270
+ let extractedFrom;
271
+ let rawPayload;
272
+ const dashRange = await this.apphudClient.fetchDashRange(app, {
273
+ apphudAppId: input.apphud_app_id,
274
+ from: input.from,
275
+ to: input.to,
276
+ granularity: input.granularity,
277
+ metricKey: input.metric_key,
278
+ platform: input.platform,
279
+ filters: input.filters,
280
+ });
281
+ rawPayload = dashRange.payload;
282
+ const extractedDash = extractTimeseries(dashRange.payload, input.metric_key);
283
+ let points = extractedDash.points;
284
+ extractedFrom = extractedDash.path;
285
+ if (points.length === 0) {
286
+ warnings.push("No timeseries in dash/range payload, fallback to chart/query/line");
287
+ const chart = await this.apphudClient.fetchChartQuery(app, {
288
+ shape: "line",
289
+ metricKey: input.metric_key,
290
+ apphudAppId: input.apphud_app_id,
291
+ from: input.from,
292
+ to: input.to,
293
+ granularity: input.granularity,
294
+ platform: input.platform,
295
+ filters: input.filters,
296
+ });
297
+ sourceUsed = chart.sourcePath;
298
+ rawPayload = chart.payload;
299
+ const extractedLine = extractTimeseries(chart.payload, input.metric_key);
300
+ points = extractedLine.points;
301
+ extractedFrom = extractedLine.path;
302
+ }
303
+ const summary = toPointSummary(points);
304
+ return {
305
+ app_id: app.appId,
306
+ apphud_app_id: input.apphud_app_id ?? app.appId,
307
+ metric_key: input.metric_key,
308
+ from: safeIso(input.from),
309
+ to: safeIso(input.to),
310
+ granularity: input.granularity ?? "day",
311
+ platform: input.platform,
312
+ points,
313
+ points_count: points.length,
314
+ total: Number(summary.total.toFixed(4)),
315
+ average: summary.average,
316
+ source: "apphud_analytics_api",
317
+ source_used: sourceUsed,
318
+ extracted_from: extractedFrom,
319
+ warnings,
320
+ retrieved_at: new Date().toISOString(),
321
+ raw_payload: this.selectRawPayload(auth, input.include_raw, rawPayload),
322
+ };
323
+ }
324
+ async metricBreakdown(auth, input) {
325
+ const app = await this.resolveApp(auth, input.app_id, input.apphud_app_id);
326
+ this.assertRawAccess(auth, input.include_raw);
327
+ const warnings = [];
328
+ let sourceUsed = "/chart/query/column";
329
+ let extractedFrom;
330
+ let rawPayload;
331
+ const column = await this.apphudClient.fetchChartQuery(app, {
332
+ shape: "column",
333
+ metricKey: input.metric_key,
334
+ apphudAppId: input.apphud_app_id,
335
+ from: input.from,
336
+ to: input.to,
337
+ granularity: input.granularity,
338
+ dimension: input.dimension,
339
+ platform: input.platform,
340
+ filters: input.filters,
341
+ });
342
+ rawPayload = column.payload;
343
+ const extractedColumn = extractBreakdown(column.payload, input.metric_key);
344
+ let rows = extractedColumn.rows;
345
+ extractedFrom = extractedColumn.path;
346
+ if (rows.length === 0) {
347
+ warnings.push("No breakdown in chart/query/column payload, fallback to dash/range");
348
+ const dashRange = await this.apphudClient.fetchDashRange(app, {
349
+ apphudAppId: input.apphud_app_id,
350
+ from: input.from,
351
+ to: input.to,
352
+ granularity: input.granularity,
353
+ metricKey: input.metric_key,
354
+ dimension: input.dimension,
355
+ platform: input.platform,
356
+ filters: input.filters,
357
+ });
358
+ sourceUsed = "/dash/range";
359
+ rawPayload = dashRange.payload;
360
+ const extractedRange = extractBreakdown(dashRange.payload, input.metric_key);
361
+ rows = extractedRange.rows;
362
+ extractedFrom = extractedRange.path;
363
+ }
364
+ const limit = input.limit ?? 20;
365
+ const limited = rows.slice(0, limit);
366
+ return {
367
+ app_id: app.appId,
368
+ apphud_app_id: input.apphud_app_id ?? app.appId,
369
+ metric_key: input.metric_key,
370
+ dimension: input.dimension,
371
+ from: safeIso(input.from),
372
+ to: safeIso(input.to),
373
+ granularity: input.granularity ?? "day",
374
+ platform: input.platform,
375
+ rows: mapShares(limited),
376
+ rows_count: limited.length,
377
+ source: "apphud_analytics_api",
378
+ source_used: sourceUsed,
379
+ extracted_from: extractedFrom,
380
+ warnings,
381
+ retrieved_at: new Date().toISOString(),
382
+ raw_payload: this.selectRawPayload(auth, input.include_raw, rawPayload),
383
+ };
384
+ }
385
+ async revenueSummary(auth, input) {
386
+ const app = await this.resolveApp(auth, input.app_id, input.apphud_app_id);
387
+ this.assertRawAccess(auth, input.include_raw);
388
+ const gross = await this.resolveMetricValue(app, {
389
+ apphudAppId: input.apphud_app_id,
390
+ metricKey: "revenue_gross",
391
+ from: input.from,
392
+ to: input.to,
393
+ platform: input.platform,
394
+ filters: input.filters,
395
+ includeRaw: false,
396
+ auth,
397
+ });
398
+ const refunds = await this.resolveMetricValue(app, {
399
+ apphudAppId: input.apphud_app_id,
400
+ metricKey: "refunds",
401
+ from: input.from,
402
+ to: input.to,
403
+ platform: input.platform,
404
+ filters: input.filters,
405
+ includeRaw: false,
406
+ auth,
407
+ });
408
+ const grossValue = gross.value ?? 0;
409
+ const refundsValue = Math.abs(refunds.value ?? 0);
410
+ const net = gross.value === null && refunds.value === null ? null : Number((grossValue - refundsValue).toFixed(4));
411
+ let compare;
412
+ if (input.compare_prev_period) {
413
+ const prev = shiftPeriod(input.from, input.to);
414
+ const prevGross = await this.resolveMetricValue(app, {
415
+ apphudAppId: input.apphud_app_id,
416
+ metricKey: "revenue_gross",
417
+ from: prev.from,
418
+ to: prev.to,
419
+ platform: input.platform,
420
+ filters: input.filters,
421
+ includeRaw: false,
422
+ auth,
423
+ });
424
+ const previous = prevGross.value;
425
+ const delta = previous === null || gross.value === null ? null : Number((gross.value - previous).toFixed(4));
426
+ compare = {
427
+ previous_from: prev.from,
428
+ previous_to: prev.to,
429
+ previous_revenue_gross: previous,
430
+ delta_vs_prev: delta,
431
+ };
432
+ }
433
+ return {
434
+ app_id: app.appId,
435
+ apphud_app_id: input.apphud_app_id ?? app.appId,
436
+ from: safeIso(input.from),
437
+ to: safeIso(input.to),
438
+ platform: input.platform,
439
+ revenue_gross: gross.value,
440
+ refunds: refunds.value,
441
+ net_revenue_estimate: net,
442
+ source: "apphud_analytics_api",
443
+ source_used: [gross.source_used, refunds.source_used],
444
+ warnings: Array.from(new Set([...gross.warnings, ...refunds.warnings])),
445
+ compare_prev_period: compare,
446
+ retrieved_at: new Date().toISOString(),
447
+ raw_payload: input.include_raw && auth.role === "admin"
448
+ ? {
449
+ gross: gross.raw_payload,
450
+ refunds: refunds.raw_payload,
451
+ }
452
+ : undefined,
453
+ };
454
+ }
455
+ async subscriptionsSummary(auth, input) {
456
+ const app = await this.resolveApp(auth, input.app_id, input.apphud_app_id);
457
+ this.assertRawAccess(auth, input.include_raw);
458
+ const metricInputBase = {
459
+ apphudAppId: input.apphud_app_id,
460
+ from: input.from,
461
+ to: input.to,
462
+ platform: input.platform,
463
+ filters: input.filters,
464
+ includeRaw: false,
465
+ auth,
466
+ };
467
+ const [activePaid, activeTrials, newSubs, renewals, cancellations] = await Promise.all([
468
+ this.resolveMetricValue(app, { ...metricInputBase, metricKey: "active_subs" }),
469
+ this.resolveMetricValue(app, { ...metricInputBase, metricKey: "active_trials" }),
470
+ this.resolveMetricValue(app, { ...metricInputBase, metricKey: "new_subscriptions" }),
471
+ this.resolveMetricValue(app, { ...metricInputBase, metricKey: "renewals" }),
472
+ this.resolveMetricValue(app, { ...metricInputBase, metricKey: "cancellations" }),
473
+ ]);
474
+ const churnRateEstimate = typeof cancellations.value === "number" && typeof activePaid.value === "number" && activePaid.value > 0
475
+ ? Number((cancellations.value / activePaid.value).toFixed(6))
476
+ : null;
477
+ return {
478
+ app_id: app.appId,
479
+ apphud_app_id: input.apphud_app_id ?? app.appId,
480
+ from: input.from,
481
+ to: input.to,
482
+ platform: input.platform,
483
+ active_paid: activePaid.value,
484
+ active_trials: activeTrials.value,
485
+ new_subscriptions: newSubs.value,
486
+ renewals: renewals.value,
487
+ cancellations: cancellations.value,
488
+ churn_rate_estimate: churnRateEstimate,
489
+ source: "apphud_analytics_api",
490
+ source_used: [
491
+ activePaid.source_used,
492
+ activeTrials.source_used,
493
+ newSubs.source_used,
494
+ renewals.source_used,
495
+ cancellations.source_used,
496
+ ],
497
+ warnings: Array.from(new Set([
498
+ ...activePaid.warnings,
499
+ ...activeTrials.warnings,
500
+ ...newSubs.warnings,
501
+ ...renewals.warnings,
502
+ ...cancellations.warnings,
503
+ ])),
504
+ retrieved_at: new Date().toISOString(),
505
+ raw_payload: input.include_raw && auth.role === "admin"
506
+ ? {
507
+ active_paid: activePaid.raw_payload,
508
+ active_trials: activeTrials.raw_payload,
509
+ new_subscriptions: newSubs.raw_payload,
510
+ renewals: renewals.raw_payload,
511
+ cancellations: cancellations.raw_payload,
512
+ }
513
+ : undefined,
514
+ };
515
+ }
516
+ async conversionTrialToPaid(auth, input) {
517
+ const app = await this.resolveApp(auth, input.app_id, input.apphud_app_id);
518
+ this.assertRawAccess(auth, input.include_raw);
519
+ const started = await this.resolveMetricValue(app, {
520
+ apphudAppId: input.apphud_app_id,
521
+ metricKey: "trials_started",
522
+ from: input.from,
523
+ to: input.to,
524
+ platform: input.platform,
525
+ filters: input.filters,
526
+ includeRaw: false,
527
+ auth,
528
+ });
529
+ const converted = await this.resolveMetricValue(app, {
530
+ apphudAppId: input.apphud_app_id,
531
+ metricKey: "trials_converted",
532
+ from: input.from,
533
+ to: input.to,
534
+ platform: input.platform,
535
+ filters: input.filters,
536
+ includeRaw: false,
537
+ auth,
538
+ });
539
+ const conversionRate = typeof started.value === "number" && started.value > 0 && typeof converted.value === "number"
540
+ ? Number((converted.value / started.value).toFixed(6))
541
+ : null;
542
+ return {
543
+ app_id: app.appId,
544
+ apphud_app_id: input.apphud_app_id ?? app.appId,
545
+ from: safeIso(input.from),
546
+ to: safeIso(input.to),
547
+ platform: input.platform,
548
+ trials_started: started.value,
549
+ trials_converted: converted.value,
550
+ conversion_rate: conversionRate,
551
+ median_time_to_convert: null,
552
+ source: "apphud_analytics_api",
553
+ source_used: [started.source_used, converted.source_used],
554
+ warnings: Array.from(new Set([...started.warnings, ...converted.warnings])),
555
+ retrieved_at: new Date().toISOString(),
556
+ raw_payload: input.include_raw && auth.role === "admin"
557
+ ? {
558
+ trials_started: started.raw_payload,
559
+ trials_converted: converted.raw_payload,
560
+ }
561
+ : undefined,
562
+ };
563
+ }
564
+ async cohortsRetention(auth, input) {
565
+ const app = await this.resolveApp(auth, input.app_id, input.apphud_app_id);
566
+ this.assertRawAccess(auth, input.include_raw);
567
+ const chart = await this.apphudClient.fetchChartQuery(app, {
568
+ shape: "column",
569
+ metricKey: "subscribers_retention",
570
+ apphudAppId: input.apphud_app_id,
571
+ from: input.from,
572
+ to: input.to,
573
+ granularity: input.granularity,
574
+ platform: input.platform,
575
+ filters: input.filters,
576
+ });
577
+ const fromBreakdown = extractBreakdown(chart.payload, "subscribers_retention");
578
+ const fromSeries = extractTimeseries(chart.payload, "subscribers_retention");
579
+ let periods = [];
580
+ let extractedFrom = fromBreakdown.path ?? fromSeries.path;
581
+ const warnings = [];
582
+ if (fromBreakdown.rows.length > 0) {
583
+ periods = fromBreakdown.rows
584
+ .slice(0, input.max_periods ?? 52)
585
+ .map((row) => ({
586
+ period_index: periodIndexFromLabel(row.key),
587
+ retention_rate: asRetentionRate(row.value),
588
+ users_count: null,
589
+ }))
590
+ .sort((a, b) => Number(a.period_index) - Number(b.period_index));
591
+ }
592
+ else if (fromSeries.points.length > 0) {
593
+ periods = fromSeries.points
594
+ .slice(0, input.max_periods ?? 52)
595
+ .map((point, index) => ({
596
+ period_index: index,
597
+ retention_rate: asRetentionRate(point.value),
598
+ users_count: null,
599
+ }));
600
+ }
601
+ else {
602
+ warnings.push("No retention series returned by analytics endpoint");
603
+ }
604
+ return {
605
+ app_id: app.appId,
606
+ apphud_app_id: input.apphud_app_id ?? app.appId,
607
+ from: safeIso(input.from),
608
+ to: safeIso(input.to),
609
+ granularity: input.granularity ?? "week",
610
+ cohort_by: "subscription_started",
611
+ rows: [
612
+ {
613
+ cohort_start_date: safeIso(input.from).slice(0, 10),
614
+ users_count: null,
615
+ periods,
616
+ },
617
+ ],
618
+ source: "apphud_analytics_api",
619
+ source_used: chart.sourcePath,
620
+ extracted_from: extractedFrom,
621
+ warnings,
622
+ retrieved_at: new Date().toISOString(),
623
+ raw_payload: this.selectRawPayload(auth, input.include_raw, chart.payload),
624
+ };
625
+ }
626
+ async cohortsLtv(auth, input) {
627
+ const app = await this.resolveApp(auth, input.app_id, input.apphud_app_id);
628
+ this.assertRawAccess(auth, input.include_raw);
629
+ const chart = await this.apphudClient.fetchChartQuery(app, {
630
+ shape: "line",
631
+ metricKey: "cumulative_ltv",
632
+ apphudAppId: input.apphud_app_id,
633
+ from: input.from,
634
+ to: input.to,
635
+ granularity: input.granularity,
636
+ platform: input.platform,
637
+ filters: input.filters,
638
+ });
639
+ const series = extractTimeseries(chart.payload, "cumulative_ltv");
640
+ const periods = series.points.slice(0, input.max_periods ?? 52).map((point, index) => ({
641
+ period_index: index,
642
+ ltv_value: Number(point.value.toFixed(6)),
643
+ date: point.date,
644
+ }));
645
+ const warnings = periods.length === 0 ? ["No LTV timeseries returned by analytics endpoint"] : [];
646
+ return {
647
+ app_id: app.appId,
648
+ apphud_app_id: input.apphud_app_id ?? app.appId,
649
+ from: safeIso(input.from),
650
+ to: safeIso(input.to),
651
+ granularity: input.granularity ?? "week",
652
+ rows: [
653
+ {
654
+ cohort_start_date: safeIso(input.from).slice(0, 10),
655
+ periods,
656
+ },
657
+ ],
658
+ source: "apphud_analytics_api",
659
+ source_used: chart.sourcePath,
660
+ extracted_from: series.path,
661
+ warnings,
662
+ retrieved_at: new Date().toISOString(),
663
+ raw_payload: this.selectRawPayload(auth, input.include_raw, chart.payload),
664
+ };
665
+ }
666
+ async queryRaw(auth, input) {
667
+ const app = await this.resolveApp(auth, input.app_id, input.apphud_app_id);
668
+ this.assertRawAccess(auth, input.include_raw);
669
+ const query = input.query;
670
+ if (query.shape === "value") {
671
+ if (!query.metric_key) {
672
+ throw new ApphudMcpError("INVALID_PAYLOAD", "query.metric_key is required for shape=value", {
673
+ statusCode: 400,
674
+ });
675
+ }
676
+ return this.metricValue(auth, {
677
+ app_id: input.app_id,
678
+ apphud_app_id: input.apphud_app_id,
679
+ metric_key: query.metric_key,
680
+ from: query.from,
681
+ to: query.to,
682
+ platform: query.platform,
683
+ filters: query.filters,
684
+ include_raw: input.include_raw,
685
+ });
686
+ }
687
+ if (query.shape === "timeseries") {
688
+ if (!query.metric_key || !query.from || !query.to) {
689
+ throw new ApphudMcpError("INVALID_PAYLOAD", "query.metric_key/from/to are required for shape=timeseries", {
690
+ statusCode: 400,
691
+ });
692
+ }
693
+ return this.metricTimeseries(auth, {
694
+ app_id: input.app_id,
695
+ apphud_app_id: input.apphud_app_id,
696
+ metric_key: query.metric_key,
697
+ from: query.from,
698
+ to: query.to,
699
+ granularity: query.granularity,
700
+ platform: query.platform,
701
+ filters: query.filters,
702
+ include_raw: input.include_raw,
703
+ });
704
+ }
705
+ if (query.shape === "breakdown") {
706
+ if (!query.metric_key || !query.from || !query.to || !query.dimension) {
707
+ throw new ApphudMcpError("INVALID_PAYLOAD", "query.metric_key/from/to/dimension are required for shape=breakdown", {
708
+ statusCode: 400,
709
+ });
710
+ }
711
+ return this.metricBreakdown(auth, {
712
+ app_id: input.app_id,
713
+ apphud_app_id: input.apphud_app_id,
714
+ metric_key: query.metric_key,
715
+ from: query.from,
716
+ to: query.to,
717
+ dimension: query.dimension,
718
+ granularity: query.granularity,
719
+ platform: query.platform,
720
+ filters: query.filters,
721
+ limit: query.limit,
722
+ include_raw: input.include_raw,
723
+ });
724
+ }
725
+ if (!query.endpoint_path) {
726
+ throw new ApphudMcpError("INVALID_PAYLOAD", "query.endpoint_path is required for shape=raw", {
727
+ statusCode: 400,
728
+ });
729
+ }
730
+ const raw = await this.apphudClient.fetchAnalyticsRaw(app, {
731
+ path: query.endpoint_path,
732
+ method: query.method,
733
+ apphudAppId: input.apphud_app_id,
734
+ query: query.query_params,
735
+ body: query.body,
736
+ });
737
+ return {
738
+ app_id: app.appId,
739
+ apphud_app_id: raw.apphudAppId,
740
+ shape: query.shape,
741
+ endpoint_path: query.endpoint_path,
742
+ method: query.method ?? "POST",
743
+ source: "apphud_analytics_api",
744
+ status: raw.status,
745
+ retrieved_at: new Date().toISOString(),
746
+ response: this.selectRawPayload(auth, true, raw.payload),
747
+ };
748
+ }
749
+ async resolveApp(auth, appId, apphudAppId) {
750
+ const explicitAppId = appId?.trim() || apphudAppId?.trim();
751
+ if (explicitAppId) {
752
+ return this.resolveOrFallbackApp(auth, explicitAppId);
753
+ }
754
+ const discovered = await this.apphudClient.listDashboardApps();
755
+ if (discovered.apps.length === 1) {
756
+ const single = discovered.apps[0];
757
+ if (!single) {
758
+ throw new ApphudMcpError("APP_NOT_FOUND", "No Apphud apps available for this account", {
759
+ statusCode: 404,
760
+ actionHint: "Check Apphud account apps visibility and analytics permissions",
761
+ });
762
+ }
763
+ return this.resolveOrFallbackApp(auth, single.app_id, single.app_name);
764
+ }
765
+ if (discovered.apps.length === 0) {
766
+ throw new ApphudMcpError("APP_NOT_FOUND", "No Apphud apps available for this account", {
767
+ statusCode: 404,
768
+ actionHint: "Check Apphud account apps visibility and analytics permissions",
769
+ });
770
+ }
771
+ throw new ApphudMcpError("INVALID_PAYLOAD", "app_id is required when account has multiple apps", {
772
+ statusCode: 400,
773
+ actionHint: "Call apphud.apps.list and pass app_id from returned list",
774
+ details: {
775
+ app_ids: discovered.apps.slice(0, 50).map((app) => app.app_id),
776
+ },
777
+ });
778
+ }
779
+ async resolveOrFallbackApp(auth, appId, appName) {
780
+ try {
781
+ return await this.appService.requireActiveAppForTenant(appId, auth.tenantId);
782
+ }
783
+ catch (error) {
784
+ if (!isApphudMcpError(error) || error.code !== "APP_NOT_FOUND") {
785
+ throw error;
786
+ }
787
+ const now = new Date().toISOString();
788
+ return {
789
+ appId,
790
+ tenantId: auth.tenantId,
791
+ name: appName ?? appId,
792
+ status: "active",
793
+ secretsRef: "UNUSED",
794
+ webhookSecretRef: undefined,
795
+ createdAt: now,
796
+ updatedAt: now,
797
+ };
798
+ }
799
+ }
800
+ async resolveMetricValue(app, options) {
801
+ const warnings = [];
802
+ if (options.from && options.to) {
803
+ const dashRange = await this.apphudClient.fetchDashRange(app, {
804
+ apphudAppId: options.apphudAppId,
805
+ from: options.from,
806
+ to: options.to,
807
+ metricKey: options.metricKey,
808
+ platform: options.platform,
809
+ filters: options.filters,
810
+ });
811
+ const extractedRange = extractMetricValue(dashRange.payload, options.metricKey);
812
+ if (extractedRange.value !== null) {
813
+ return {
814
+ value: extractedRange.value,
815
+ source_used: "/dash/range",
816
+ extracted_from: extractedRange.path,
817
+ raw_payload: this.selectRawPayload(options.auth, options.includeRaw, dashRange.payload),
818
+ warnings,
819
+ };
820
+ }
821
+ const extractedSeries = extractTimeseries(dashRange.payload, options.metricKey);
822
+ if (extractedSeries.points.length > 0) {
823
+ const total = extractedSeries.points.reduce((sum, point) => sum + point.value, 0);
824
+ return {
825
+ value: Number(total.toFixed(4)),
826
+ source_used: "/dash/range",
827
+ extracted_from: extractedSeries.path,
828
+ raw_payload: this.selectRawPayload(options.auth, options.includeRaw, dashRange.payload),
829
+ warnings,
830
+ };
831
+ }
832
+ warnings.push("Metric not found in dash/range payload, fallback to chart/query/line");
833
+ const chart = await this.apphudClient.fetchChartQuery(app, {
834
+ shape: "line",
835
+ metricKey: options.metricKey,
836
+ apphudAppId: options.apphudAppId,
837
+ from: options.from,
838
+ to: options.to,
839
+ platform: options.platform,
840
+ filters: options.filters,
841
+ });
842
+ const extractedChart = extractMetricValue(chart.payload, options.metricKey);
843
+ if (extractedChart.value !== null) {
844
+ return {
845
+ value: extractedChart.value,
846
+ source_used: chart.sourcePath,
847
+ extracted_from: extractedChart.path,
848
+ raw_payload: this.selectRawPayload(options.auth, options.includeRaw, chart.payload),
849
+ warnings,
850
+ };
851
+ }
852
+ const fromChartSeries = extractTimeseries(chart.payload, options.metricKey);
853
+ if (fromChartSeries.points.length > 0) {
854
+ const total = fromChartSeries.points.reduce((sum, point) => sum + point.value, 0);
855
+ return {
856
+ value: Number(total.toFixed(4)),
857
+ source_used: chart.sourcePath,
858
+ extracted_from: fromChartSeries.path,
859
+ raw_payload: this.selectRawPayload(options.auth, options.includeRaw, chart.payload),
860
+ warnings,
861
+ };
862
+ }
863
+ return {
864
+ value: null,
865
+ source_used: chart.sourcePath,
866
+ raw_payload: this.selectRawPayload(options.auth, options.includeRaw, chart.payload),
867
+ warnings: [...warnings, "Metric not found in analytics payloads"],
868
+ };
869
+ }
870
+ const dashNow = await this.apphudClient.fetchDashNow(app, {
871
+ apphudAppId: options.apphudAppId,
872
+ platform: options.platform,
873
+ filters: options.filters,
874
+ });
875
+ const extractedNow = extractMetricValue(dashNow.payload, options.metricKey);
876
+ if (extractedNow.value !== null) {
877
+ return {
878
+ value: extractedNow.value,
879
+ source_used: "/dash/now",
880
+ extracted_from: extractedNow.path,
881
+ raw_payload: this.selectRawPayload(options.auth, options.includeRaw, dashNow.payload),
882
+ warnings,
883
+ };
884
+ }
885
+ warnings.push("Metric not found in dash/now payload, fallback to chart/query/line");
886
+ const chartNow = await this.apphudClient.fetchChartQuery(app, {
887
+ shape: "line",
888
+ metricKey: options.metricKey,
889
+ apphudAppId: options.apphudAppId,
890
+ platform: options.platform,
891
+ filters: options.filters,
892
+ });
893
+ const extractedChartNow = extractMetricValue(chartNow.payload, options.metricKey);
894
+ if (extractedChartNow.value !== null) {
895
+ return {
896
+ value: extractedChartNow.value,
897
+ source_used: chartNow.sourcePath,
898
+ extracted_from: extractedChartNow.path,
899
+ raw_payload: this.selectRawPayload(options.auth, options.includeRaw, chartNow.payload),
900
+ warnings,
901
+ };
902
+ }
903
+ const series = extractTimeseries(chartNow.payload, options.metricKey);
904
+ const lastPoint = series.points[series.points.length - 1];
905
+ return {
906
+ value: lastPoint?.value ?? null,
907
+ source_used: chartNow.sourcePath,
908
+ extracted_from: series.path,
909
+ raw_payload: this.selectRawPayload(options.auth, options.includeRaw, chartNow.payload),
910
+ warnings: [...warnings, "Metric value estimated from last chart point"],
911
+ };
912
+ }
913
+ assertRawAccess(auth, includeRaw) {
914
+ if (!includeRaw) {
915
+ return;
916
+ }
917
+ if (auth.role !== "admin") {
918
+ throw new ApphudMcpError("FORBIDDEN", "Only admin can request raw payload", {
919
+ statusCode: 403,
920
+ });
921
+ }
922
+ }
923
+ selectRawPayload(auth, includeRaw, payload) {
924
+ if (!includeRaw) {
925
+ return undefined;
926
+ }
927
+ if (auth.role !== "admin") {
928
+ throw new ApphudMcpError("FORBIDDEN", "Only admin can request raw payload", {
929
+ statusCode: 403,
930
+ });
931
+ }
932
+ return payload;
933
+ }
934
+ }