@growthbook/mcp 1.6.0 → 1.8.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.8.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"
@@ -38,12 +38,12 @@
38
38
  "author": "GrowthBook",
39
39
  "license": "MIT",
40
40
  "dependencies": {
41
- "@modelcontextprotocol/sdk": "^1.25.3",
41
+ "@modelcontextprotocol/sdk": "^1.27.1",
42
42
  "env-paths": "^4.0.0",
43
43
  "zod": "^4.3.6"
44
44
  },
45
45
  "devDependencies": {
46
- "@types/node": "^25.1.0",
46
+ "@types/node": "^25.3.5",
47
47
  "@vitest/coverage-v8": "^4.0.18",
48
48
  "typescript": "^5.9.2",
49
49
  "vitest": "^4.0.18"
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,521 @@
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
+ export function formatStaleFeatureFlags(data, requestedIds) {
429
+ const features = data.features || {};
430
+ const foundIds = Object.keys(features);
431
+ if (foundIds.length === 0) {
432
+ return "No features found for the given IDs. Check that the feature IDs are correct and your API key has access.";
433
+ }
434
+ const parts = [`**${foundIds.length} feature flag(s) checked:**`, ""];
435
+ let staleCount = 0;
436
+ for (const id of requestedIds) {
437
+ const f = features[id];
438
+ if (!f) {
439
+ parts.push(`- **\`${id}\`**: NOT FOUND`);
440
+ continue;
441
+ }
442
+ if (f.neverStale) {
443
+ parts.push(`- **\`${f.featureId}\`**: NOT STALE (stale detection disabled)`);
444
+ continue;
445
+ }
446
+ if (!f.isStale) {
447
+ parts.push(`- **\`${f.featureId}\`**: NOT STALE${f.staleReason ? ` (${f.staleReason})` : ""}`);
448
+ continue;
449
+ }
450
+ // ── Stale flag: include replacement guidance ──
451
+ staleCount++;
452
+ const envEntries = f.staleByEnv ? Object.entries(f.staleByEnv) : [];
453
+ const envsWithValues = envEntries.filter(([, e]) => e.evaluatesTo !== undefined);
454
+ let replacementValue;
455
+ let envNote;
456
+ if (envsWithValues.length === 0) {
457
+ replacementValue = undefined;
458
+ envNote =
459
+ "No deterministic value available — ask the user what the replacement should be.";
460
+ }
461
+ else {
462
+ const values = new Set(envsWithValues.map(([, e]) => e.evaluatesTo));
463
+ if (values.size === 1) {
464
+ replacementValue = envsWithValues[0][1].evaluatesTo;
465
+ envNote = `All environments agree.`;
466
+ }
467
+ else {
468
+ // Environments disagree — default to production
469
+ const prod = envsWithValues.find(([env]) => env === "production");
470
+ if (prod) {
471
+ replacementValue = prod[1].evaluatesTo;
472
+ const others = envsWithValues
473
+ .map(([env, e]) => `${env}=\`${e.evaluatesTo}\``)
474
+ .join(", ");
475
+ envNote = `Environments disagree (${others}). Using production value. Confirm with the user if a different environment should be used.`;
476
+ }
477
+ else {
478
+ replacementValue = envsWithValues[0][1].evaluatesTo;
479
+ const others = envsWithValues
480
+ .map(([env, e]) => `${env}=\`${e.evaluatesTo}\``)
481
+ .join(", ");
482
+ envNote = `Environments disagree (${others}). No production environment found, using ${envsWithValues[0][0]}. Confirm with the user which environment to use.`;
483
+ }
484
+ }
485
+ }
486
+ if (replacementValue !== undefined) {
487
+ parts.push(`- **\`${f.featureId}\`**: STALE (${f.staleReason}) — replace with: \`${replacementValue}\``);
488
+ }
489
+ else {
490
+ parts.push(`- **\`${f.featureId}\`**: STALE (${f.staleReason}) — needs manual review`);
491
+ }
492
+ parts.push(` ${envNote}`);
493
+ parts.push(` Search for \`${id}\` in relevant source files to find usages.`);
494
+ parts.push("");
495
+ }
496
+ // Summary
497
+ const notFound = requestedIds.filter((id) => !features[id]);
498
+ if (notFound.length > 0) {
499
+ parts.push(`${notFound.length} flag(s) not found: ${notFound.map((id) => `\`${id}\``).join(", ")}`);
500
+ }
501
+ if (staleCount > 0) {
502
+ 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.`);
503
+ }
504
+ else {
505
+ parts.push("No stale flags found. All checked features are active.");
506
+ }
507
+ return parts.join("\n");
508
+ }
509
+ // ─── Helpful Errors ─────────────────────────────────────────────────
510
+ export function formatApiError(error, context, suggestions) {
511
+ const message = error instanceof Error ? error.message : String(error);
512
+ const parts = [`Error ${context}: ${message}`];
513
+ if (suggestions && suggestions.length > 0) {
514
+ parts.push("");
515
+ parts.push("Suggestions:");
516
+ for (const s of suggestions) {
517
+ parts.push(`- ${s}`);
518
+ }
519
+ }
520
+ return parts.join("\n");
521
+ }
@@ -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
  };