@growthbook/mcp 1.3.0 → 1.4.2

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,494 @@
1
+ import { fetchWithRateLimit, handleResNotOk } from "../../utils.js";
2
+ // Helper functions
3
+ function median(arr) {
4
+ if (arr.length === 0)
5
+ return null;
6
+ const sorted = [...arr].sort((a, b) => a - b);
7
+ const mid = Math.floor(sorted.length / 2);
8
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
9
+ }
10
+ function round(n, decimals = 4) {
11
+ if (n === null || n === undefined || isNaN(n))
12
+ return null;
13
+ return Math.round(n * 10 ** decimals) / 10 ** decimals;
14
+ }
15
+ function formatLift(lift) {
16
+ if (lift === null)
17
+ return "N/A";
18
+ const sign = lift >= 0 ? "+" : "";
19
+ return `${sign}${(lift * 100).toFixed(1)}%`;
20
+ }
21
+ function getYearMonth(dateStr) {
22
+ if (!dateStr)
23
+ return null;
24
+ const d = new Date(dateStr);
25
+ if (isNaN(d.getTime()))
26
+ return null;
27
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
28
+ }
29
+ function computeVerdict(exp, metricLookup) {
30
+ const resultData = exp.result?.results?.[0];
31
+ const srmPValue = resultData?.checks?.srm ?? null;
32
+ const totalUsers = resultData?.totalUsers || 0;
33
+ const srmPassing = srmPValue !== null ? srmPValue > 0.001 : true;
34
+ // Get goal and guardrail metric IDs
35
+ const goalIds = exp.settings?.goals?.map((g) => g.metricId) || [];
36
+ const guardrailIds = new Set(exp.settings?.guardrails?.map((g) => g.metricId) || []);
37
+ // Check guardrail regression
38
+ const guardrailsRegressed = resultData
39
+ ? (resultData.metrics || [])
40
+ .filter((m) => guardrailIds.has(m.metricId))
41
+ .some((m) => {
42
+ const metricInfo = metricLookup.get(m.metricId);
43
+ const isInverse = metricInfo?.inverse ?? false;
44
+ return m.variations.slice(1).some((v) => {
45
+ const analysis = v.analyses?.[0];
46
+ if (!analysis)
47
+ return false;
48
+ if (analysis.chanceToBeatControl !== undefined) {
49
+ return isInverse
50
+ ? analysis.chanceToBeatControl > 0.95
51
+ : analysis.chanceToBeatControl < 0.05;
52
+ }
53
+ return isInverse
54
+ ? (analysis.ciLow ?? 0) > 0
55
+ : (analysis.ciHigh ?? 0) < 0;
56
+ });
57
+ })
58
+ : false;
59
+ // Verdict: Match GrowthBook's ExperimentWinRate.tsx exactly
60
+ // exp.results maps to resultSummary.status in the API
61
+ const userResult = exp.resultSummary?.status?.toLowerCase() || "";
62
+ let verdict;
63
+ if (userResult === "won") {
64
+ verdict = "won";
65
+ }
66
+ else if (userResult === "lost") {
67
+ verdict = "lost";
68
+ }
69
+ else {
70
+ // Everything else is "inconclusive": dnf, inconclusive, undefined, null, ""
71
+ verdict = "inconclusive";
72
+ }
73
+ // Compute primary metric result for display
74
+ const primaryMetricResult = resultData
75
+ ? computePrimaryMetricResult(resultData, metricLookup, goalIds)
76
+ : null;
77
+ return {
78
+ verdict,
79
+ primaryMetricResult,
80
+ guardrailsRegressed,
81
+ srmPassing,
82
+ srmPValue,
83
+ totalUsers,
84
+ };
85
+ }
86
+ function computePrimaryMetricResult(resultData, metricLookup, goalIds) {
87
+ const primaryMetricId = goalIds[0];
88
+ if (!primaryMetricId)
89
+ return null;
90
+ const primaryMetricData = resultData.metrics?.find((m) => m.metricId === primaryMetricId);
91
+ if (!primaryMetricData || primaryMetricData.variations.length <= 1) {
92
+ return null;
93
+ }
94
+ const metricInfo = metricLookup.get(primaryMetricId);
95
+ const isInverse = metricInfo?.inverse ?? false;
96
+ // Find best performing variation (excluding control at index 0)
97
+ let bestVariation = primaryMetricData.variations[1];
98
+ let bestLift = bestVariation?.analyses?.[0]?.percentChange ?? 0;
99
+ for (let i = 2; i < primaryMetricData.variations.length; i++) {
100
+ const v = primaryMetricData.variations[i];
101
+ const lift = v.analyses?.[0]?.percentChange ?? 0;
102
+ const isBetter = isInverse ? lift < bestLift : lift > bestLift;
103
+ if (isBetter) {
104
+ bestVariation = v;
105
+ bestLift = lift;
106
+ }
107
+ }
108
+ const analysis = bestVariation?.analyses?.[0];
109
+ if (!analysis)
110
+ return null;
111
+ const lift = analysis.percentChange;
112
+ const chanceToBeatControl = analysis.chanceToBeatControl;
113
+ let significant = false;
114
+ if (chanceToBeatControl !== undefined) {
115
+ significant = chanceToBeatControl > 0.95 || chanceToBeatControl < 0.05;
116
+ }
117
+ else {
118
+ significant =
119
+ analysis.ciLow !== undefined &&
120
+ analysis.ciHigh !== undefined &&
121
+ (analysis.ciLow > 0 || analysis.ciHigh < 0);
122
+ }
123
+ let direction = "flat";
124
+ if (significant) {
125
+ const rawPositive = lift > 0;
126
+ const isWinning = isInverse ? !rawPositive : rawPositive;
127
+ direction = isWinning ? "winning" : "losing";
128
+ }
129
+ return {
130
+ id: primaryMetricId,
131
+ name: metricInfo?.name || primaryMetricId,
132
+ lift: round(lift),
133
+ significant,
134
+ direction,
135
+ };
136
+ }
137
+ // Metric Lookup with caching
138
+ const metricCache = new Map();
139
+ const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
140
+ const MAX_CONCURRENT_FETCHES = 10; // Limit concurrent API calls
141
+ // Helper to process array in batches with concurrency limit
142
+ async function processBatch(items, concurrency, processor) {
143
+ const results = [];
144
+ for (let i = 0; i < items.length; i += concurrency) {
145
+ const batch = items.slice(i, i + concurrency);
146
+ const batchResults = await Promise.all(batch.map(processor));
147
+ results.push(...batchResults);
148
+ }
149
+ return results;
150
+ }
151
+ async function getMetricLookup(baseApiUrl, apiKey, metricIds) {
152
+ const metricLookup = new Map();
153
+ if (metricIds.size === 0) {
154
+ return metricLookup;
155
+ }
156
+ // Check cache first
157
+ const now = Date.now();
158
+ const uncachedMetricIds = [];
159
+ const factMetricIds = [];
160
+ const regularMetricIds = [];
161
+ for (const metricId of metricIds) {
162
+ const cached = metricCache.get(metricId);
163
+ if (cached && now - cached.timestamp < CACHE_TTL_MS) {
164
+ metricLookup.set(metricId, cached.info);
165
+ }
166
+ else {
167
+ uncachedMetricIds.push(metricId);
168
+ if (metricId.startsWith("fact__")) {
169
+ factMetricIds.push(metricId);
170
+ }
171
+ else {
172
+ regularMetricIds.push(metricId);
173
+ }
174
+ }
175
+ }
176
+ // If all metrics are cached, return early
177
+ if (uncachedMetricIds.length === 0) {
178
+ return metricLookup;
179
+ }
180
+ try {
181
+ // Fetch regular metrics in batches with concurrency limit
182
+ const regularResults = await processBatch(regularMetricIds, MAX_CONCURRENT_FETCHES, async (metricId) => {
183
+ try {
184
+ const res = await fetchWithRateLimit(`${baseApiUrl}/api/v1/metrics/${metricId}`, {
185
+ headers: {
186
+ Authorization: `Bearer ${apiKey}`,
187
+ "Content-Type": "application/json",
188
+ },
189
+ });
190
+ await handleResNotOk(res);
191
+ const data = await res.json();
192
+ const info = {
193
+ id: metricId,
194
+ name: data.name || metricId,
195
+ inverse: data.inverse || false,
196
+ type: data.type || "binomial",
197
+ };
198
+ // Cache the result
199
+ metricCache.set(metricId, { info, timestamp: now });
200
+ return { id: metricId, info };
201
+ }
202
+ catch (error) {
203
+ console.error(`Error fetching metric ${metricId}:`, error);
204
+ return null;
205
+ }
206
+ });
207
+ // Fetch fact metrics in batches with concurrency limit
208
+ const factResults = await processBatch(factMetricIds, MAX_CONCURRENT_FETCHES, async (metricId) => {
209
+ try {
210
+ const res = await fetchWithRateLimit(`${baseApiUrl}/api/v1/fact-metrics/${metricId}`, {
211
+ headers: {
212
+ Authorization: `Bearer ${apiKey}`,
213
+ "Content-Type": "application/json",
214
+ },
215
+ });
216
+ await handleResNotOk(res);
217
+ const data = await res.json();
218
+ const info = {
219
+ id: metricId,
220
+ name: data.name || metricId,
221
+ inverse: false,
222
+ type: "count", // Fact metrics are typically count type
223
+ };
224
+ // Cache the result
225
+ metricCache.set(metricId, { info, timestamp: now });
226
+ return { id: metricId, info };
227
+ }
228
+ catch (error) {
229
+ console.error(`Error fetching fact metric ${metricId}:`, error);
230
+ return null;
231
+ }
232
+ });
233
+ // Add fetched metrics to lookup
234
+ for (const result of regularResults) {
235
+ if (result) {
236
+ metricLookup.set(result.id, result.info);
237
+ }
238
+ }
239
+ for (const result of factResults) {
240
+ if (result) {
241
+ metricLookup.set(result.id, result.info);
242
+ }
243
+ }
244
+ }
245
+ catch (error) {
246
+ console.error("Error fetching metrics for lookup:", error);
247
+ // Return partial map if some fetches fail
248
+ }
249
+ return metricLookup;
250
+ }
251
+ async function buildExperimentStats(experiments, baseApiUrl, apiKey, reportProgress) {
252
+ await reportProgress(3, "Figuring out metrics...");
253
+ // Extract all unique metric IDs
254
+ const metricIds = new Set();
255
+ for (const exp of experiments) {
256
+ if (exp.settings?.goals) {
257
+ for (const goal of exp.settings.goals) {
258
+ if (goal.metricId)
259
+ metricIds.add(goal.metricId);
260
+ }
261
+ }
262
+ if (exp.settings?.guardrails) {
263
+ for (const guardrail of exp.settings.guardrails) {
264
+ if (guardrail.metricId)
265
+ metricIds.add(guardrail.metricId);
266
+ }
267
+ }
268
+ }
269
+ const metricLookup = await getMetricLookup(baseApiUrl, apiKey, metricIds);
270
+ const cards = [];
271
+ const byVerdict = { won: 0, lost: 0, inconclusive: 0 };
272
+ const byProject = {};
273
+ const byTag = {};
274
+ const byMonth = {};
275
+ const byType = {
276
+ standard: 0,
277
+ bandit: 0,
278
+ };
279
+ const srmIssues = [];
280
+ const durations = [];
281
+ const winnerLifts = [];
282
+ const loserLifts = [];
283
+ let totalUsers = 0;
284
+ let srmFailures = 0;
285
+ let guardrailRegressions = 0;
286
+ let experimentsWithResults = 0;
287
+ await reportProgress(4, "Computing experiment stats...");
288
+ for (const exp of experiments) {
289
+ const verdictResult = computeVerdict(exp, metricLookup);
290
+ const { verdict, primaryMetricResult, guardrailsRegressed, srmPassing, srmPValue, } = verdictResult;
291
+ const expType = exp.type || "standard";
292
+ // Parse dates
293
+ const dateStarted = exp.result?.dateStart || null;
294
+ const dateEnded = exp.result?.dateEnd || null;
295
+ let durationDays = null;
296
+ if (dateStarted && dateEnded) {
297
+ const start = new Date(dateStarted);
298
+ const end = new Date(dateEnded);
299
+ if (!isNaN(start.getTime()) && !isNaN(end.getTime())) {
300
+ durationDays = Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
301
+ if (durationDays >= 0)
302
+ durations.push(durationDays);
303
+ }
304
+ }
305
+ // Build card
306
+ const card = {
307
+ id: exp.id,
308
+ name: exp.name,
309
+ trackingKey: exp.trackingKey,
310
+ hypothesis: exp.hypothesis || "",
311
+ verdict,
312
+ project: exp.project || "",
313
+ tags: exp.tags || [],
314
+ owner: exp.owner || "",
315
+ type: expType,
316
+ primaryMetric: primaryMetricResult
317
+ ? {
318
+ ...primaryMetricResult,
319
+ liftFormatted: formatLift(primaryMetricResult.lift),
320
+ }
321
+ : null,
322
+ totalUsers: verdictResult.totalUsers,
323
+ durationDays,
324
+ dateStarted,
325
+ dateEnded,
326
+ srmPassing,
327
+ srmPValue,
328
+ guardrailsRegressed,
329
+ };
330
+ cards.push(card);
331
+ // Accumulate stats
332
+ byVerdict[verdict]++;
333
+ totalUsers += verdictResult.totalUsers;
334
+ if (verdictResult.totalUsers > 0) {
335
+ experimentsWithResults++;
336
+ if (!srmPassing) {
337
+ srmFailures++;
338
+ srmIssues.push({
339
+ id: exp.id,
340
+ name: exp.name,
341
+ srmPValue: srmPValue,
342
+ });
343
+ }
344
+ if (guardrailsRegressed)
345
+ guardrailRegressions++;
346
+ }
347
+ if (verdict === "won" && primaryMetricResult?.lift != null) {
348
+ winnerLifts.push(Math.abs(primaryMetricResult.lift));
349
+ }
350
+ if (verdict === "lost" && primaryMetricResult?.lift != null) {
351
+ loserLifts.push(primaryMetricResult.lift);
352
+ }
353
+ // By project
354
+ const project = exp.project || "(none)";
355
+ if (!byProject[project]) {
356
+ byProject[project] = {
357
+ count: 0,
358
+ won: 0,
359
+ lost: 0,
360
+ inconclusive: 0,
361
+ winRate: null,
362
+ };
363
+ }
364
+ byProject[project].count++;
365
+ if (verdict === "won")
366
+ byProject[project].won++;
367
+ if (verdict === "lost")
368
+ byProject[project].lost++;
369
+ if (verdict === "inconclusive")
370
+ byProject[project].inconclusive++;
371
+ // By tags
372
+ for (const tag of exp.tags || []) {
373
+ if (!byTag[tag]) {
374
+ byTag[tag] = {
375
+ count: 0,
376
+ won: 0,
377
+ lost: 0,
378
+ inconclusive: 0,
379
+ winRate: null,
380
+ };
381
+ }
382
+ byTag[tag].count++;
383
+ if (verdict === "won")
384
+ byTag[tag].won++;
385
+ if (verdict === "lost")
386
+ byTag[tag].lost++;
387
+ if (verdict === "inconclusive")
388
+ byTag[tag].inconclusive++;
389
+ }
390
+ // By month
391
+ const endMonth = getYearMonth(dateEnded);
392
+ if (endMonth) {
393
+ if (!byMonth[endMonth])
394
+ byMonth[endMonth] = { ended: 0, won: 0, lost: 0 };
395
+ byMonth[endMonth].ended++;
396
+ if (verdict === "won")
397
+ byMonth[endMonth].won++;
398
+ if (verdict === "lost")
399
+ byMonth[endMonth].lost++;
400
+ }
401
+ // By type
402
+ if (expType === "multi-armed-bandit") {
403
+ byType.bandit++;
404
+ }
405
+ else {
406
+ byType.standard++;
407
+ }
408
+ }
409
+ // Calculate win rates for projects and tags
410
+ for (const key of Object.keys(byProject)) {
411
+ const p = byProject[key];
412
+ const total = p.won + p.lost + p.inconclusive;
413
+ p.winRate = total > 0 ? round(p.won / total) : null;
414
+ }
415
+ for (const key of Object.keys(byTag)) {
416
+ const t = byTag[key];
417
+ const total = t.won + t.lost + t.inconclusive;
418
+ t.winRate = total > 0 ? round(t.won / total) : null;
419
+ }
420
+ const total = byVerdict.won + byVerdict.lost + byVerdict.inconclusive;
421
+ // Top winners and losers
422
+ const topWinners = cards
423
+ .filter((c) => c.verdict === "won" && c.primaryMetric?.lift != null)
424
+ .sort((a, b) => Math.abs(b.primaryMetric.lift) - Math.abs(a.primaryMetric.lift))
425
+ .slice(0, 5)
426
+ .map((c) => ({
427
+ id: c.id,
428
+ name: c.name,
429
+ lift: c.primaryMetric.lift,
430
+ liftFormatted: c.primaryMetric.liftFormatted,
431
+ metric: c.primaryMetric.name,
432
+ hypothesis: c.hypothesis,
433
+ }));
434
+ const topLosers = cards
435
+ .filter((c) => c.verdict === "lost" && c.primaryMetric?.lift != null)
436
+ .sort((a, b) => Math.abs(b.primaryMetric.lift) - Math.abs(a.primaryMetric.lift))
437
+ .slice(0, 5)
438
+ .map((c) => ({
439
+ id: c.id,
440
+ name: c.name,
441
+ lift: c.primaryMetric.lift,
442
+ liftFormatted: c.primaryMetric.liftFormatted,
443
+ metric: c.primaryMetric.name,
444
+ hypothesis: c.hypothesis,
445
+ }));
446
+ await reportProgress(5, "Putting on the finishing touches...");
447
+ return {
448
+ total: experiments.length,
449
+ byVerdict,
450
+ // Matches GrowthBook: winRate = won / (won + lost + inconclusive)
451
+ winRate: total > 0 ? round(byVerdict.won / total) : null,
452
+ avgDurationDays: durations.length > 0
453
+ ? round(durations.reduce((a, b) => a + b, 0) / durations.length, 1)
454
+ : null,
455
+ medianDurationDays: median(durations),
456
+ totalUsers,
457
+ avgUsersPerExperiment: experimentsWithResults > 0
458
+ ? Math.round(totalUsers / experimentsWithResults)
459
+ : null,
460
+ avgLiftWinners: winnerLifts.length > 0
461
+ ? round(winnerLifts.reduce((a, b) => a + b, 0) / winnerLifts.length)
462
+ : null,
463
+ medianLiftWinners: median(winnerLifts),
464
+ srmFailureRate: experimentsWithResults > 0
465
+ ? round(srmFailures / experimentsWithResults)
466
+ : null,
467
+ guardrailRegressionRate: experimentsWithResults > 0
468
+ ? round(guardrailRegressions / experimentsWithResults)
469
+ : null,
470
+ srmIssues,
471
+ topWinners,
472
+ topLosers,
473
+ byProject,
474
+ byTag,
475
+ byMonth,
476
+ byType,
477
+ experiments: cards,
478
+ };
479
+ }
480
+ export async function handleSummaryMode(experiments, baseApiUrl, apiKey, reportProgress) {
481
+ // Filter to stopped experiments only - matching GrowthBook's filter
482
+ const stoppedExperiments = experiments.filter((exp) => exp.status === "stopped");
483
+ const stats = await buildExperimentStats(stoppedExperiments, baseApiUrl, apiKey, reportProgress);
484
+ return {
485
+ ...stats,
486
+ _meta: {
487
+ totalFetched: experiments.length,
488
+ excluded: {
489
+ draft: experiments.filter((e) => e.status === "draft").length,
490
+ running: experiments.filter((e) => e.status === "running").length,
491
+ },
492
+ },
493
+ };
494
+ }