@growthbook/mcp 1.6.0 → 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 CHANGED
@@ -19,22 +19,4 @@ Use the following env variables to configure the MCP server.
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
- **Custom Headers Examples**
23
-
24
- For multi-tenant deployments or proxy configurations, you can add custom headers:
25
-
26
- ```bash
27
- # Multi-tenant identification
28
- GB_HTTP_HEADER_X_TENANT_ID=tenant-123
29
-
30
- # Cloudflare Access proxy authentication
31
- GB_HTTP_HEADER_CF_ACCESS_TOKEN=eyJhbGciOiJSUzI1NiIs...
32
- ```
33
-
34
- **Security Best Practices**
35
- - Always use HTTPS for API communication (default for GrowthBook Cloud)
36
- - Store API keys and sensitive headers in environment variables, never hardcode them
37
- - Use the Authorization header (via GB_API_KEY) for authentication
38
- - Custom headers are useful for multi-tenant scenarios, proxy routing, or additional context
39
-
40
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.6.0",
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
+ }
@@ -1,4 +1,5 @@
1
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";
@@ -13,15 +14,15 @@ export async function createDefaults(apiKey, baseApiUrl) {
13
14
  headers: buildHeaders(apiKey, false),
14
15
  });
15
16
  await handleResNotOk(experimentsResponse);
16
- const experimentData = await experimentsResponse.json();
17
- if (experimentData.experiments.length === 0) {
17
+ const experimentData = (await experimentsResponse.json());
18
+ if (experimentData.experiments?.length === 0) {
18
19
  // No experiments: return assignment query and environments if possible
19
20
  const assignmentQueryResponse = await fetchWithRateLimit(`${baseApiUrl}/api/v1/data-sources`, {
20
21
  headers: buildHeaders(apiKey, false),
21
22
  });
22
23
  await handleResNotOk(assignmentQueryResponse);
23
- const dataSourceData = await assignmentQueryResponse.json();
24
- if (dataSourceData.dataSources.length === 0) {
24
+ const dataSourceData = (await assignmentQueryResponse.json());
25
+ if (dataSourceData.dataSources?.length === 0) {
25
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.");
26
27
  }
27
28
  const assignmentQuery = dataSourceData.dataSources[0].assignmentQueries[0].id;
@@ -29,8 +30,8 @@ export async function createDefaults(apiKey, baseApiUrl) {
29
30
  headers: buildHeaders(apiKey, false),
30
31
  });
31
32
  await handleResNotOk(environmentsResponse);
32
- const environmentsData = await environmentsResponse.json();
33
- const environments = environmentsData.environments.map(({ id }) => id);
33
+ const environmentsData = (await environmentsResponse.json());
34
+ const environments = (environmentsData.environments || []).map(({ id }) => id);
34
35
  return {
35
36
  name: [],
36
37
  hypothesis: [],
@@ -48,15 +49,15 @@ export async function createDefaults(apiKey, baseApiUrl) {
48
49
  let experiments = [];
49
50
  if (experimentData.hasMore) {
50
51
  const mostRecentExperiments = await fetchWithRateLimit(`${baseApiUrl}/api/v1/experiments?offset=${experimentData.total -
51
- Math.min(50, experimentData.count + experimentData.offset)}&limit=${Math.min(50, experimentData.count + experimentData.offset)}`, {
52
+ Math.min(50, (experimentData.count ?? 0) + (experimentData.offset ?? 0))}&limit=${Math.min(50, (experimentData.count ?? 0) + (experimentData.offset ?? 0))}`, {
52
53
  headers: buildHeaders(apiKey),
53
54
  });
54
55
  await handleResNotOk(mostRecentExperiments);
55
- const mostRecentExperimentData = await mostRecentExperiments.json();
56
- experiments = mostRecentExperimentData.experiments;
56
+ const mostRecentExperimentData = (await mostRecentExperiments.json());
57
+ experiments = (mostRecentExperimentData.experiments || []);
57
58
  }
58
59
  else {
59
- experiments = experimentData.experiments;
60
+ experiments = (experimentData.experiments || []);
60
61
  }
61
62
  // Aggregate experiment stats
62
63
  const experimentStats = experiments.reduce((acc, experiment) => {
@@ -107,8 +108,8 @@ export async function createDefaults(apiKey, baseApiUrl) {
107
108
  headers: buildHeaders(apiKey, false),
108
109
  });
109
110
  await handleResNotOk(environmentsResponse);
110
- const environmentsData = await environmentsResponse.json();
111
- const environments = environmentsData.environments.map(({ id }) => id);
111
+ const environmentsData = (await environmentsResponse.json());
112
+ const environments = (environmentsData.environments || []).map(({ id }) => id);
112
113
  return {
113
114
  name: experimentStats.name,
114
115
  hypothesis: experimentStats.hypothesis,
@@ -251,7 +252,7 @@ export async function registerDefaultsTools({ server, baseApiUrl, apiKey, }) {
251
252
  content: [
252
253
  {
253
254
  type: "text",
254
- text: JSON.stringify(defaults),
255
+ text: formatDefaults(defaults),
255
256
  },
256
257
  ],
257
258
  };
@@ -1,4 +1,5 @@
1
1
  import { handleResNotOk, fetchWithRateLimit, buildHeaders, } from "../utils.js";
2
+ import { formatEnvironments, formatApiError } from "../format-responses.js";
2
3
  import { z } from "zod";
3
4
  /**
4
5
  * Tool: get_environments
@@ -17,13 +18,15 @@ export function registerEnvironmentTools({ server, baseApiUrl, apiKey, }) {
17
18
  headers: buildHeaders(apiKey),
18
19
  });
19
20
  await handleResNotOk(res);
20
- const data = await res.json();
21
+ const data = (await res.json());
21
22
  return {
22
- content: [{ type: "text", text: JSON.stringify(data) }],
23
+ content: [{ type: "text", text: formatEnvironments(data) }],
23
24
  };
24
25
  }
25
26
  catch (error) {
26
- throw new Error(`Error fetching environments: ${error}`);
27
+ throw new Error(formatApiError(error, "fetching environments", [
28
+ "Check that your GB_API_KEY has permission to read environments.",
29
+ ]));
27
30
  }
28
31
  });
29
32
  }
@@ -14,7 +14,7 @@ async function processBatch(items, concurrency, processor) {
14
14
  }
15
15
  return results;
16
16
  }
17
- async function getMetricLookup(baseApiUrl, apiKey, metricIds) {
17
+ export async function getMetricLookup(baseApiUrl, apiKey, metricIds) {
18
18
  const metricLookup = new Map();
19
19
  if (metricIds.size === 0) {
20
20
  return metricLookup;
@@ -1,7 +1,8 @@
1
1
  import { z } from "zod";
2
- import { generateLinkToGrowthBook, getDocsMetadata, handleResNotOk, SUPPORTED_FILE_EXTENSIONS, paginationSchema, fetchWithRateLimit, fetchWithPagination, featureFlagSchema, fetchFeatureFlag, mergeRuleIntoFeatureFlag, buildHeaders, } from "../../utils.js";
2
+ import { getDocsMetadata, handleResNotOk, SUPPORTED_FILE_EXTENSIONS, paginationSchema, fetchWithRateLimit, fetchWithPagination, featureFlagSchema, fetchFeatureFlag, mergeRuleIntoFeatureFlag, buildHeaders, } from "../../utils.js";
3
+ import { formatExperimentList, formatExperimentDetail, formatExperimentCreated, formatAttributes, formatApiError, } from "../../format-responses.js";
3
4
  import { getDefaults } from "../defaults.js";
4
- import { handleSummaryMode } from "./experiment-summary.js";
5
+ import { handleSummaryMode, getMetricLookup } from "./experiment-summary.js";
5
6
  export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin, user, }) {
6
7
  /**
7
8
  * Tool: get_experiments
@@ -34,36 +35,56 @@ export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin,
34
35
  headers: buildHeaders(apiKey),
35
36
  });
36
37
  await handleResNotOk(res);
37
- const data = await res.json();
38
- // Fetch results
38
+ const data = (await res.json());
39
+ const dataWithResult = data;
39
40
  if (mode === "full") {
40
- if (data.status === "draft") {
41
- data.result = null;
41
+ // Fetch results
42
+ if (data.experiment.status === "draft") {
43
+ dataWithResult.result = null;
42
44
  }
43
- try {
44
- const resultsRes = await fetchWithRateLimit(`${baseApiUrl}/api/v1/experiments/${experimentId}/results`, {
45
- headers: buildHeaders(apiKey, false),
46
- });
47
- await handleResNotOk(resultsRes);
48
- const resultsData = await resultsRes.json();
49
- data.result = resultsData.result;
45
+ else {
46
+ try {
47
+ const resultsRes = await fetchWithRateLimit(`${baseApiUrl}/api/v1/experiments/${experimentId}/results`, {
48
+ headers: buildHeaders(apiKey, false),
49
+ });
50
+ await handleResNotOk(resultsRes);
51
+ const resultsData = await resultsRes.json();
52
+ dataWithResult.result = resultsData.result;
53
+ }
54
+ catch (error) {
55
+ console.error(`Error fetching results for experiment ${experimentId}`, error);
56
+ }
50
57
  }
51
- catch (error) {
52
- console.error(`Error fetching results for experiment ${experimentId}`, error);
58
+ // Resolve metric IDs to names
59
+ const metricIds = new Set();
60
+ for (const g of data.experiment.settings?.goals || [])
61
+ metricIds.add(g.metricId);
62
+ for (const g of data.experiment.settings?.guardrails || [])
63
+ metricIds.add(g.metricId);
64
+ for (const g of data.experiment.settings?.secondaryMetrics || [])
65
+ metricIds.add(g.metricId);
66
+ const metricLookup = await getMetricLookup(baseApiUrl, apiKey, metricIds);
67
+ // Multi-block response: curated summary first, raw results second
68
+ const content = [
69
+ { type: "text", text: formatExperimentDetail(dataWithResult, appOrigin, metricLookup) },
70
+ ];
71
+ if (dataWithResult.result) {
72
+ content.push({
73
+ type: "text",
74
+ text: `**Full results data (raw):**\n\`\`\`json\n${JSON.stringify(dataWithResult.result, null, 2)}\n\`\`\``,
75
+ });
53
76
  }
77
+ return { content };
54
78
  }
55
- const linkToGrowthBook = generateLinkToGrowthBook(appOrigin, "experiment", experimentId);
56
- const text = `
57
- ${JSON.stringify(data)}
58
-
59
- [View the experiment in GrowthBook](${linkToGrowthBook})
60
- `;
61
79
  return {
62
- content: [{ type: "text", text }],
80
+ content: [{ type: "text", text: formatExperimentDetail(dataWithResult, appOrigin) }],
63
81
  };
64
82
  }
65
83
  catch (error) {
66
- throw new Error(`Error getting experiment: ${error}`);
84
+ throw new Error(formatApiError(error, `fetching experiment '${experimentId}'`, [
85
+ "Check the experiment ID is correct.",
86
+ "Use get_experiments without an experimentId to list all available experiments.",
87
+ ]));
67
88
  }
68
89
  }
69
90
  const progressToken = extra._meta?.progressToken;
@@ -83,7 +104,7 @@ export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin,
83
104
  };
84
105
  await reportProgress(1, "Fetching experiments...");
85
106
  try {
86
- const data = await fetchWithPagination(baseApiUrl, apiKey, "/api/v1/experiments", limit, offset, mostRecent, project ? { projectId: project } : undefined);
107
+ const data = (await fetchWithPagination(baseApiUrl, apiKey, "/api/v1/experiments", limit, offset, mostRecent, project ? { projectId: project } : undefined));
87
108
  let experiments = data.experiments || [];
88
109
  // Reverse experiments array for mostRecent to show newest-first
89
110
  if (mostRecent && offset === 0 && Array.isArray(experiments)) {
@@ -130,12 +151,20 @@ export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin,
130
151
  };
131
152
  }
132
153
  await reportProgress(2, "Processing results...");
154
+ // Full mode: return raw JSON since users expect complete results data
155
+ if (mode === "full") {
156
+ return {
157
+ content: [{ type: "text", text: JSON.stringify(data) }],
158
+ };
159
+ }
133
160
  return {
134
- content: [{ type: "text", text: JSON.stringify(data) }],
161
+ content: [{ type: "text", text: formatExperimentList(data) }],
135
162
  };
136
163
  }
137
164
  catch (error) {
138
- throw new Error(`Error fetching experiments: ${error}`);
165
+ throw new Error(formatApiError(error, "fetching experiments", [
166
+ "Check that your GB_API_KEY has permission to read experiments.",
167
+ ]));
139
168
  }
140
169
  });
141
170
  /**
@@ -156,13 +185,15 @@ export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin,
156
185
  headers: buildHeaders(apiKey),
157
186
  });
158
187
  await handleResNotOk(res);
159
- const data = await res.json();
188
+ const data = (await res.json());
160
189
  return {
161
- content: [{ type: "text", text: JSON.stringify(data) }],
190
+ content: [{ type: "text", text: formatAttributes(data) }],
162
191
  };
163
192
  }
164
193
  catch (error) {
165
- throw new Error(`Error fetching attributes: ${error}`);
194
+ throw new Error(formatApiError(error, "fetching attributes", [
195
+ "Check that your GB_API_KEY has permission to read attributes.",
196
+ ]));
166
197
  }
167
198
  });
168
199
  /**
@@ -252,7 +283,7 @@ export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin,
252
283
  body: JSON.stringify(experimentPayload),
253
284
  });
254
285
  await handleResNotOk(experimentRes);
255
- const experimentData = await experimentRes.json();
286
+ const experimentData = (await experimentRes.json());
256
287
  let flagData = null;
257
288
  if (featureId) {
258
289
  // Fetch the existing feature flag first to preserve existing rules
@@ -275,23 +306,18 @@ export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin,
275
306
  });
276
307
  await handleResNotOk(flagRes);
277
308
  flagData = await flagRes.json();
278
- }
279
- const experimentLink = generateLinkToGrowthBook(appOrigin, "experiment", experimentData.experiment.id);
309
+ } // flagData is UpdateFeatureResponse when featureId was set
280
310
  const { stub, docs, language } = getDocsMetadata(fileExtension);
281
- const flagText = featureId &&
282
- `**How to implement the feature flag experiment in your code:**
283
- ---
284
- ${stub}
285
- ---
286
- **Learn more about implementing experiments in your codebase:**
287
- See the [GrowthBook ${language} docs](${docs}).`;
288
- const text = `**✅ Your draft experiment \`${name}\` is ready!.** [View the experiment in GrowthBook](${experimentLink}) to review and launch.\n\n${flagText}`;
289
311
  return {
290
- content: [{ type: "text", text }],
312
+ content: [{ type: "text", text: formatExperimentCreated(experimentData, appOrigin, featureId ? stub : undefined, language, docs) }],
291
313
  };
292
314
  }
293
315
  catch (error) {
294
- throw new Error(`Error creating experiment: ${error}`);
316
+ throw new Error(formatApiError(error, `creating experiment '${name}'`, [
317
+ "Call get_defaults first and use the returned datasource/assignment query IDs.",
318
+ "If linking to a feature flag, verify the flag exists with get_feature_flags.",
319
+ "Check that variation values match the specified valueType.",
320
+ ]));
295
321
  }
296
322
  });
297
323
  }
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
- import { getDocsMetadata, handleResNotOk, generateLinkToGrowthBook, paginationSchema, featureFlagSchema, fetchWithRateLimit, fetchWithPagination, fetchFeatureFlag, mergeRuleIntoFeatureFlag, buildHeaders, } from "../utils.js";
2
+ import { getDocsMetadata, handleResNotOk, paginationSchema, featureFlagSchema, fetchWithRateLimit, fetchWithPagination, fetchFeatureFlag, mergeRuleIntoFeatureFlag, buildHeaders, } from "../utils.js";
3
+ import { formatFeatureFlagList, formatFeatureFlagDetail, formatFeatureFlagCreated, formatForceRuleCreated, formatStaleFeatureFlags, formatApiError, } from "../format-responses.js";
3
4
  import { exec } from "child_process";
4
5
  import { getDefaults } from "./defaults.js";
5
6
  export function registerFeatureTools({ server, baseApiUrl, apiKey, appOrigin, user, }) {
@@ -54,28 +55,21 @@ export function registerFeatureTools({ server, baseApiUrl, apiKey, appOrigin, us
54
55
  await handleResNotOk(res);
55
56
  const data = await res.json();
56
57
  const { docs, language, stub } = getDocsMetadata(fileExtension);
57
- const linkToGrowthBook = generateLinkToGrowthBook(appOrigin, "features", id);
58
- const text = `This is the API response: ${JSON.stringify(data)}
59
-
60
- Additionally, here is a template of what to show to the user:
61
-
62
- **✅ Your feature flag \`my-flag-name\` is ready!**
63
- [View it in GrowthBook](${linkToGrowthBook})
64
-
65
- **How to use it in your code:**
66
- (To the LLM agent. Use the text below to properly add the feature flag to the codebase. Also, surface the relevant docs/implemenation to the user)
67
-
68
- ${stub}
69
-
70
- **Want to learn more?**
71
- See the [GrowthBook ${language} docs](${docs})
72
- `;
73
58
  return {
74
- content: [{ type: "text", text }],
59
+ content: [
60
+ {
61
+ type: "text",
62
+ text: formatFeatureFlagCreated(data, appOrigin, stub, language, docs),
63
+ },
64
+ ],
75
65
  };
76
66
  }
77
67
  catch (error) {
78
- throw new Error(`Error creating feature flag: ${error}`);
68
+ throw new Error(formatApiError(error, `creating feature flag '${id}'`, [
69
+ "Check the id is valid (letters, numbers, _, -, ., :, | only).",
70
+ "A flag with this id may already exist — use get_feature_flags to check.",
71
+ "If scoping to a project, verify the project id with get_projects.",
72
+ ]));
79
73
  }
80
74
  });
81
75
  /**
@@ -122,29 +116,22 @@ export function registerFeatureTools({ server, baseApiUrl, apiKey, appOrigin, us
122
116
  });
123
117
  await handleResNotOk(res);
124
118
  const data = await res.json();
125
- const linkToGrowthBook = generateLinkToGrowthBook(appOrigin, "features", featureId);
126
119
  const { docs, language, stub } = getDocsMetadata(fileExtension);
127
- const text = `This is the API response: ${JSON.stringify(data)}
128
-
129
- Additionally, here is a template of what to show to the user:
130
-
131
- **✅ Your feature flag \`my-flag-name\` is ready!.**
132
- [View it in GrowthBook](${linkToGrowthBook})
133
-
134
- **How to use it in your code:**
135
- (To the LLM agent. Use the text below to properly add the feature flag to the codebase. Also, surface the relevant docs/implemenation to the user)
136
-
137
- ${stub}
138
-
139
- **Want to learn more?**
140
- See the [GrowthBook ${language} docs](${docs})
141
- `;
142
120
  return {
143
- content: [{ type: "text", text }],
121
+ content: [
122
+ {
123
+ type: "text",
124
+ text: formatForceRuleCreated(data, appOrigin, featureId, stub, language, docs),
125
+ },
126
+ ],
144
127
  };
145
128
  }
146
129
  catch (error) {
147
- throw new Error(`Error creating force rule: ${error}`);
130
+ throw new Error(formatApiError(error, `adding rule to '${featureId}'`, [
131
+ `Check that feature flag '${featureId}' exists — use get_feature_flags to verify.`,
132
+ 'Ensure the value matches the flag\'s valueType (e.g. "true" for boolean flags).',
133
+ 'For condition syntax, use MongoDB-style JSON: {"country": "US"}',
134
+ ]));
148
135
  }
149
136
  });
150
137
  /**
@@ -170,20 +157,17 @@ export function registerFeatureTools({ server, baseApiUrl, apiKey, appOrigin, us
170
157
  });
171
158
  await handleResNotOk(res);
172
159
  const data = await res.json();
173
- const linkToGrowthBook = generateLinkToGrowthBook(appOrigin, "features", featureFlagId);
174
- const text = `This is the API response: ${JSON.stringify(data)}
175
-
176
- Share information about the feature flag with the user. In particular, give details about the enabled environments,
177
- rules for each environment, and the default value. If the feature flag is archived or doesn't exist, inform the user and
178
- ask if they want to remove references to the feature flag from the codebase.
179
-
180
- [View it in GrowthBook](${linkToGrowthBook})`;
181
160
  return {
182
- content: [{ type: "text", text }],
161
+ content: [
162
+ { type: "text", text: formatFeatureFlagDetail(data, appOrigin) },
163
+ ],
183
164
  };
184
165
  }
185
166
  catch (error) {
186
- throw new Error(`Error fetching flags: ${error}`);
167
+ throw new Error(formatApiError(error, `fetching feature flag '${featureFlagId}'`, [
168
+ "Check the feature flag id is correct.",
169
+ "Use get_feature_flags without a featureFlagId to list all available flags.",
170
+ ]));
187
171
  }
188
172
  }
189
173
  // Fetch multiple feature flags
@@ -194,66 +178,68 @@ ask if they want to remove references to the feature flag from the codebase.
194
178
  data.features = data.features.reverse();
195
179
  }
196
180
  return {
197
- content: [{ type: "text", text: JSON.stringify(data) }],
181
+ content: [{ type: "text", text: formatFeatureFlagList(data) }],
198
182
  };
199
183
  }
200
184
  catch (error) {
201
- throw new Error(`Error fetching flags: ${error}`);
185
+ throw new Error(formatApiError(error, "fetching feature flags", [
186
+ "Check that your GB_API_KEY has permission to read features.",
187
+ ]));
202
188
  }
203
189
  });
204
190
  /**
205
- * Tool: get_stale_safe_rollouts
191
+ * Tool: get_stale_feature_flags
206
192
  */
207
- server.registerTool("get_stale_safe_rollouts", {
208
- title: "Get Stale Safe Rollouts",
209
- description: "Finds feature flags with completed safe rollout rules that can be cleaned up from your codebase. Safe rollouts gradually increase traffic while monitoring for regressions. Completed rollouts (released or rolled-back) indicate flag code can be simplified: released means the new value won, rolled-back means revert to control value. Use for technical debt cleanup.",
193
+ server.registerTool("get_stale_feature_flags", {
194
+ title: "Get Stale Feature Flags",
195
+ description: "Given a list of feature flag IDs, checks whether each one is stale and returns cleanup guidance including replacement values and SDK search patterns. You MUST provide featureIds gather them first from the user, from the current file context, or by grepping the codebase for SDK patterns (isOn, getFeatureValue, useFeatureIsOn, useFeatureValue, evalFeature).",
210
196
  inputSchema: z.object({
211
- limit: z.number().optional().default(100),
212
- offset: z.number().optional().default(0),
197
+ featureIds: z
198
+ .array(z.string())
199
+ .optional()
200
+ .describe("REQUIRED. One or more feature flag IDs to check (e.g. [\"my-feature\", \"dark-mode\"]). Gather IDs first from the user, from code context, or by grepping for SDK usage patterns."),
213
201
  }),
214
202
  annotations: {
215
203
  readOnlyHint: true,
216
204
  },
217
- }, async ({ limit, offset }) => {
205
+ }, async ({ featureIds }) => {
218
206
  try {
219
- const queryParams = new URLSearchParams({
220
- limit: limit?.toString(),
221
- offset: offset?.toString(),
222
- });
223
- const res = await fetchWithRateLimit(`${baseApiUrl}/api/v1/features?${queryParams.toString()}`, {
207
+ if (!featureIds?.length) {
208
+ return {
209
+ content: [
210
+ {
211
+ type: "text",
212
+ text: [
213
+ "**featureIds is required.** This tool checks specific flags — it does not list all stale flags.",
214
+ "",
215
+ "To gather feature flag IDs, try one of these approaches:",
216
+ "1. **Ask the user** which flags they want to check",
217
+ "2. **Extract from current file context** — look for flag IDs in the open file",
218
+ "3. **Grep the codebase** for GrowthBook SDK patterns:",
219
+ ' `grep -rn "isOn\\|getFeatureValue\\|useFeatureIsOn\\|useFeatureValue\\|evalFeature" --include="*.{ts,tsx,js,jsx,py,go,rb}"`',
220
+ "",
221
+ "Then call this tool again with the discovered flag IDs.",
222
+ ].join("\n"),
223
+ },
224
+ ],
225
+ };
226
+ }
227
+ const ids = featureIds.join(",");
228
+ const res = await fetchWithRateLimit(`${baseApiUrl}/api/v1/stale-features?ids=${encodeURIComponent(ids)}`, {
224
229
  headers: buildHeaders(apiKey),
225
230
  });
226
231
  await handleResNotOk(res);
227
- const data = await res.json();
228
- const filteredSafeRollouts = data.features.filter((feature) => {
229
- const envs = feature.environments;
230
- if (!envs)
231
- return false;
232
- return Object.values(envs).some((env) => {
233
- const rules = env.rules;
234
- if (!rules)
235
- return false;
236
- return rules.some((rule) => {
237
- return (rule.type === "safe-rollout" &&
238
- (rule.status === "rolled-back" || rule.status === "released"));
239
- });
240
- });
241
- });
242
- const text = `
243
- ${JSON.stringify(filteredSafeRollouts)}
244
-
245
- Share information about the rolled-back or released safe rollout rules with the user. Safe Rollout rules are stored under
246
- environmentSettings, keyed by environment and are within the rules array with a type of "safe-rollout". Ask the user if they
247
- would like to remove references to the feature associated with the rolled-back or released safe rollout rules and if they do,
248
- remove the references and associated GrowthBook code and replace the values with controlValue if the safe rollout rule is rolled-back or with the
249
- variationValue if the safe rollout is released. In addition to the current file, you may need to update other files in the codebase.
250
- `;
232
+ const data = (await res.json());
233
+ const text = formatStaleFeatureFlags(data, featureIds);
251
234
  return {
252
235
  content: [{ type: "text", text }],
253
236
  };
254
237
  }
255
238
  catch (error) {
256
- throw new Error(`Error fetching stale safe rollouts: ${error}`);
239
+ throw new Error(formatApiError(error, "checking stale features", [
240
+ "Check that the feature IDs are correct.",
241
+ "Check that your GB_API_KEY has permission to read features.",
242
+ ]));
257
243
  }
258
244
  });
259
245
  /**
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
- import { generateLinkToGrowthBook, handleResNotOk, paginationSchema, fetchWithRateLimit, fetchWithPagination, buildHeaders, } from "../utils.js";
2
+ import { handleResNotOk, paginationSchema, fetchWithRateLimit, fetchWithPagination, buildHeaders, } from "../utils.js";
3
+ import { formatMetricsList, formatMetricDetail, formatApiError } from "../format-responses.js";
3
4
  export function registerMetricsTools({ server, baseApiUrl, apiKey, appOrigin, }) {
4
5
  /**
5
6
  * Tool: get_metrics
@@ -36,27 +37,29 @@ export function registerMetricsTools({ server, baseApiUrl, apiKey, appOrigin, })
36
37
  });
37
38
  }
38
39
  await handleResNotOk(res);
39
- const data = await res.json();
40
- const linkToGrowthBook = generateLinkToGrowthBook(appOrigin, data.factMetric ? "fact-metrics" : "metric", metricId);
40
+ const data = metricId.startsWith("fact__")
41
+ ? (await res.json())
42
+ : (await res.json());
41
43
  return {
42
44
  content: [
43
45
  {
44
46
  type: "text",
45
- text: JSON.stringify(data) +
46
- `\n**Critical** Show the user the link to the metric in GrowthBook: [View the metric in GrowthBook](${linkToGrowthBook})
47
- `,
47
+ text: formatMetricDetail(data, appOrigin),
48
48
  },
49
49
  ],
50
50
  };
51
51
  }
52
52
  catch (error) {
53
- throw new Error(`Error fetching metric: ${error}`);
53
+ throw new Error(formatApiError(error, `fetching metric '${metricId}'`, [
54
+ "Check the metric ID is correct. Fact metric IDs start with 'fact__'.",
55
+ "Use get_metrics without a metricId to list all available metrics.",
56
+ ]));
54
57
  }
55
58
  }
56
59
  try {
57
60
  const additionalParams = project ? { projectId: project } : undefined;
58
- const metricsData = await fetchWithPagination(baseApiUrl, apiKey, "/api/v1/metrics", limit, offset, mostRecent, additionalParams);
59
- const factMetricData = await fetchWithPagination(baseApiUrl, apiKey, "/api/v1/fact-metrics", limit, offset, mostRecent, additionalParams);
61
+ const metricsData = (await fetchWithPagination(baseApiUrl, apiKey, "/api/v1/metrics", limit, offset, mostRecent, additionalParams));
62
+ const factMetricData = (await fetchWithPagination(baseApiUrl, apiKey, "/api/v1/fact-metrics", limit, offset, mostRecent, additionalParams));
60
63
  // Reverse arrays for mostRecent to show newest-first
61
64
  if (mostRecent && offset === 0) {
62
65
  if (Array.isArray(metricsData.metrics)) {
@@ -66,16 +69,14 @@ export function registerMetricsTools({ server, baseApiUrl, apiKey, appOrigin, })
66
69
  factMetricData.factMetrics = factMetricData.factMetrics.reverse();
67
70
  }
68
71
  }
69
- const metricData = {
70
- metrics: metricsData,
71
- factMetrics: factMetricData,
72
- };
73
72
  return {
74
- content: [{ type: "text", text: JSON.stringify(metricData) }],
73
+ content: [{ type: "text", text: formatMetricsList(metricsData, factMetricData) }],
75
74
  };
76
75
  }
77
76
  catch (error) {
78
- throw new Error(`Error fetching metrics: ${error}`);
77
+ throw new Error(formatApiError(error, "fetching metrics", [
78
+ "Check that your GB_API_KEY has permission to read metrics.",
79
+ ]));
79
80
  }
80
81
  });
81
82
  }
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { paginationSchema, fetchWithPagination, } from "../utils.js";
3
+ import { formatProjects, formatApiError } from "../format-responses.js";
3
4
  /**
4
5
  * Tool: get_projects
5
6
  */
@@ -15,17 +16,18 @@ export function registerProjectTools({ server, baseApiUrl, apiKey, }) {
15
16
  },
16
17
  }, async ({ limit, offset, mostRecent }) => {
17
18
  try {
18
- const data = await fetchWithPagination(baseApiUrl, apiKey, "/api/v1/projects", limit, offset, mostRecent);
19
- // Reverse projects array for mostRecent to show newest-first
19
+ const data = (await fetchWithPagination(baseApiUrl, apiKey, "/api/v1/projects", limit, offset, mostRecent));
20
20
  if (mostRecent && offset === 0 && Array.isArray(data.projects)) {
21
21
  data.projects = data.projects.reverse();
22
22
  }
23
23
  return {
24
- content: [{ type: "text", text: JSON.stringify(data) }],
24
+ content: [{ type: "text", text: formatProjects(data) }],
25
25
  };
26
26
  }
27
27
  catch (error) {
28
- throw new Error(`Error fetching projects: ${error}`);
28
+ throw new Error(formatApiError(error, "fetching projects", [
29
+ "Check that your GB_API_KEY has permission to read projects.",
30
+ ]));
29
31
  }
30
32
  });
31
33
  }
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { handleResNotOk, paginationSchema, fetchWithRateLimit, fetchWithPagination, buildHeaders, } from "../utils.js";
3
+ import { formatSdkConnections, formatEnvironments, formatApiError } from "../format-responses.js";
3
4
  export function registerSdkConnectionTools({ server, baseApiUrl, apiKey, }) {
4
5
  /**
5
6
  * Tool: get_sdk_connections
@@ -19,17 +20,19 @@ export function registerSdkConnectionTools({ server, baseApiUrl, apiKey, }) {
19
20
  },
20
21
  }, async ({ limit, offset, mostRecent, project }) => {
21
22
  try {
22
- const data = await fetchWithPagination(baseApiUrl, apiKey, "/api/v1/sdk-connections", limit, offset, mostRecent, project ? { projectId: project } : undefined);
23
+ const data = (await fetchWithPagination(baseApiUrl, apiKey, "/api/v1/sdk-connections", limit, offset, mostRecent, project ? { projectId: project } : undefined));
23
24
  // Reverse connections array for mostRecent to show newest-first
24
25
  if (mostRecent && offset === 0 && Array.isArray(data.connections)) {
25
26
  data.connections = data.connections.reverse();
26
27
  }
27
28
  return {
28
- content: [{ type: "text", text: JSON.stringify(data) }],
29
+ content: [{ type: "text", text: formatSdkConnections(data) }],
29
30
  };
30
31
  }
31
32
  catch (error) {
32
- throw new Error(`Error fetching sdk connections: ${error}`);
33
+ throw new Error(formatApiError(error, "fetching SDK connections", [
34
+ "Check that your GB_API_KEY has permission to read SDK connections.",
35
+ ]));
33
36
  }
34
37
  });
35
38
  /**
@@ -89,12 +92,14 @@ export function registerSdkConnectionTools({ server, baseApiUrl, apiKey, }) {
89
92
  });
90
93
  await handleResNotOk(res);
91
94
  const data = await res.json();
92
- const text = `${JSON.stringify(data)}
93
-
94
- Here is the list of environments. Ask the user to select one and use the key in the create_sdk_connection tool.
95
- `;
96
95
  return {
97
- content: [{ type: "text", text }],
96
+ content: [
97
+ {
98
+ type: "text",
99
+ text: formatEnvironments(data) +
100
+ "\n\nAsk the user to select an environment, then call create_sdk_connection with the chosen environment id.",
101
+ },
102
+ ],
98
103
  };
99
104
  }
100
105
  catch (error) {
@@ -116,13 +121,20 @@ export function registerSdkConnectionTools({ server, baseApiUrl, apiKey, }) {
116
121
  body: JSON.stringify(payload),
117
122
  });
118
123
  await handleResNotOk(res);
119
- const data = await res.json();
124
+ const data = (await res.json());
125
+ const conn = data.sdkConnection;
126
+ const text = conn
127
+ ? `**SDK connection \`${conn.name}\` created.**\nClient key: \`${conn.key}\`\nEnvironment: ${conn.environment}\nLanguage(s): ${conn.languages.join(", ")}`
128
+ : `SDK connection created.\n${JSON.stringify(data)}`;
120
129
  return {
121
- content: [{ type: "text", text: JSON.stringify(data) }],
130
+ content: [{ type: "text", text }],
122
131
  };
123
132
  }
124
133
  catch (error) {
125
- throw new Error(`Error creating sdk connection: ${error}`);
134
+ throw new Error(formatApiError(error, "creating SDK connection", [
135
+ "Ensure the environment exists — use get_environments to check available environments.",
136
+ "Check that the language parameter is a valid SDK language.",
137
+ ]));
126
138
  }
127
139
  });
128
140
  }
package/server/utils.js CHANGED
@@ -516,3 +516,9 @@ export async function fetchWithPagination(baseApiUrl, apiKey, endpoint, limit, o
516
516
  await handleResNotOk(mostRecentRes);
517
517
  return await mostRecentRes.json();
518
518
  }
519
+ export function formatList(items) {
520
+ return new Intl.ListFormat("en", {
521
+ style: "long",
522
+ type: "conjunction",
523
+ }).format(items);
524
+ }