@growthbook/mcp 1.5.1 → 1.7.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/README.md +1 -1
- package/package.json +2 -2
- package/server/api-type-helpers.js +1 -0
- package/server/format-responses.js +540 -0
- package/server/tools/defaults.js +20 -30
- package/server/tools/environments.js +8 -8
- package/server/tools/experiments/experiment-summary.js +4 -10
- package/server/tools/experiments/experiments.js +73 -63
- package/server/tools/features.js +77 -106
- package/server/tools/metrics.js +18 -23
- package/server/tools/projects.js +6 -4
- package/server/tools/sdk-connections.js +26 -20
- package/server/utils.js +66 -16
package/README.md
CHANGED
|
@@ -17,6 +17,6 @@ Use the following env variables to configure the MCP server.
|
|
|
17
17
|
| GB_EMAIL | Required | Your email address used with GrowthBook. Used when creating feature flags and experiments.|
|
|
18
18
|
| GB_API_URL | Optional | Your GrowthBook API URL. Defaults to `https://api.growthbook.io`. |
|
|
19
19
|
| GB_APP_ORIGIN | Optional | Your GrowthBook app URL Defaults to `https://app.growthbook.io`. |
|
|
20
|
-
|
|
20
|
+
| GB_HTTP_HEADER_* | Optional | Custom HTTP headers to include in all GrowthBook API requests. Use the pattern `GB_HTTP_HEADER_<NAME>` where `<NAME>` is converted to proper HTTP header format (underscores become hyphens). Examples: `GB_HTTP_HEADER_X_TENANT_ID=abc123` becomes `X-Tenant-ID: abc123`, `GB_HTTP_HEADER_CF_ACCESS_TOKEN=<token>` becomes `Cf-Access-Token: <token>`. Multiple custom headers can be configured. |
|
|
21
21
|
|
|
22
22
|
Add the MCP server to your AI tool of choice. See the [official docs](https://docs.growthbook.io/integrations/mcp) for complete a complete guide.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@growthbook/mcp",
|
|
3
3
|
"mcpName": "io.github.growthbook/growthbook-mcp",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.7.0",
|
|
5
5
|
"description": "MCP Server for interacting with GrowthBook",
|
|
6
6
|
"access": "public",
|
|
7
7
|
"homepage": "https://github.com/growthbook/growthbook-mcp",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"bump:minor": "npm version minor --no-git-tag-version && npm run sync-version",
|
|
21
21
|
"bump:major": "npm version major --no-git-tag-version && npm run sync-version",
|
|
22
22
|
"mcpb:build": "npx -y @anthropic-ai/mcpb -- pack",
|
|
23
|
-
"generate-api-types": "npx openapi-typescript https://api.growthbook.io/api/v1/openapi.yaml -o src/api-types.ts"
|
|
23
|
+
"generate-api-types": "npx openapi-typescript https://api.growthbook.io/api/v1/openapi.yaml -o src/api-types.d.ts"
|
|
24
24
|
},
|
|
25
25
|
"bin": {
|
|
26
26
|
"mcp": "server/index.js"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
import { formatList, generateLinkToGrowthBook } from "./utils.js";
|
|
2
|
+
// Helper to resolve a metric ID to a display name using an optional lookup
|
|
3
|
+
function resolveMetric(metricId, metricLookup) {
|
|
4
|
+
if (!metricLookup)
|
|
5
|
+
return `\`${metricId}\``;
|
|
6
|
+
const info = metricLookup.get(metricId);
|
|
7
|
+
if (!info)
|
|
8
|
+
return `\`${metricId}\``;
|
|
9
|
+
const inverse = info.inverse ? " (inverse)" : "";
|
|
10
|
+
return `**${info.name}** (\`${metricId}\`, ${info.type}${inverse})`;
|
|
11
|
+
}
|
|
12
|
+
function resolveMetricList(metrics, metricLookup) {
|
|
13
|
+
if (!metrics?.length)
|
|
14
|
+
return "none";
|
|
15
|
+
return metrics.map((g) => resolveMetric(g.metricId, metricLookup)).join(", ");
|
|
16
|
+
}
|
|
17
|
+
// ─── Projects ───────────────────────────────────────────────────────
|
|
18
|
+
export function formatProjects(data) {
|
|
19
|
+
const projects = data.projects || [];
|
|
20
|
+
if (projects.length === 0) {
|
|
21
|
+
return "No projects found. Features and experiments will be created without a project scope.";
|
|
22
|
+
}
|
|
23
|
+
const lines = projects.map((p) => {
|
|
24
|
+
const parts = [`- **${p.name}** (id: \`${p.id}\`)`];
|
|
25
|
+
if (p.description)
|
|
26
|
+
parts.push(` ${p.description}`);
|
|
27
|
+
return parts.join("\n");
|
|
28
|
+
});
|
|
29
|
+
return [
|
|
30
|
+
`**${projects.length} project(s):**`,
|
|
31
|
+
"",
|
|
32
|
+
...lines,
|
|
33
|
+
"",
|
|
34
|
+
`Use the \`id\` value when creating feature flags or experiments scoped to a project.`,
|
|
35
|
+
].join("\n");
|
|
36
|
+
}
|
|
37
|
+
// ─── Environments ───────────────────────────────────────────────────
|
|
38
|
+
export function formatEnvironments(data) {
|
|
39
|
+
const environments = data.environments || [];
|
|
40
|
+
if (environments.length === 0) {
|
|
41
|
+
return "No environments found. At least one environment (production) should exist.";
|
|
42
|
+
}
|
|
43
|
+
const lines = environments.map((e) => {
|
|
44
|
+
const parts = [`- **${e.id}**`];
|
|
45
|
+
if (e.description)
|
|
46
|
+
parts.push(`: ${e.description}`);
|
|
47
|
+
if (e.toggleOnList)
|
|
48
|
+
parts.push(" (toggle on by default)");
|
|
49
|
+
if (e.defaultState === false)
|
|
50
|
+
parts.push(" (disabled by default)");
|
|
51
|
+
return parts.join("");
|
|
52
|
+
});
|
|
53
|
+
return [`**${environments.length} environment(s):**`, "", ...lines].join("\n");
|
|
54
|
+
}
|
|
55
|
+
// ─── Attributes ─────────────────────────────────────────────────────
|
|
56
|
+
export function formatAttributes(data) {
|
|
57
|
+
const attributes = data.attributes || [];
|
|
58
|
+
if (attributes.length === 0) {
|
|
59
|
+
return "No targeting attributes configured. Attributes (like country, plan, userId) must be set up in GrowthBook before they can be used in targeting conditions.";
|
|
60
|
+
}
|
|
61
|
+
const lines = attributes.map((a) => {
|
|
62
|
+
return `- **${a.property}** (${a.datatype}${a.hashAttribute ? ", hash attribute" : ""})`;
|
|
63
|
+
});
|
|
64
|
+
return [
|
|
65
|
+
`**${attributes.length} attribute(s) available for targeting:**`,
|
|
66
|
+
"",
|
|
67
|
+
...lines,
|
|
68
|
+
"",
|
|
69
|
+
`These can be used in targeting conditions (e.g. \`{"${attributes[0]?.property}": "value"}\`).`,
|
|
70
|
+
].join("\n");
|
|
71
|
+
}
|
|
72
|
+
// ─── SDK Connections ────────────────────────────────────────────────
|
|
73
|
+
export function formatSdkConnections(data) {
|
|
74
|
+
const connections = data.connections || [];
|
|
75
|
+
if (connections.length === 0) {
|
|
76
|
+
return "No SDK connections found. Use create_sdk_connection to create one for your app.";
|
|
77
|
+
}
|
|
78
|
+
const lines = connections.map((c) => {
|
|
79
|
+
return `**${c.name}**:
|
|
80
|
+
- Languages: ${formatList(c.languages)}
|
|
81
|
+
- Environment: ${c.environment}
|
|
82
|
+
- Client Key: \`${c.key}\`
|
|
83
|
+
- Projects: ${formatList(c.projects || [])}`;
|
|
84
|
+
});
|
|
85
|
+
return [`**${connections.length} SDK connection(s):**`, "", ...lines].join("\n");
|
|
86
|
+
}
|
|
87
|
+
// ─── Feature Flags ──────────────────────────────────────────────────
|
|
88
|
+
export function formatFeatureFlagList(data) {
|
|
89
|
+
const features = data.features || [];
|
|
90
|
+
if (features.length === 0) {
|
|
91
|
+
return "No feature flags found. Use create_feature_flag to create one.";
|
|
92
|
+
}
|
|
93
|
+
const lines = features.map((f) => {
|
|
94
|
+
const envStatus = f.environments
|
|
95
|
+
? Object.entries(f.environments)
|
|
96
|
+
.map(([env, config]) => `${env}: ${config.enabled ? "ON" : "OFF"}${config.rules?.length
|
|
97
|
+
? ` (${config.rules.length} rule${config.rules.length > 1 ? "s" : ""})`
|
|
98
|
+
: ""}`)
|
|
99
|
+
.join(", ")
|
|
100
|
+
: "no environments";
|
|
101
|
+
const archived = f.archived ? " [ARCHIVED]" : "";
|
|
102
|
+
return `- **${f.id}** (${f.valueType}) — default: \`${f.defaultValue}\`${archived}\n Environments: ${envStatus}${f.project ? `\n Project: ${f.project}` : ""}`;
|
|
103
|
+
});
|
|
104
|
+
const pagination = data.hasMore
|
|
105
|
+
? `\n\nShowing ${features.length} of ${data.total}. Use offset=${data.nextOffset} to see more.`
|
|
106
|
+
: "";
|
|
107
|
+
return [
|
|
108
|
+
`**${features.length} feature flag(s):**`,
|
|
109
|
+
"",
|
|
110
|
+
...lines,
|
|
111
|
+
pagination,
|
|
112
|
+
].join("\n");
|
|
113
|
+
}
|
|
114
|
+
export function formatFeatureFlagDetail(data, appOrigin) {
|
|
115
|
+
const f = data.feature;
|
|
116
|
+
if (!f)
|
|
117
|
+
return "Feature flag not found.";
|
|
118
|
+
const formatRule = (r, i) => {
|
|
119
|
+
const disabledTag = r.enabled === false ? " [DISABLED]" : "";
|
|
120
|
+
const desc = r.description ? ` — ${r.description}` : "";
|
|
121
|
+
const condition = r.condition ? `\n Condition: ${r.condition}` : "";
|
|
122
|
+
const savedGroups = r.savedGroupTargeting?.length
|
|
123
|
+
? `\n Saved groups: ${r.savedGroupTargeting.map((sg) => `${sg.matchType} of [${sg.savedGroups.join(", ")}]`).join("; ")}`
|
|
124
|
+
: "";
|
|
125
|
+
const schedule = r.scheduleRules?.length
|
|
126
|
+
? `\n Schedule: ${r.scheduleRules.map((sr) => `${sr.enabled ? "enable" : "disable"} at ${sr.timestamp || "immediately"}`).join(", ")}`
|
|
127
|
+
: "";
|
|
128
|
+
const prerequisites = r.prerequisites?.length
|
|
129
|
+
? `\n Prerequisites: ${r.prerequisites.map((p) => p.id).join(", ")}`
|
|
130
|
+
: "";
|
|
131
|
+
if (r.type === "force") {
|
|
132
|
+
return ` ${i + 1}. Force rule${disabledTag}: value=\`${r.value}\`${desc}${condition}${savedGroups}${schedule}${prerequisites}`;
|
|
133
|
+
}
|
|
134
|
+
if (r.type === "rollout") {
|
|
135
|
+
return ` ${i + 1}. Rollout rule${disabledTag}: value=\`${r.value}\`, coverage=${r.coverage}, hashAttribute=${r.hashAttribute || "id"}${desc}${condition}${savedGroups}${schedule}`;
|
|
136
|
+
}
|
|
137
|
+
if (r.type === "experiment-ref") {
|
|
138
|
+
const variations = r.variations?.length
|
|
139
|
+
? `\n Variations: ${r.variations.map((v) => `${v.variationId}=\`${v.value}\``).join(", ")}`
|
|
140
|
+
: "";
|
|
141
|
+
return ` ${i + 1}. Experiment rule${disabledTag}: experimentId=\`${r.experimentId}\`${desc}${condition}${variations}${schedule}`;
|
|
142
|
+
}
|
|
143
|
+
if (r.type === "experiment") {
|
|
144
|
+
const trackingKey = r.trackingKey ? `, trackingKey=\`${r.trackingKey}\`` : "";
|
|
145
|
+
const coverage = r.coverage != null ? `, coverage=${r.coverage}` : "";
|
|
146
|
+
return ` ${i + 1}. Inline experiment${disabledTag}${trackingKey}${coverage}${desc}${condition}${savedGroups}${schedule}`;
|
|
147
|
+
}
|
|
148
|
+
if (r.type === "safe-rollout") {
|
|
149
|
+
return ` ${i + 1}. Safe rollout${disabledTag}: status=${r.status || "running"}, control=\`${r.controlValue}\`, variation=\`${r.variationValue}\`${condition}${savedGroups}${prerequisites}`;
|
|
150
|
+
}
|
|
151
|
+
return ` ${i + 1}. ${r.type} rule${disabledTag}${desc}`;
|
|
152
|
+
};
|
|
153
|
+
const envLines = f.environments
|
|
154
|
+
? Object.entries(f.environments).map(([env, config]) => {
|
|
155
|
+
const status = config.enabled ? "ON" : "OFF";
|
|
156
|
+
const rules = config.rules || [];
|
|
157
|
+
const rulesSummary = rules.length > 0
|
|
158
|
+
? rules.map(formatRule).join("\n")
|
|
159
|
+
: " (no rules)";
|
|
160
|
+
return ` **${env}**: ${status}\n${rulesSummary}`;
|
|
161
|
+
})
|
|
162
|
+
: [];
|
|
163
|
+
const link = generateLinkToGrowthBook(appOrigin, "features", f.id);
|
|
164
|
+
const archived = f.archived
|
|
165
|
+
? "\n**This flag is ARCHIVED.** Consider removing it from the codebase."
|
|
166
|
+
: "";
|
|
167
|
+
const tags = f.tags?.length ? `Tags: ${f.tags.join(", ")}` : "";
|
|
168
|
+
const prereqs = f.prerequisites?.length
|
|
169
|
+
? `Prerequisites: ${f.prerequisites.join(", ")}`
|
|
170
|
+
: "";
|
|
171
|
+
return [
|
|
172
|
+
`**Feature flag: \`${f.id}\`**${archived}`,
|
|
173
|
+
`Type: ${f.valueType} | Default: \`${f.defaultValue}\` | Owner: ${f.owner || "unset"}${f.project ? ` | Project: ${f.project}` : ""}`,
|
|
174
|
+
f.description ? `Description: ${f.description}` : "",
|
|
175
|
+
tags,
|
|
176
|
+
prereqs,
|
|
177
|
+
"",
|
|
178
|
+
"**Environments:**",
|
|
179
|
+
...envLines,
|
|
180
|
+
"",
|
|
181
|
+
`[View in GrowthBook](${link})`,
|
|
182
|
+
]
|
|
183
|
+
.filter(Boolean)
|
|
184
|
+
.join("\n");
|
|
185
|
+
}
|
|
186
|
+
// ─── Feature Flag Creation / Update ─────────────────────────────────
|
|
187
|
+
export function formatFeatureFlagCreated(data, appOrigin, sdkStub, language, docsUrl) {
|
|
188
|
+
const f = data.feature;
|
|
189
|
+
const id = f?.id || "unknown";
|
|
190
|
+
const link = generateLinkToGrowthBook(appOrigin, "features", id);
|
|
191
|
+
return [
|
|
192
|
+
`**Feature flag \`${id}\` created.**`,
|
|
193
|
+
`[View in GrowthBook](${link})`,
|
|
194
|
+
"",
|
|
195
|
+
"**SDK integration:**",
|
|
196
|
+
sdkStub,
|
|
197
|
+
"",
|
|
198
|
+
`[${language} docs](${docsUrl})`,
|
|
199
|
+
].join("\n");
|
|
200
|
+
}
|
|
201
|
+
export function formatForceRuleCreated(data, appOrigin, featureId, sdkStub, language, docsUrl) {
|
|
202
|
+
const link = generateLinkToGrowthBook(appOrigin, "features", featureId);
|
|
203
|
+
return [
|
|
204
|
+
`**Targeting rule added to \`${featureId}\`.**`,
|
|
205
|
+
`[View in GrowthBook](${link})`,
|
|
206
|
+
"",
|
|
207
|
+
"**SDK integration:**",
|
|
208
|
+
sdkStub,
|
|
209
|
+
"",
|
|
210
|
+
`[${language} docs](${docsUrl})`,
|
|
211
|
+
].join("\n");
|
|
212
|
+
}
|
|
213
|
+
// ─── Experiments ────────────────────────────────────────────────────
|
|
214
|
+
export function formatExperimentList(data) {
|
|
215
|
+
const experiments = data.experiments || [];
|
|
216
|
+
if (experiments.length === 0) {
|
|
217
|
+
return "No experiments found. Use create_experiment to create one.";
|
|
218
|
+
}
|
|
219
|
+
const lines = experiments.map((e) => {
|
|
220
|
+
const status = e.status || "unknown";
|
|
221
|
+
const variations = e.variations
|
|
222
|
+
? e.variations.map((v) => v.name).join(" vs ")
|
|
223
|
+
: "no variations";
|
|
224
|
+
const goalCount = e.settings?.goals?.length || 0;
|
|
225
|
+
return `- **${e.name}** (id: \`${e.id}\`, status: ${status})\n Variations: ${variations}${goalCount > 0 ? ` | Goals: ${goalCount} metric(s)` : ""}${e.project ? ` | Project: ${e.project}` : ""}`;
|
|
226
|
+
});
|
|
227
|
+
const pagination = data.hasMore
|
|
228
|
+
? `\n\nShowing ${experiments.length} of ${data.total}. Use offset=${data.nextOffset} to see more.`
|
|
229
|
+
: "";
|
|
230
|
+
return [
|
|
231
|
+
`**${experiments.length} experiment(s):**`,
|
|
232
|
+
"",
|
|
233
|
+
...lines,
|
|
234
|
+
pagination,
|
|
235
|
+
].join("\n");
|
|
236
|
+
}
|
|
237
|
+
export function formatExperimentDetail(data, appOrigin, metricLookup) {
|
|
238
|
+
const e = "experiment" in data && data.experiment
|
|
239
|
+
? data.experiment
|
|
240
|
+
: data;
|
|
241
|
+
if (!e?.id)
|
|
242
|
+
return "Experiment not found.";
|
|
243
|
+
const link = generateLinkToGrowthBook(appOrigin, "experiment", e.id);
|
|
244
|
+
const variations = e.variations
|
|
245
|
+
? e.variations
|
|
246
|
+
.map((v) => `${v.name} (key: \`${v.key}\`, variationId: \`${v.variationId}\`)`)
|
|
247
|
+
.join(", ")
|
|
248
|
+
: "none";
|
|
249
|
+
const parts = [
|
|
250
|
+
`**Experiment: ${e.name}** (id: \`${e.id}\`, status: ${e.status}, type: ${e.type || "standard"})`,
|
|
251
|
+
];
|
|
252
|
+
if (e.archived)
|
|
253
|
+
parts.push("**This experiment is ARCHIVED.**");
|
|
254
|
+
if (e.hypothesis)
|
|
255
|
+
parts.push(`Hypothesis: ${e.hypothesis}`);
|
|
256
|
+
if (e.description)
|
|
257
|
+
parts.push(`Description: ${e.description}`);
|
|
258
|
+
parts.push(`Variations: ${variations}`);
|
|
259
|
+
parts.push(`Goal metrics: ${resolveMetricList(e.settings?.goals, metricLookup)}`);
|
|
260
|
+
const secondary = resolveMetricList(e.settings?.secondaryMetrics, metricLookup);
|
|
261
|
+
if (secondary !== "none")
|
|
262
|
+
parts.push(`Secondary metrics: ${secondary}`);
|
|
263
|
+
parts.push(`Guardrail metrics: ${resolveMetricList(e.settings?.guardrails, metricLookup)}`);
|
|
264
|
+
if (e.trackingKey)
|
|
265
|
+
parts.push(`Tracking key: \`${e.trackingKey}\``);
|
|
266
|
+
if (e.hashAttribute)
|
|
267
|
+
parts.push(`Hash attribute: \`${e.hashAttribute}\``);
|
|
268
|
+
if (e.project)
|
|
269
|
+
parts.push(`Project: ${e.project}`);
|
|
270
|
+
if (e.owner)
|
|
271
|
+
parts.push(`Owner: ${e.owner}`);
|
|
272
|
+
if (e.tags?.length)
|
|
273
|
+
parts.push(`Tags: ${e.tags.join(", ")}`);
|
|
274
|
+
// Linked features
|
|
275
|
+
if (e.linkedFeatures?.length) {
|
|
276
|
+
parts.push(`Linked features: ${e.linkedFeatures.map((f) => `\`${f}\``).join(", ")}`);
|
|
277
|
+
}
|
|
278
|
+
// Result summary (if experiment has concluded)
|
|
279
|
+
if (e.resultSummary) {
|
|
280
|
+
const rs = e.resultSummary;
|
|
281
|
+
parts.push("");
|
|
282
|
+
parts.push("**Result summary:**");
|
|
283
|
+
if (rs.status)
|
|
284
|
+
parts.push(` Status: ${rs.status}`);
|
|
285
|
+
if (rs.winner)
|
|
286
|
+
parts.push(` Winner: \`${rs.winner}\``);
|
|
287
|
+
if (rs.conclusions)
|
|
288
|
+
parts.push(` Conclusions: ${rs.conclusions}`);
|
|
289
|
+
if (rs.releasedVariationId)
|
|
290
|
+
parts.push(` Released variation: \`${rs.releasedVariationId}\``);
|
|
291
|
+
}
|
|
292
|
+
// Phases (traffic allocation history)
|
|
293
|
+
if (e.phases?.length) {
|
|
294
|
+
parts.push("");
|
|
295
|
+
parts.push(`**Phases (${e.phases.length}):**`);
|
|
296
|
+
for (const [idx, phase] of e.phases.entries()) {
|
|
297
|
+
const dateRange = `${phase.dateStarted || "?"} → ${phase.dateEnded || "ongoing"}`;
|
|
298
|
+
const traffic = phase.trafficSplit?.length
|
|
299
|
+
? phase.trafficSplit.map((t) => `${t.variationId}: ${(t.weight * 100).toFixed(0)}%`).join(", ")
|
|
300
|
+
: "even split";
|
|
301
|
+
const coverageStr = phase.coverage != null ? `, coverage: ${(phase.coverage * 100).toFixed(0)}%` : "";
|
|
302
|
+
const targeting = phase.targetingCondition ? `\n Targeting: ${phase.targetingCondition}` : "";
|
|
303
|
+
parts.push(` ${idx + 1}. ${phase.name || `Phase ${idx + 1}`} (${dateRange})\n Traffic: ${traffic}${coverageStr}${targeting}`);
|
|
304
|
+
if (phase.reasonForStopping)
|
|
305
|
+
parts.push(` Stopped: ${phase.reasonForStopping}`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
// Bandit-specific settings
|
|
309
|
+
if (e.type === "multi-armed-bandit") {
|
|
310
|
+
const banditParts = [];
|
|
311
|
+
if (e.banditScheduleValue)
|
|
312
|
+
banditParts.push(`schedule: ${e.banditScheduleValue} ${e.banditScheduleUnit || "hours"}`);
|
|
313
|
+
if (e.banditBurnInValue)
|
|
314
|
+
banditParts.push(`burn-in: ${e.banditBurnInValue} ${e.banditBurnInUnit || "hours"}`);
|
|
315
|
+
if (banditParts.length)
|
|
316
|
+
parts.push(`Bandit settings: ${banditParts.join(", ")}`);
|
|
317
|
+
}
|
|
318
|
+
parts.push("");
|
|
319
|
+
parts.push(`[View in GrowthBook](${link})`);
|
|
320
|
+
return parts.join("\n");
|
|
321
|
+
}
|
|
322
|
+
export function formatExperimentCreated(experimentData, appOrigin, sdkStub, language, docsUrl) {
|
|
323
|
+
const e = experimentData.experiment;
|
|
324
|
+
const link = generateLinkToGrowthBook(appOrigin, "experiment", e.id);
|
|
325
|
+
const variations = e.variations
|
|
326
|
+
? e.variations
|
|
327
|
+
.map((v) => `${v.name} (variationId: \`${v.variationId}\`)`)
|
|
328
|
+
.join(", ")
|
|
329
|
+
: "none";
|
|
330
|
+
const parts = [
|
|
331
|
+
`**Draft experiment \`${e.name}\` created.** [Review and launch in GrowthBook](${link})`,
|
|
332
|
+
"",
|
|
333
|
+
`Variations: ${variations}`,
|
|
334
|
+
`Tracking key: \`${e.trackingKey}\``,
|
|
335
|
+
];
|
|
336
|
+
if (sdkStub) {
|
|
337
|
+
parts.push("", "**SDK integration:**", sdkStub, "", `[${language} docs](${docsUrl})`);
|
|
338
|
+
}
|
|
339
|
+
return parts.join("\n");
|
|
340
|
+
}
|
|
341
|
+
// ─── Metrics ────────────────────────────────────────────────────────
|
|
342
|
+
export function formatMetricsList(metricsData, factMetricData) {
|
|
343
|
+
const metrics = metricsData.metrics || [];
|
|
344
|
+
const factMetrics = factMetricData.factMetrics || [];
|
|
345
|
+
if (metrics.length === 0 && factMetrics.length === 0) {
|
|
346
|
+
return "No metrics found. Metrics must be created GrowthBook before they can be used in experiments.";
|
|
347
|
+
}
|
|
348
|
+
const parts = [];
|
|
349
|
+
if (factMetrics.length > 0) {
|
|
350
|
+
parts.push(`**${factMetrics.length} fact metric(s)**:`);
|
|
351
|
+
parts.push("");
|
|
352
|
+
for (const m of factMetrics) {
|
|
353
|
+
const desc = m.description ? ` — ${m.description}` : "";
|
|
354
|
+
parts.push(`- **${m.name}** (id: \`${m.id}\`)${desc}`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (metrics.length > 0) {
|
|
358
|
+
if (parts.length > 0)
|
|
359
|
+
parts.push("");
|
|
360
|
+
parts.push(`**${metrics.length} legacy metric(s):**`);
|
|
361
|
+
parts.push("");
|
|
362
|
+
for (const m of metrics) {
|
|
363
|
+
const desc = m.description ? ` — ${m.description}` : "";
|
|
364
|
+
const type = m.type ? ` [${m.type}]` : "";
|
|
365
|
+
parts.push(`- **${m.name}** (id: \`${m.id}\`)${type}${desc}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
parts.push("");
|
|
369
|
+
parts.push("Use metric `id` values when configuring experiment goals and guardrails. Fact metrics (ids starting with `fact__`) are recommended over legacy metrics.");
|
|
370
|
+
return parts.join("\n");
|
|
371
|
+
}
|
|
372
|
+
export function formatMetricDetail(data, appOrigin) {
|
|
373
|
+
const m = data.metric || data.factMetric;
|
|
374
|
+
if (!m)
|
|
375
|
+
return "Metric not found.";
|
|
376
|
+
const isFactMetric = !!data.factMetric;
|
|
377
|
+
const resource = isFactMetric ? "fact-metrics" : "metric";
|
|
378
|
+
const link = generateLinkToGrowthBook(appOrigin, resource, m.id);
|
|
379
|
+
const metricType = isFactMetric
|
|
380
|
+
? "fact metric"
|
|
381
|
+
: "type" in m
|
|
382
|
+
? m.type ?? "legacy"
|
|
383
|
+
: "legacy";
|
|
384
|
+
return [
|
|
385
|
+
`**Metric: ${m.name}** (id: \`${m.id}\`, type: ${metricType})`,
|
|
386
|
+
m.description ? `Description: ${m.description}` : "",
|
|
387
|
+
"inverse" in m && m.inverse
|
|
388
|
+
? "**Inverse metric** — lower is better"
|
|
389
|
+
: "",
|
|
390
|
+
"",
|
|
391
|
+
`[View in GrowthBook](${link})`,
|
|
392
|
+
]
|
|
393
|
+
.filter(Boolean)
|
|
394
|
+
.join("\n");
|
|
395
|
+
}
|
|
396
|
+
// ─── Defaults ───────────────────────────────────────────────────────
|
|
397
|
+
export function formatDefaults(defaults) {
|
|
398
|
+
const parts = [];
|
|
399
|
+
parts.push("**Experiment defaults:**");
|
|
400
|
+
parts.push("");
|
|
401
|
+
parts.push(`Datasource: \`${defaults.datasource || "not set"}\``);
|
|
402
|
+
parts.push(`Assignment query: \`${defaults.assignmentQuery || "not set"}\``);
|
|
403
|
+
parts.push(`Environments: ${defaults.environments?.length
|
|
404
|
+
? defaults.environments.map((e) => `\`${e}\``).join(", ")
|
|
405
|
+
: "none found"}`);
|
|
406
|
+
if (defaults.name?.length > 0) {
|
|
407
|
+
const recentNames = defaults.name.slice(-5);
|
|
408
|
+
parts.push("");
|
|
409
|
+
parts.push("**Recent experiment naming examples:**");
|
|
410
|
+
for (const name of recentNames) {
|
|
411
|
+
if (name)
|
|
412
|
+
parts.push(`- ${name}`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
if (defaults.hypothesis?.length > 0) {
|
|
416
|
+
const recentHypotheses = defaults.hypothesis.filter(Boolean).slice(-3);
|
|
417
|
+
if (recentHypotheses.length > 0) {
|
|
418
|
+
parts.push("");
|
|
419
|
+
parts.push("**Recent hypothesis examples:**");
|
|
420
|
+
for (const h of recentHypotheses) {
|
|
421
|
+
parts.push(`- ${h}`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return parts.join("\n");
|
|
426
|
+
}
|
|
427
|
+
// ─── Stale Features ─────────────────────────────────────────────────
|
|
428
|
+
// Common SDK patterns to search for when removing a flag from the codebase
|
|
429
|
+
const SDK_PATTERNS = [
|
|
430
|
+
// JS/TS/React
|
|
431
|
+
"isOn",
|
|
432
|
+
"getFeatureValue",
|
|
433
|
+
"useFeatureIsOn",
|
|
434
|
+
"useFeatureValue",
|
|
435
|
+
"evalFeature",
|
|
436
|
+
// Python
|
|
437
|
+
"is_on",
|
|
438
|
+
"get_feature_value",
|
|
439
|
+
// Go / Ruby / other
|
|
440
|
+
"IsOn",
|
|
441
|
+
"GetFeatureValue",
|
|
442
|
+
"feature_is_on",
|
|
443
|
+
];
|
|
444
|
+
function buildSearchPatterns(flagId) {
|
|
445
|
+
return SDK_PATTERNS.map((fn) => `${fn}("${flagId}")`).join(", ");
|
|
446
|
+
}
|
|
447
|
+
export function formatStaleFeatureFlags(data, requestedIds) {
|
|
448
|
+
const features = data.features || {};
|
|
449
|
+
const foundIds = Object.keys(features);
|
|
450
|
+
if (foundIds.length === 0) {
|
|
451
|
+
return "No features found for the given IDs. Check that the feature IDs are correct and your API key has access.";
|
|
452
|
+
}
|
|
453
|
+
const parts = [`**${foundIds.length} feature flag(s) checked:**`, ""];
|
|
454
|
+
let staleCount = 0;
|
|
455
|
+
for (const id of requestedIds) {
|
|
456
|
+
const f = features[id];
|
|
457
|
+
if (!f) {
|
|
458
|
+
parts.push(`- **\`${id}\`**: NOT FOUND`);
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
if (f.neverStale) {
|
|
462
|
+
parts.push(`- **\`${f.featureId}\`**: NOT STALE (stale detection disabled)`);
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
if (!f.isStale) {
|
|
466
|
+
parts.push(`- **\`${f.featureId}\`**: NOT STALE${f.staleReason ? ` (${f.staleReason})` : ""}`);
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
// ── Stale flag: include replacement guidance ──
|
|
470
|
+
staleCount++;
|
|
471
|
+
const envEntries = f.staleByEnv ? Object.entries(f.staleByEnv) : [];
|
|
472
|
+
const envsWithValues = envEntries.filter(([, e]) => e.evaluatesTo !== undefined);
|
|
473
|
+
let replacementValue;
|
|
474
|
+
let envNote;
|
|
475
|
+
if (envsWithValues.length === 0) {
|
|
476
|
+
replacementValue = undefined;
|
|
477
|
+
envNote =
|
|
478
|
+
"No deterministic value available — ask the user what the replacement should be.";
|
|
479
|
+
}
|
|
480
|
+
else {
|
|
481
|
+
const values = new Set(envsWithValues.map(([, e]) => e.evaluatesTo));
|
|
482
|
+
if (values.size === 1) {
|
|
483
|
+
replacementValue = envsWithValues[0][1].evaluatesTo;
|
|
484
|
+
envNote = `All environments agree.`;
|
|
485
|
+
}
|
|
486
|
+
else {
|
|
487
|
+
// Environments disagree — default to production
|
|
488
|
+
const prod = envsWithValues.find(([env]) => env === "production");
|
|
489
|
+
if (prod) {
|
|
490
|
+
replacementValue = prod[1].evaluatesTo;
|
|
491
|
+
const others = envsWithValues
|
|
492
|
+
.map(([env, e]) => `${env}=\`${e.evaluatesTo}\``)
|
|
493
|
+
.join(", ");
|
|
494
|
+
envNote = `Environments disagree (${others}). Using production value. Confirm with the user if a different environment should be used.`;
|
|
495
|
+
}
|
|
496
|
+
else {
|
|
497
|
+
replacementValue = envsWithValues[0][1].evaluatesTo;
|
|
498
|
+
const others = envsWithValues
|
|
499
|
+
.map(([env, e]) => `${env}=\`${e.evaluatesTo}\``)
|
|
500
|
+
.join(", ");
|
|
501
|
+
envNote = `Environments disagree (${others}). No production environment found, using ${envsWithValues[0][0]}. Confirm with the user which environment to use.`;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (replacementValue !== undefined) {
|
|
506
|
+
parts.push(`- **\`${f.featureId}\`**: STALE (${f.staleReason}) — replace with: \`${replacementValue}\``);
|
|
507
|
+
}
|
|
508
|
+
else {
|
|
509
|
+
parts.push(`- **\`${f.featureId}\`**: STALE (${f.staleReason}) — needs manual review`);
|
|
510
|
+
}
|
|
511
|
+
parts.push(` ${envNote}`);
|
|
512
|
+
parts.push(` Search for: ${buildSearchPatterns(id)}`);
|
|
513
|
+
parts.push("");
|
|
514
|
+
}
|
|
515
|
+
// Summary
|
|
516
|
+
const notFound = requestedIds.filter((id) => !features[id]);
|
|
517
|
+
if (notFound.length > 0) {
|
|
518
|
+
parts.push(`${notFound.length} flag(s) not found: ${notFound.map((id) => `\`${id}\``).join(", ")}`);
|
|
519
|
+
}
|
|
520
|
+
if (staleCount > 0) {
|
|
521
|
+
parts.push(`**${staleCount} flag(s) ready for cleanup.** For each stale flag, find usages with the search patterns above, replace the flag check with the resolved value, and remove dead code branches. Confirm changes with the user before modifying files.`);
|
|
522
|
+
}
|
|
523
|
+
else {
|
|
524
|
+
parts.push("No stale flags found. All checked features are active.");
|
|
525
|
+
}
|
|
526
|
+
return parts.join("\n");
|
|
527
|
+
}
|
|
528
|
+
// ─── Helpful Errors ─────────────────────────────────────────────────
|
|
529
|
+
export function formatApiError(error, context, suggestions) {
|
|
530
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
531
|
+
const parts = [`Error ${context}: ${message}`];
|
|
532
|
+
if (suggestions && suggestions.length > 0) {
|
|
533
|
+
parts.push("");
|
|
534
|
+
parts.push("Suggestions:");
|
|
535
|
+
for (const s of suggestions) {
|
|
536
|
+
parts.push(`- ${s}`);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return parts.join("\n");
|
|
540
|
+
}
|
package/server/tools/defaults.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { handleResNotOk, fetchWithRateLimit, } from "../utils.js";
|
|
1
|
+
import { handleResNotOk, fetchWithRateLimit, buildHeaders, } from "../utils.js";
|
|
2
|
+
import { formatDefaults } from "../format-responses.js";
|
|
2
3
|
import envPaths from "env-paths";
|
|
3
4
|
import { writeFile, readFile, mkdir, unlink } from "fs/promises";
|
|
4
5
|
import { join } from "path";
|
|
@@ -10,33 +11,27 @@ const userDefaultsFile = join(experimentDefaultsDir, "user-defaults.json");
|
|
|
10
11
|
export async function createDefaults(apiKey, baseApiUrl) {
|
|
11
12
|
try {
|
|
12
13
|
const experimentsResponse = await fetchWithRateLimit(`${baseApiUrl}/api/v1/experiments`, {
|
|
13
|
-
headers:
|
|
14
|
-
Authorization: `Bearer ${apiKey}`,
|
|
15
|
-
},
|
|
14
|
+
headers: buildHeaders(apiKey, false),
|
|
16
15
|
});
|
|
17
16
|
await handleResNotOk(experimentsResponse);
|
|
18
|
-
const experimentData = await experimentsResponse.json();
|
|
19
|
-
if (experimentData.experiments
|
|
17
|
+
const experimentData = (await experimentsResponse.json());
|
|
18
|
+
if (experimentData.experiments?.length === 0) {
|
|
20
19
|
// No experiments: return assignment query and environments if possible
|
|
21
20
|
const assignmentQueryResponse = await fetchWithRateLimit(`${baseApiUrl}/api/v1/data-sources`, {
|
|
22
|
-
headers:
|
|
23
|
-
Authorization: `Bearer ${apiKey}`,
|
|
24
|
-
},
|
|
21
|
+
headers: buildHeaders(apiKey, false),
|
|
25
22
|
});
|
|
26
23
|
await handleResNotOk(assignmentQueryResponse);
|
|
27
|
-
const dataSourceData = await assignmentQueryResponse.json();
|
|
28
|
-
if (dataSourceData.dataSources
|
|
24
|
+
const dataSourceData = (await assignmentQueryResponse.json());
|
|
25
|
+
if (dataSourceData.dataSources?.length === 0) {
|
|
29
26
|
throw new Error("No data source or assignment query found. Experiments require a data source/assignment query. Set these up in the GrowthBook and try again.");
|
|
30
27
|
}
|
|
31
28
|
const assignmentQuery = dataSourceData.dataSources[0].assignmentQueries[0].id;
|
|
32
29
|
const environmentsResponse = await fetchWithRateLimit(`${baseApiUrl}/api/v1/environments`, {
|
|
33
|
-
headers:
|
|
34
|
-
Authorization: `Bearer ${apiKey}`,
|
|
35
|
-
},
|
|
30
|
+
headers: buildHeaders(apiKey, false),
|
|
36
31
|
});
|
|
37
32
|
await handleResNotOk(environmentsResponse);
|
|
38
|
-
const environmentsData = await environmentsResponse.json();
|
|
39
|
-
const environments = environmentsData.environments.map(({ id }) => id);
|
|
33
|
+
const environmentsData = (await environmentsResponse.json());
|
|
34
|
+
const environments = (environmentsData.environments || []).map(({ id }) => id);
|
|
40
35
|
return {
|
|
41
36
|
name: [],
|
|
42
37
|
hypothesis: [],
|
|
@@ -54,18 +49,15 @@ export async function createDefaults(apiKey, baseApiUrl) {
|
|
|
54
49
|
let experiments = [];
|
|
55
50
|
if (experimentData.hasMore) {
|
|
56
51
|
const mostRecentExperiments = await fetchWithRateLimit(`${baseApiUrl}/api/v1/experiments?offset=${experimentData.total -
|
|
57
|
-
Math.min(50, experimentData.count + experimentData.offset)}&limit=${Math.min(50, experimentData.count + experimentData.offset)}`, {
|
|
58
|
-
headers:
|
|
59
|
-
Authorization: `Bearer ${apiKey}`,
|
|
60
|
-
"Content-Type": "application/json",
|
|
61
|
-
},
|
|
52
|
+
Math.min(50, (experimentData.count ?? 0) + (experimentData.offset ?? 0))}&limit=${Math.min(50, (experimentData.count ?? 0) + (experimentData.offset ?? 0))}`, {
|
|
53
|
+
headers: buildHeaders(apiKey),
|
|
62
54
|
});
|
|
63
55
|
await handleResNotOk(mostRecentExperiments);
|
|
64
|
-
const mostRecentExperimentData = await mostRecentExperiments.json();
|
|
65
|
-
experiments = mostRecentExperimentData.experiments;
|
|
56
|
+
const mostRecentExperimentData = (await mostRecentExperiments.json());
|
|
57
|
+
experiments = (mostRecentExperimentData.experiments || []);
|
|
66
58
|
}
|
|
67
59
|
else {
|
|
68
|
-
experiments = experimentData.experiments;
|
|
60
|
+
experiments = (experimentData.experiments || []);
|
|
69
61
|
}
|
|
70
62
|
// Aggregate experiment stats
|
|
71
63
|
const experimentStats = experiments.reduce((acc, experiment) => {
|
|
@@ -113,13 +105,11 @@ export async function createDefaults(apiKey, baseApiUrl) {
|
|
|
113
105
|
}
|
|
114
106
|
// Fetch environments
|
|
115
107
|
const environmentsResponse = await fetchWithRateLimit(`${baseApiUrl}/api/v1/environments`, {
|
|
116
|
-
headers:
|
|
117
|
-
Authorization: `Bearer ${apiKey}`,
|
|
118
|
-
},
|
|
108
|
+
headers: buildHeaders(apiKey, false),
|
|
119
109
|
});
|
|
120
110
|
await handleResNotOk(environmentsResponse);
|
|
121
|
-
const environmentsData = await environmentsResponse.json();
|
|
122
|
-
const environments = environmentsData.environments.map(({ id }) => id);
|
|
111
|
+
const environmentsData = (await environmentsResponse.json());
|
|
112
|
+
const environments = (environmentsData.environments || []).map(({ id }) => id);
|
|
123
113
|
return {
|
|
124
114
|
name: experimentStats.name,
|
|
125
115
|
hypothesis: experimentStats.hypothesis,
|
|
@@ -262,7 +252,7 @@ export async function registerDefaultsTools({ server, baseApiUrl, apiKey, }) {
|
|
|
262
252
|
content: [
|
|
263
253
|
{
|
|
264
254
|
type: "text",
|
|
265
|
-
text:
|
|
255
|
+
text: formatDefaults(defaults),
|
|
266
256
|
},
|
|
267
257
|
],
|
|
268
258
|
};
|