@growthbook/mcp 1.4.4 → 1.5.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.
- package/package.json +11 -5
- package/server/docs.js +88 -0
- package/server/index.js +6 -26
- package/server/tools/defaults.js +31 -16
- package/server/tools/environments.js +8 -2
- package/server/tools/experiments/experiment-summary.js +1 -135
- package/server/tools/experiments/experiments.js +183 -275
- package/server/tools/experiments/summary-logic.js +134 -0
- package/server/tools/features.js +104 -108
- package/server/tools/metrics.js +67 -76
- package/server/tools/projects.js +17 -18
- package/server/tools/sdk-connections.js +65 -65
- package/server/tools/search.js +19 -14
- package/server/utils.js +112 -0
|
@@ -1,24 +1,76 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { generateLinkToGrowthBook, getDocsMetadata, handleResNotOk, SUPPORTED_FILE_EXTENSIONS, paginationSchema, fetchWithRateLimit, } from "../../utils.js";
|
|
2
|
+
import { generateLinkToGrowthBook, getDocsMetadata, handleResNotOk, SUPPORTED_FILE_EXTENSIONS, paginationSchema, fetchWithRateLimit, fetchWithPagination, featureFlagSchema, fetchFeatureFlag, mergeRuleIntoFeatureFlag, } from "../../utils.js";
|
|
3
3
|
import { getDefaults } from "../defaults.js";
|
|
4
4
|
import { handleSummaryMode } from "./experiment-summary.js";
|
|
5
5
|
export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin, user, }) {
|
|
6
6
|
/**
|
|
7
7
|
* Tool: get_experiments
|
|
8
8
|
*/
|
|
9
|
-
server.
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
9
|
+
server.registerTool("get_experiments", {
|
|
10
|
+
title: "Get Experiments",
|
|
11
|
+
description: "Lists experiments or fetches details for a specific experiment. Supports three modes: metadata (default) returns experiment config without results, good for listing; summary fetches results and returns key statistics including win rate and top performers, good for quick analysis; full returns complete results with all metrics (warning: large payloads). Use this to review recent experiments (mostRecent=true), analyze results, or check experiment status (draft, running, stopped). Single experiment fetch includes a link to view in GrowthBook.",
|
|
12
|
+
inputSchema: z.object({
|
|
13
|
+
project: z
|
|
14
|
+
.string()
|
|
15
|
+
.describe("The ID of the project to filter experiments by")
|
|
16
|
+
.optional(),
|
|
17
|
+
mode: z
|
|
18
|
+
.enum(["metadata", "summary", "full"])
|
|
19
|
+
.default("metadata")
|
|
20
|
+
.describe("The mode to use to fetch experiments. Metadata mode returns experiment config without results. Summary mode fetches results and returns pruned key stats for quick analysis. Full mode fetches and returns complete results data. WARNING: Full mode may return large payloads."),
|
|
21
|
+
experimentId: z
|
|
22
|
+
.string()
|
|
23
|
+
.describe("The ID of the experiment to fetch")
|
|
24
|
+
.optional(),
|
|
25
|
+
...paginationSchema,
|
|
26
|
+
}),
|
|
27
|
+
annotations: {
|
|
28
|
+
readOnlyHint: true,
|
|
29
|
+
},
|
|
30
|
+
}, async ({ limit, offset, mostRecent, project, mode, experimentId }, extra) => {
|
|
31
|
+
if (experimentId) {
|
|
32
|
+
try {
|
|
33
|
+
const res = await fetchWithRateLimit(`${baseApiUrl}/api/v1/experiments/${experimentId}`, {
|
|
34
|
+
headers: {
|
|
35
|
+
Authorization: `Bearer ${apiKey}`,
|
|
36
|
+
"Content-Type": "application/json",
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
await handleResNotOk(res);
|
|
40
|
+
const data = await res.json();
|
|
41
|
+
// Fetch results
|
|
42
|
+
if (mode === "full") {
|
|
43
|
+
if (data.status === "draft") {
|
|
44
|
+
data.result = null;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const resultsRes = await fetchWithRateLimit(`${baseApiUrl}/api/v1/experiments/${experimentId}/results`, {
|
|
48
|
+
headers: {
|
|
49
|
+
Authorization: `Bearer ${apiKey}`,
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
await handleResNotOk(resultsRes);
|
|
53
|
+
const resultsData = await resultsRes.json();
|
|
54
|
+
data.result = resultsData.result;
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
console.error(`Error fetching results for experiment ${experimentId}`, error);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const linkToGrowthBook = generateLinkToGrowthBook(appOrigin, "experiment", experimentId);
|
|
61
|
+
const text = `
|
|
62
|
+
${JSON.stringify(data)}
|
|
63
|
+
|
|
64
|
+
[View the experiment in GrowthBook](${linkToGrowthBook})
|
|
65
|
+
`;
|
|
66
|
+
return {
|
|
67
|
+
content: [{ type: "text", text }],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
throw new Error(`Error getting experiment: ${error}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
22
74
|
const progressToken = extra._meta?.progressToken;
|
|
23
75
|
const totalSteps = mode === "summary" ? 5 : mode === "full" ? 3 : 2;
|
|
24
76
|
const reportProgress = async (progress, message) => {
|
|
@@ -36,128 +88,44 @@ export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin,
|
|
|
36
88
|
};
|
|
37
89
|
await reportProgress(1, "Fetching experiments...");
|
|
38
90
|
try {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
},
|
|
53
|
-
});
|
|
54
|
-
await handleResNotOk(defaultRes);
|
|
55
|
-
const data = await defaultRes.json();
|
|
56
|
-
const experiments = data.experiments;
|
|
57
|
-
if (mode === "full" || mode === "summary") {
|
|
58
|
-
await reportProgress(2, "Fetching experiment results...");
|
|
59
|
-
for (const [index, experiment] of experiments.entries()) {
|
|
60
|
-
if (experiment.status === "draft") {
|
|
61
|
-
experiments[index].result = undefined;
|
|
62
|
-
continue;
|
|
63
|
-
}
|
|
64
|
-
try {
|
|
65
|
-
const resultsRes = await fetchWithRateLimit(`${baseApiUrl}/api/v1/experiments/${experiment.id}/results`, {
|
|
66
|
-
headers: {
|
|
67
|
-
Authorization: `Bearer ${apiKey}`,
|
|
68
|
-
},
|
|
69
|
-
});
|
|
70
|
-
await handleResNotOk(resultsRes);
|
|
71
|
-
const resultsData = await resultsRes.json();
|
|
72
|
-
experiments[index].result = resultsData.result;
|
|
73
|
-
}
|
|
74
|
-
catch (error) {
|
|
75
|
-
console.error(`Error fetching results for experiment ${experiment.id} (${experiment.name})`, error);
|
|
76
|
-
}
|
|
91
|
+
const data = await fetchWithPagination(baseApiUrl, apiKey, "/api/v1/experiments", limit, offset, mostRecent, project ? { projectId: project } : undefined);
|
|
92
|
+
let experiments = data.experiments || [];
|
|
93
|
+
// Reverse experiments array for mostRecent to show newest-first
|
|
94
|
+
if (mostRecent && offset === 0 && Array.isArray(experiments)) {
|
|
95
|
+
experiments = experiments.reverse();
|
|
96
|
+
data.experiments = experiments;
|
|
97
|
+
}
|
|
98
|
+
if (mode === "full" || mode === "summary") {
|
|
99
|
+
await reportProgress(2, "Fetching experiment results...");
|
|
100
|
+
for (const [index, experiment] of experiments.entries()) {
|
|
101
|
+
if (experiment.status === "draft") {
|
|
102
|
+
experiments[index].result = undefined;
|
|
103
|
+
continue;
|
|
77
104
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
summary: summaryExperiments,
|
|
83
|
-
limit: data.limit,
|
|
84
|
-
offset: data.offset,
|
|
85
|
-
total: data.total,
|
|
86
|
-
hasMore: data.hasMore,
|
|
87
|
-
nextOffset: data.nextOffset,
|
|
88
|
-
};
|
|
89
|
-
return {
|
|
90
|
-
content: [
|
|
91
|
-
{
|
|
92
|
-
type: "text",
|
|
93
|
-
text: JSON.stringify(summaryExperimentsWithPagination),
|
|
105
|
+
try {
|
|
106
|
+
const resultsRes = await fetchWithRateLimit(`${baseApiUrl}/api/v1/experiments/${experiment.id}/results`, {
|
|
107
|
+
headers: {
|
|
108
|
+
Authorization: `Bearer ${apiKey}`,
|
|
94
109
|
},
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
// Most recent behavior
|
|
104
|
-
const countRes = await fetchWithRateLimit(`${baseApiUrl}/api/v1/experiments?limit=1`, {
|
|
105
|
-
headers: {
|
|
106
|
-
Authorization: `Bearer ${apiKey}`,
|
|
107
|
-
},
|
|
108
|
-
});
|
|
109
|
-
await handleResNotOk(countRes);
|
|
110
|
-
const countData = await countRes.json();
|
|
111
|
-
const total = countData.total;
|
|
112
|
-
const calculatedOffset = Math.max(0, total - limit);
|
|
113
|
-
const mostRecentQueryParams = new URLSearchParams({
|
|
114
|
-
limit: limit.toString(),
|
|
115
|
-
offset: calculatedOffset.toString(),
|
|
116
|
-
});
|
|
117
|
-
if (project) {
|
|
118
|
-
mostRecentQueryParams.append("projectId", project);
|
|
119
|
-
}
|
|
120
|
-
const mostRecentRes = await fetchWithRateLimit(`${baseApiUrl}/api/v1/experiments?${mostRecentQueryParams.toString()}`, {
|
|
121
|
-
headers: {
|
|
122
|
-
Authorization: `Bearer ${apiKey}`,
|
|
123
|
-
},
|
|
124
|
-
});
|
|
125
|
-
await handleResNotOk(mostRecentRes);
|
|
126
|
-
const mostRecentData = await mostRecentRes.json();
|
|
127
|
-
if (mostRecentData.experiments &&
|
|
128
|
-
Array.isArray(mostRecentData.experiments)) {
|
|
129
|
-
mostRecentData.experiments = mostRecentData.experiments.reverse();
|
|
130
|
-
if (mode === "full" || mode === "summary") {
|
|
131
|
-
await reportProgress(2, "Fetching experiment results...");
|
|
132
|
-
for (const [index, experiment,] of mostRecentData.experiments.entries()) {
|
|
133
|
-
try {
|
|
134
|
-
const resultsRes = await fetchWithRateLimit(`${baseApiUrl}/api/v1/experiments/${experiment.id}/results`, {
|
|
135
|
-
headers: {
|
|
136
|
-
Authorization: `Bearer ${apiKey}`,
|
|
137
|
-
},
|
|
138
|
-
});
|
|
139
|
-
await handleResNotOk(resultsRes);
|
|
140
|
-
const resultsData = await resultsRes.json();
|
|
141
|
-
mostRecentData.experiments[index].result = resultsData.result;
|
|
142
|
-
}
|
|
143
|
-
catch (error) {
|
|
144
|
-
console.error(`Error fetching results for experiment ${experiment.id} (${experiment.name})`, error);
|
|
145
|
-
}
|
|
110
|
+
});
|
|
111
|
+
await handleResNotOk(resultsRes);
|
|
112
|
+
const resultsData = await resultsRes.json();
|
|
113
|
+
experiments[index].result = resultsData.result;
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
console.error(`Error fetching results for experiment ${experiment.id} (${experiment.name})`, error);
|
|
146
117
|
}
|
|
147
118
|
}
|
|
148
119
|
}
|
|
149
120
|
if (mode === "summary") {
|
|
150
|
-
const experiments = Array.isArray(mostRecentData.experiments)
|
|
151
|
-
? mostRecentData.experiments
|
|
152
|
-
: [];
|
|
153
121
|
const summaryExperiments = await handleSummaryMode(experiments, baseApiUrl, apiKey, reportProgress);
|
|
154
122
|
const summaryExperimentsWithPagination = {
|
|
155
123
|
summary: summaryExperiments,
|
|
156
|
-
limit:
|
|
157
|
-
offset:
|
|
158
|
-
total:
|
|
159
|
-
hasMore:
|
|
160
|
-
nextOffset:
|
|
124
|
+
limit: data.limit,
|
|
125
|
+
offset: data.offset,
|
|
126
|
+
total: data.total,
|
|
127
|
+
hasMore: data.hasMore,
|
|
128
|
+
nextOffset: data.nextOffset,
|
|
161
129
|
};
|
|
162
130
|
return {
|
|
163
131
|
content: [
|
|
@@ -168,73 +136,25 @@ export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin,
|
|
|
168
136
|
],
|
|
169
137
|
};
|
|
170
138
|
}
|
|
139
|
+
await reportProgress(2, "Processing results...");
|
|
171
140
|
return {
|
|
172
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
141
|
+
content: [{ type: "text", text: JSON.stringify(data) }],
|
|
173
142
|
};
|
|
174
143
|
}
|
|
175
144
|
catch (error) {
|
|
176
145
|
throw new Error(`Error fetching experiments: ${error}`);
|
|
177
146
|
}
|
|
178
147
|
});
|
|
179
|
-
/**
|
|
180
|
-
* Tool: get_experiment
|
|
181
|
-
*/
|
|
182
|
-
server.tool("get_experiment", "Gets a single experiment from GrowthBook", {
|
|
183
|
-
experimentId: z.string().describe("The ID of the experiment to get"),
|
|
184
|
-
mode: z
|
|
185
|
-
.enum(["metadata", "full"])
|
|
186
|
-
.default("metadata")
|
|
187
|
-
.describe("The mode to use to fetch the experiment. Metadata mode returns summary info about the experiment. Full mode fetches results and returns complete results data."),
|
|
188
|
-
}, {
|
|
189
|
-
readOnlyHint: true,
|
|
190
|
-
}, async ({ experimentId, mode }) => {
|
|
191
|
-
try {
|
|
192
|
-
const res = await fetchWithRateLimit(`${baseApiUrl}/api/v1/experiments/${experimentId}`, {
|
|
193
|
-
headers: {
|
|
194
|
-
Authorization: `Bearer ${apiKey}`,
|
|
195
|
-
"Content-Type": "application/json",
|
|
196
|
-
},
|
|
197
|
-
});
|
|
198
|
-
await handleResNotOk(res);
|
|
199
|
-
const data = await res.json();
|
|
200
|
-
// If analyze or summary mode, fetch results
|
|
201
|
-
if (mode === "full") {
|
|
202
|
-
if (data.status === "draft") {
|
|
203
|
-
data.result = null;
|
|
204
|
-
}
|
|
205
|
-
try {
|
|
206
|
-
const resultsRes = await fetchWithRateLimit(`${baseApiUrl}/api/v1/experiments/${experimentId}/results`, {
|
|
207
|
-
headers: {
|
|
208
|
-
Authorization: `Bearer ${apiKey}`,
|
|
209
|
-
},
|
|
210
|
-
});
|
|
211
|
-
await handleResNotOk(resultsRes);
|
|
212
|
-
const resultsData = await resultsRes.json();
|
|
213
|
-
data.result = resultsData.result;
|
|
214
|
-
}
|
|
215
|
-
catch (error) {
|
|
216
|
-
console.error(`Error fetching results for experiment ${experimentId}`, error);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
const linkToGrowthBook = generateLinkToGrowthBook(appOrigin, "experiment", experimentId);
|
|
220
|
-
const text = `
|
|
221
|
-
${JSON.stringify(data)}
|
|
222
|
-
|
|
223
|
-
[View the experiment in GrowthBook](${linkToGrowthBook})
|
|
224
|
-
`;
|
|
225
|
-
return {
|
|
226
|
-
content: [{ type: "text", text }],
|
|
227
|
-
};
|
|
228
|
-
}
|
|
229
|
-
catch (error) {
|
|
230
|
-
throw new Error(`Error getting experiment: ${error}`);
|
|
231
|
-
}
|
|
232
|
-
});
|
|
233
148
|
/**
|
|
234
149
|
* Tool: get_attributes
|
|
235
150
|
*/
|
|
236
|
-
server.
|
|
237
|
-
|
|
151
|
+
server.registerTool("get_attributes", {
|
|
152
|
+
title: "Get Attributes",
|
|
153
|
+
description: "Lists all user attributes configured in GrowthBook. Attributes are user properties (like country, plan type, user ID) used for targeting in feature flags and experiments. Use this to see available attributes for targeting conditions in create_force_rule, understand targeting options when setting up experiments, or verify attribute names before writing conditions. Common examples: id, email, country, plan, deviceType, isEmployee. Attributes must be passed to the GrowthBook SDK at runtime for targeting to work.",
|
|
154
|
+
inputSchema: z.object({}),
|
|
155
|
+
annotations: {
|
|
156
|
+
readOnlyHint: true,
|
|
157
|
+
},
|
|
238
158
|
}, async () => {
|
|
239
159
|
try {
|
|
240
160
|
const queryParams = new URLSearchParams();
|
|
@@ -258,44 +178,55 @@ export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin,
|
|
|
258
178
|
/**
|
|
259
179
|
* Tool: create_experiment
|
|
260
180
|
*/
|
|
261
|
-
server.
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
description: z.string().optional().describe("Experiment description."),
|
|
266
|
-
hypothesis: z
|
|
267
|
-
.string()
|
|
268
|
-
.optional()
|
|
269
|
-
.describe("Experiment hypothesis. Base hypothesis off the examples from get_defaults. If none are available, use a falsifiable statement about what will happen if the experiment succeeds or fails."),
|
|
270
|
-
variations: z
|
|
271
|
-
.array(z.object({
|
|
181
|
+
server.registerTool("create_experiment", {
|
|
182
|
+
title: "Create Experiment",
|
|
183
|
+
description: "Creates a new A/B test experiment in GrowthBook. An experiment randomly assigns users to different variations and measures which performs better against your metrics. Prerequisites: 1) Call get_defaults first to review naming conventions and configuration, 2) If testing via a feature flag, provide its featureId OR create the flag first using create_feature_flag. Returns a draft experiment that the user must review and launch in the GrowthBook UI, including a link and SDK integration code. Do NOT use for simple feature toggles (use create_feature_flag) or targeting without measurement (use create_force_rule).",
|
|
184
|
+
inputSchema: z.object({
|
|
272
185
|
name: z
|
|
273
186
|
.string()
|
|
274
|
-
.describe("
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
.describe("The value
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
187
|
+
.describe("Experiment name. Base name off the examples from get_defaults. If none are available, use a short, descriptive name that captures the essence of the experiment."),
|
|
188
|
+
description: z.string().optional().describe("Experiment description."),
|
|
189
|
+
hypothesis: z
|
|
190
|
+
.string()
|
|
191
|
+
.optional()
|
|
192
|
+
.describe("Experiment hypothesis. Base hypothesis off the examples from get_defaults. If none are available, use a falsifiable statement about what will happen if the experiment succeeds or fails."),
|
|
193
|
+
valueType: z
|
|
194
|
+
.enum(["string", "number", "boolean", "json"])
|
|
195
|
+
.describe("The value type for all experiment variations"),
|
|
196
|
+
variations: z
|
|
197
|
+
.array(z.object({
|
|
198
|
+
name: z
|
|
199
|
+
.string()
|
|
200
|
+
.describe("Variation name. Base name off the examples from get_defaults. If none are available, use a short, descriptive name that captures the essence of the variation."),
|
|
201
|
+
value: z
|
|
202
|
+
.union([
|
|
203
|
+
z.string(),
|
|
204
|
+
z.number(),
|
|
205
|
+
z.boolean(),
|
|
206
|
+
z.record(z.string(), z.any()),
|
|
207
|
+
])
|
|
208
|
+
.describe("The value of this variation. Must match the specified valueType: provide actual booleans (true/false) not strings, actual numbers, strings, or valid JSON objects."),
|
|
209
|
+
}))
|
|
210
|
+
.describe('Array of experiment variations. Each has a name (displayed in GrowthBook UI) and value (what users receive). The first variation should be the control/default. Example: [{name: "Control", value: false}, {name: "Treatment", value: true}]'),
|
|
211
|
+
project: z
|
|
212
|
+
.string()
|
|
213
|
+
.describe("The ID of the project to create the experiment in")
|
|
214
|
+
.optional(),
|
|
215
|
+
featureId: featureFlagSchema.id
|
|
216
|
+
.optional()
|
|
217
|
+
.describe("The ID of the feature flag to create the experiment on."),
|
|
218
|
+
fileExtension: z
|
|
219
|
+
.enum(SUPPORTED_FILE_EXTENSIONS)
|
|
220
|
+
.describe("The extension of the current file. If it's unclear, ask the user."),
|
|
221
|
+
confirmedDefaultsReviewed: z
|
|
222
|
+
.boolean()
|
|
223
|
+
.describe("Set to true to confirm you have called get_defaults and reviewed the output to guide these parameters."),
|
|
224
|
+
}),
|
|
225
|
+
annotations: {
|
|
226
|
+
readOnlyHint: false,
|
|
227
|
+
destructiveHint: false,
|
|
228
|
+
},
|
|
229
|
+
}, async ({ description, hypothesis, name, valueType, variations, fileExtension, confirmedDefaultsReviewed, project, featureId, }) => {
|
|
299
230
|
if (!confirmedDefaultsReviewed) {
|
|
300
231
|
return {
|
|
301
232
|
content: [
|
|
@@ -335,65 +266,42 @@ export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin,
|
|
|
335
266
|
});
|
|
336
267
|
await handleResNotOk(experimentRes);
|
|
337
268
|
const experimentData = await experimentRes.json();
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
:
|
|
346
|
-
|
|
347
|
-
:
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
],
|
|
365
|
-
};
|
|
366
|
-
return acc;
|
|
367
|
-
}, {}),
|
|
368
|
-
},
|
|
369
|
-
};
|
|
370
|
-
const flagRes = await fetchWithRateLimit(`${baseApiUrl}/api/v1/features`, {
|
|
371
|
-
method: "POST",
|
|
372
|
-
headers: {
|
|
373
|
-
Authorization: `Bearer ${apiKey}`,
|
|
374
|
-
"Content-Type": "application/json",
|
|
375
|
-
},
|
|
376
|
-
body: JSON.stringify(flagPayload),
|
|
377
|
-
});
|
|
378
|
-
await handleResNotOk(flagRes);
|
|
379
|
-
const flagData = await flagRes.json();
|
|
269
|
+
let flagData = null;
|
|
270
|
+
if (featureId) {
|
|
271
|
+
// Fetch the existing feature flag first to preserve existing rules
|
|
272
|
+
const existingFeature = await fetchFeatureFlag(baseApiUrl, apiKey, featureId);
|
|
273
|
+
// Create new experiment-ref rule
|
|
274
|
+
const newRule = {
|
|
275
|
+
type: "experiment-ref",
|
|
276
|
+
experimentId: experimentData.experiment.id,
|
|
277
|
+
variations: experimentData.experiment.variations.map((expVariation, idx) => ({
|
|
278
|
+
value: stringifyValue(variations[idx].value),
|
|
279
|
+
variationId: expVariation.variationId,
|
|
280
|
+
})),
|
|
281
|
+
};
|
|
282
|
+
// Merge new rule into existing feature flag
|
|
283
|
+
const flagPayload = mergeRuleIntoFeatureFlag(existingFeature, newRule, experimentDefaults.environments);
|
|
284
|
+
const flagRes = await fetchWithRateLimit(`${baseApiUrl}/api/v1/features/${featureId}`, {
|
|
285
|
+
method: "POST",
|
|
286
|
+
headers: {
|
|
287
|
+
Authorization: `Bearer ${apiKey}`,
|
|
288
|
+
"Content-Type": "application/json",
|
|
289
|
+
},
|
|
290
|
+
body: JSON.stringify(flagPayload),
|
|
291
|
+
});
|
|
292
|
+
await handleResNotOk(flagRes);
|
|
293
|
+
flagData = await flagRes.json();
|
|
294
|
+
}
|
|
380
295
|
const experimentLink = generateLinkToGrowthBook(appOrigin, "experiment", experimentData.experiment.id);
|
|
381
|
-
const flagLink = generateLinkToGrowthBook(appOrigin, "features", flagData.feature.id);
|
|
382
296
|
const { stub, docs, language } = getDocsMetadata(fileExtension);
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
A linked feature flag was also created: \`${flagId}\`. [View the feature flag in GrowthBook](${flagLink}).
|
|
388
|
-
|
|
389
|
-
**How to use it in your code:**
|
|
390
|
-
(To the LLM agent. GrowthBook uses feature flags to implement experiments. Use the code snippets below to properly update the file to use the new feature flag)
|
|
391
|
-
|
|
297
|
+
const flagText = featureId &&
|
|
298
|
+
`**How to implement the feature flag experiment in your code:**
|
|
299
|
+
---
|
|
392
300
|
${stub}
|
|
393
|
-
|
|
301
|
+
---
|
|
394
302
|
**Learn more about implementing experiments in your codebase:**
|
|
395
|
-
See the [GrowthBook ${language} docs](${docs})
|
|
396
|
-
|
|
303
|
+
See the [GrowthBook ${language} docs](${docs}).`;
|
|
304
|
+
const text = `**✅ Your draft experiment \`${name}\` is ready!.** [View the experiment in GrowthBook](${experimentLink}) to review and launch.\n\n${flagText}`;
|
|
397
305
|
return {
|
|
398
306
|
content: [{ type: "text", text }],
|
|
399
307
|
};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
export function median(arr) {
|
|
2
|
+
if (arr.length === 0)
|
|
3
|
+
return null;
|
|
4
|
+
const sorted = [...arr].sort((a, b) => a - b);
|
|
5
|
+
const mid = Math.floor(sorted.length / 2);
|
|
6
|
+
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
7
|
+
}
|
|
8
|
+
export function round(n, decimals = 4) {
|
|
9
|
+
if (n === null || n === undefined || isNaN(n))
|
|
10
|
+
return null;
|
|
11
|
+
return Math.round(n * 10 ** decimals) / 10 ** decimals;
|
|
12
|
+
}
|
|
13
|
+
export function formatLift(lift) {
|
|
14
|
+
if (lift === null)
|
|
15
|
+
return "N/A";
|
|
16
|
+
const sign = lift >= 0 ? "+" : "";
|
|
17
|
+
return `${sign}${(lift * 100).toFixed(1)}%`;
|
|
18
|
+
}
|
|
19
|
+
export function getYearMonth(dateStr) {
|
|
20
|
+
if (!dateStr)
|
|
21
|
+
return null;
|
|
22
|
+
const d = new Date(dateStr);
|
|
23
|
+
if (isNaN(d.getTime()))
|
|
24
|
+
return null;
|
|
25
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
|
26
|
+
}
|
|
27
|
+
export function computePrimaryMetricResult(resultData, metricLookup, goalIds) {
|
|
28
|
+
const primaryMetricId = goalIds[0];
|
|
29
|
+
if (!primaryMetricId)
|
|
30
|
+
return null;
|
|
31
|
+
const primaryMetricData = resultData.metrics?.find((m) => m.metricId === primaryMetricId);
|
|
32
|
+
if (!primaryMetricData || primaryMetricData.variations.length <= 1) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const metricInfo = metricLookup.get(primaryMetricId);
|
|
36
|
+
const isInverse = metricInfo?.inverse ?? false;
|
|
37
|
+
// Find best performing variation (excluding control at index 0)
|
|
38
|
+
let bestVariation = primaryMetricData.variations[1];
|
|
39
|
+
let bestLift = bestVariation?.analyses?.[0]?.percentChange ?? 0;
|
|
40
|
+
for (let i = 2; i < primaryMetricData.variations.length; i++) {
|
|
41
|
+
const v = primaryMetricData.variations[i];
|
|
42
|
+
const lift = v.analyses?.[0]?.percentChange ?? 0;
|
|
43
|
+
const isBetter = isInverse ? lift < bestLift : lift > bestLift;
|
|
44
|
+
if (isBetter) {
|
|
45
|
+
bestVariation = v;
|
|
46
|
+
bestLift = lift;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const analysis = bestVariation?.analyses?.[0];
|
|
50
|
+
if (!analysis)
|
|
51
|
+
return null;
|
|
52
|
+
const lift = analysis.percentChange;
|
|
53
|
+
const chanceToBeatControl = analysis.chanceToBeatControl;
|
|
54
|
+
let significant = false;
|
|
55
|
+
if (chanceToBeatControl !== undefined) {
|
|
56
|
+
significant = chanceToBeatControl > 0.95 || chanceToBeatControl < 0.05;
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
significant =
|
|
60
|
+
analysis.ciLow !== undefined &&
|
|
61
|
+
analysis.ciHigh !== undefined &&
|
|
62
|
+
(analysis.ciLow > 0 || analysis.ciHigh < 0);
|
|
63
|
+
}
|
|
64
|
+
let direction = "flat";
|
|
65
|
+
if (significant) {
|
|
66
|
+
const rawPositive = lift > 0;
|
|
67
|
+
const isWinning = isInverse ? !rawPositive : rawPositive;
|
|
68
|
+
direction = isWinning ? "winning" : "losing";
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
id: primaryMetricId,
|
|
72
|
+
name: metricInfo?.name || primaryMetricId,
|
|
73
|
+
lift: round(lift),
|
|
74
|
+
significant,
|
|
75
|
+
direction,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
export function computeVerdict(exp, metricLookup) {
|
|
79
|
+
const resultData = exp.result?.results?.[0];
|
|
80
|
+
const srmPValue = resultData?.checks?.srm ?? null;
|
|
81
|
+
const totalUsers = resultData?.totalUsers || 0;
|
|
82
|
+
const srmPassing = srmPValue !== null ? srmPValue > 0.001 : true;
|
|
83
|
+
// Get goal and guardrail metric IDs
|
|
84
|
+
const goalIds = exp.settings?.goals?.map((g) => g.metricId) || [];
|
|
85
|
+
const guardrailIds = new Set(exp.settings?.guardrails?.map((g) => g.metricId) || []);
|
|
86
|
+
// Check guardrail regression
|
|
87
|
+
const guardrailsRegressed = resultData
|
|
88
|
+
? (resultData.metrics || [])
|
|
89
|
+
.filter((m) => guardrailIds.has(m.metricId))
|
|
90
|
+
.some((m) => {
|
|
91
|
+
const metricInfo = metricLookup.get(m.metricId);
|
|
92
|
+
const isInverse = metricInfo?.inverse ?? false;
|
|
93
|
+
return m.variations.slice(1).some((v) => {
|
|
94
|
+
const analysis = v.analyses?.[0];
|
|
95
|
+
if (!analysis)
|
|
96
|
+
return false;
|
|
97
|
+
if (analysis.chanceToBeatControl !== undefined) {
|
|
98
|
+
return isInverse
|
|
99
|
+
? analysis.chanceToBeatControl > 0.95
|
|
100
|
+
: analysis.chanceToBeatControl < 0.05;
|
|
101
|
+
}
|
|
102
|
+
return isInverse
|
|
103
|
+
? (analysis.ciLow ?? 0) > 0
|
|
104
|
+
: (analysis.ciHigh ?? 0) < 0;
|
|
105
|
+
});
|
|
106
|
+
})
|
|
107
|
+
: false;
|
|
108
|
+
// Verdict: Match GrowthBook's ExperimentWinRate.tsx exactly
|
|
109
|
+
// exp.results maps to resultSummary.status in the API
|
|
110
|
+
const userResult = exp.resultSummary?.status?.toLowerCase() || "";
|
|
111
|
+
let verdict;
|
|
112
|
+
if (userResult === "won") {
|
|
113
|
+
verdict = "won";
|
|
114
|
+
}
|
|
115
|
+
else if (userResult === "lost") {
|
|
116
|
+
verdict = "lost";
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
// Everything else is "inconclusive": dnf, inconclusive, undefined, null, ""
|
|
120
|
+
verdict = "inconclusive";
|
|
121
|
+
}
|
|
122
|
+
// Compute primary metric result for display
|
|
123
|
+
const primaryMetricResult = resultData
|
|
124
|
+
? computePrimaryMetricResult(resultData, metricLookup, goalIds)
|
|
125
|
+
: null;
|
|
126
|
+
return {
|
|
127
|
+
verdict,
|
|
128
|
+
primaryMetricResult,
|
|
129
|
+
guardrailsRegressed,
|
|
130
|
+
srmPassing,
|
|
131
|
+
srmPValue,
|
|
132
|
+
totalUsers,
|
|
133
|
+
};
|
|
134
|
+
}
|