@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 +0 -18
- 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 +14 -13
- package/server/tools/environments.js +6 -3
- package/server/tools/experiments/experiment-summary.js +1 -1
- package/server/tools/experiments/experiments.js +68 -42
- package/server/tools/features.js +72 -86
- package/server/tools/metrics.js +16 -15
- package/server/tools/projects.js +6 -4
- package/server/tools/sdk-connections.js +23 -11
- package/server/utils.js +6 -0
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.
|
|
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
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
|
|
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
|
|
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:
|
|
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:
|
|
23
|
+
content: [{ type: "text", text: formatEnvironments(data) }],
|
|
23
24
|
};
|
|
24
25
|
}
|
|
25
26
|
catch (error) {
|
|
26
|
-
throw new 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 {
|
|
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
|
-
|
|
38
|
+
const data = (await res.json());
|
|
39
|
+
const dataWithResult = data;
|
|
39
40
|
if (mode === "full") {
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
// Fetch results
|
|
42
|
+
if (data.experiment.status === "draft") {
|
|
43
|
+
dataWithResult.result = null;
|
|
42
44
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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(`
|
|
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:
|
|
161
|
+
content: [{ type: "text", text: formatExperimentList(data) }],
|
|
135
162
|
};
|
|
136
163
|
}
|
|
137
164
|
catch (error) {
|
|
138
|
-
throw new 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:
|
|
190
|
+
content: [{ type: "text", text: formatAttributes(data) }],
|
|
162
191
|
};
|
|
163
192
|
}
|
|
164
193
|
catch (error) {
|
|
165
|
-
throw new 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(`
|
|
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
|
}
|
package/server/tools/features.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { getDocsMetadata, handleResNotOk,
|
|
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: [
|
|
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(`
|
|
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: [
|
|
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(`
|
|
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: [
|
|
161
|
+
content: [
|
|
162
|
+
{ type: "text", text: formatFeatureFlagDetail(data, appOrigin) },
|
|
163
|
+
],
|
|
183
164
|
};
|
|
184
165
|
}
|
|
185
166
|
catch (error) {
|
|
186
|
-
throw new 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:
|
|
181
|
+
content: [{ type: "text", text: formatFeatureFlagList(data) }],
|
|
198
182
|
};
|
|
199
183
|
}
|
|
200
184
|
catch (error) {
|
|
201
|
-
throw new 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:
|
|
191
|
+
* Tool: get_stale_feature_flags
|
|
206
192
|
*/
|
|
207
|
-
server.registerTool("
|
|
208
|
-
title: "Get Stale
|
|
209
|
-
description: "
|
|
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
|
-
|
|
212
|
-
|
|
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 ({
|
|
205
|
+
}, async ({ featureIds }) => {
|
|
218
206
|
try {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
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(
|
|
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
|
/**
|
package/server/tools/metrics.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
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 =
|
|
40
|
-
|
|
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:
|
|
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(`
|
|
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:
|
|
73
|
+
content: [{ type: "text", text: formatMetricsList(metricsData, factMetricData) }],
|
|
75
74
|
};
|
|
76
75
|
}
|
|
77
76
|
catch (error) {
|
|
78
|
-
throw new 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
|
}
|
package/server/tools/projects.js
CHANGED
|
@@ -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:
|
|
24
|
+
content: [{ type: "text", text: formatProjects(data) }],
|
|
25
25
|
};
|
|
26
26
|
}
|
|
27
27
|
catch (error) {
|
|
28
|
-
throw new 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:
|
|
29
|
+
content: [{ type: "text", text: formatSdkConnections(data) }],
|
|
29
30
|
};
|
|
30
31
|
}
|
|
31
32
|
catch (error) {
|
|
32
|
-
throw new 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: [
|
|
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
|
|
130
|
+
content: [{ type: "text", text }],
|
|
122
131
|
};
|
|
123
132
|
}
|
|
124
133
|
catch (error) {
|
|
125
|
-
throw new 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
|
+
}
|