@growthbook/mcp 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -7
- package/{build → server}/index.js +11 -1
- package/server/prompts/experiment-analysis.js +13 -0
- package/{build → server}/tools/defaults.js +10 -2
- package/{build → server}/tools/environments.js +4 -4
- package/{build → server}/tools/experiments.js +87 -80
- package/{build → server}/tools/features.js +115 -41
- package/{build → server}/tools/metrics.js +13 -2
- package/{build → server}/tools/projects.js +3 -3
- package/{build → server}/tools/sdk-connections.js +20 -3
- package/{build → server}/tools/search.js +2 -0
- package/{build → server}/utils.js +19 -0
- /package/{build → server}/docs.js +0 -0
package/package.json
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@growthbook/mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "MCP Server for interacting with GrowthBook",
|
|
5
5
|
"access": "public",
|
|
6
6
|
"homepage": "https://github.com/growthbook/growthbook-mcp",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"test": "echo \"Error: no test specified\" && exit 1",
|
|
9
9
|
"build": "tsc",
|
|
10
|
-
"
|
|
11
|
-
"bump:
|
|
12
|
-
"bump:
|
|
10
|
+
"dev": "tsc --watch",
|
|
11
|
+
"bump:patch": "npm version patch --no-git-tag-version",
|
|
12
|
+
"bump:minor": "npm version minor --no-git-tag-version",
|
|
13
|
+
"bump:major": "npm version major --no-git-tag-version",
|
|
14
|
+
"mcpb:build": "npx -y @anthropic-ai/mcpb -- pack"
|
|
13
15
|
},
|
|
14
16
|
"bin": {
|
|
15
|
-
"mcp": "
|
|
17
|
+
"mcp": "server/index.js"
|
|
16
18
|
},
|
|
17
19
|
"files": [
|
|
18
|
-
"
|
|
20
|
+
"server"
|
|
19
21
|
],
|
|
20
22
|
"keywords": [
|
|
21
23
|
"growthbook",
|
|
@@ -26,7 +28,6 @@
|
|
|
26
28
|
],
|
|
27
29
|
"author": "GrowthBook",
|
|
28
30
|
"license": "MIT",
|
|
29
|
-
"packageManager": "pnpm@10.6.1",
|
|
30
31
|
"dependencies": {
|
|
31
32
|
"@modelcontextprotocol/sdk": "^1.17.2",
|
|
32
33
|
"env-paths": "^3.0.0",
|
|
@@ -10,6 +10,7 @@ import { getApiKey, getApiUrl, getAppOrigin } from "./utils.js";
|
|
|
10
10
|
import { registerSearchTools } from "./tools/search.js";
|
|
11
11
|
import { registerDefaultsTools } from "./tools/defaults.js";
|
|
12
12
|
import { registerMetricsTools } from "./tools/metrics.js";
|
|
13
|
+
import { registerExperimentAnalysisPrompt } from "./prompts/experiment-analysis.js";
|
|
13
14
|
export const baseApiUrl = getApiUrl();
|
|
14
15
|
export const apiKey = getApiKey();
|
|
15
16
|
export const appOrigin = getAppOrigin();
|
|
@@ -95,6 +96,15 @@ registerMetricsTools({
|
|
|
95
96
|
appOrigin,
|
|
96
97
|
user,
|
|
97
98
|
});
|
|
99
|
+
registerExperimentAnalysisPrompt({
|
|
100
|
+
server,
|
|
101
|
+
});
|
|
98
102
|
// Start receiving messages on stdin and sending messages on stdout
|
|
99
103
|
const transport = new StdioServerTransport();
|
|
100
|
-
|
|
104
|
+
try {
|
|
105
|
+
await server.connect(transport);
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
console.error(error);
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function registerExperimentAnalysisPrompt({ server, }) {
|
|
2
|
+
server.prompt("experiment-analysis", "Analyze recent experiments and give me actionable advice", () => ({
|
|
3
|
+
messages: [
|
|
4
|
+
{
|
|
5
|
+
role: "user",
|
|
6
|
+
content: {
|
|
7
|
+
type: "text",
|
|
8
|
+
text: "Use GrowthBook to fetch my recent experiments. Analyze the experiments and tell me:\n\n1. Which experiment types are actually worth running vs. theater?\n\n2. What's the one pattern in our losses that we're blind to?\n\n3. If you could only run 3 experiments next quarter based on these results, what would they be and why?\n\n4. What's the biggest methodological risk in our current approach that could be invalidating results?\n\nBe specific. Use the actual data. Don't give me generic advice.",
|
|
9
|
+
},
|
|
10
|
+
},
|
|
11
|
+
],
|
|
12
|
+
}));
|
|
13
|
+
}
|
|
@@ -249,7 +249,9 @@ export async function getDefaults(apiKey, baseApiUrl) {
|
|
|
249
249
|
* Tool: get_defaults
|
|
250
250
|
*/
|
|
251
251
|
export async function registerDefaultsTools({ server, baseApiUrl, apiKey, }) {
|
|
252
|
-
server.tool("get_defaults", "Get the default values for experiments, including hypothesis, description, datasource, assignment query, and environments.", {},
|
|
252
|
+
server.tool("get_defaults", "Get the default values for experiments, including hypothesis, description, datasource, assignment query, and environments.", {}, {
|
|
253
|
+
readOnlyHint: true,
|
|
254
|
+
}, async () => {
|
|
253
255
|
const defaults = await getDefaults(apiKey, baseApiUrl);
|
|
254
256
|
return {
|
|
255
257
|
content: [
|
|
@@ -268,6 +270,9 @@ export async function registerDefaultsTools({ server, baseApiUrl, apiKey, }) {
|
|
|
268
270
|
environments: z
|
|
269
271
|
.array(z.string())
|
|
270
272
|
.describe("List of environment IDs to use as defaults"),
|
|
273
|
+
}, {
|
|
274
|
+
readOnlyHint: false,
|
|
275
|
+
destructiveHint: true,
|
|
271
276
|
}, async ({ datasourceId, assignmentQueryId, environments }) => {
|
|
272
277
|
try {
|
|
273
278
|
const userDefaults = {
|
|
@@ -291,7 +296,10 @@ export async function registerDefaultsTools({ server, baseApiUrl, apiKey, }) {
|
|
|
291
296
|
throw new Error(`Error setting user defaults: ${error}`);
|
|
292
297
|
}
|
|
293
298
|
});
|
|
294
|
-
server.tool("clear_user_defaults", "Clear user-defined defaults and revert to automatic defaults.", {},
|
|
299
|
+
server.tool("clear_user_defaults", "Clear user-defined defaults and revert to automatic defaults.", {}, {
|
|
300
|
+
readOnlyHint: false,
|
|
301
|
+
destructiveHint: true,
|
|
302
|
+
}, async () => {
|
|
295
303
|
try {
|
|
296
304
|
await readFile(userDefaultsFile, "utf8");
|
|
297
305
|
await unlink(userDefaultsFile);
|
|
@@ -3,7 +3,9 @@ import { handleResNotOk } from "../utils.js";
|
|
|
3
3
|
* Tool: get_environments
|
|
4
4
|
*/
|
|
5
5
|
export function registerEnvironmentTools({ server, baseApiUrl, apiKey, }) {
|
|
6
|
-
server.tool("get_environments", "Fetches all environments from the GrowthBook API. GrowthBook comes with one environment by default (production), but you can add as many as you need. Feature flags can be enabled and disabled on a per-environment basis. You can also set the default feature state for any new environment. Additionally, you can scope environments to only be available in specific projects, allowing for further control and segmentation over feature delivery.", {},
|
|
6
|
+
server.tool("get_environments", "Fetches all environments from the GrowthBook API. GrowthBook comes with one environment by default (production), but you can add as many as you need. Feature flags can be enabled and disabled on a per-environment basis. You can also set the default feature state for any new environment. Additionally, you can scope environments to only be available in specific projects, allowing for further control and segmentation over feature delivery.", {}, {
|
|
7
|
+
readOnlyHint: true,
|
|
8
|
+
}, async () => {
|
|
7
9
|
try {
|
|
8
10
|
const res = await fetch(`${baseApiUrl}/api/v1/environments`, {
|
|
9
11
|
headers: {
|
|
@@ -18,9 +20,7 @@ export function registerEnvironmentTools({ server, baseApiUrl, apiKey, }) {
|
|
|
18
20
|
};
|
|
19
21
|
}
|
|
20
22
|
catch (error) {
|
|
21
|
-
|
|
22
|
-
content: [{ type: "text", text: `Error: ${error}` }],
|
|
23
|
-
};
|
|
23
|
+
throw new Error(`Error fetching environments: ${error}`);
|
|
24
24
|
}
|
|
25
25
|
});
|
|
26
26
|
}
|
|
@@ -6,8 +6,18 @@ export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin,
|
|
|
6
6
|
* Tool: get_experiments
|
|
7
7
|
*/
|
|
8
8
|
server.tool("get_experiments", "Fetches experiments from the GrowthBook API", {
|
|
9
|
+
project: z
|
|
10
|
+
.string()
|
|
11
|
+
.describe("The ID of the project to filter experiments by")
|
|
12
|
+
.optional(),
|
|
13
|
+
mode: z
|
|
14
|
+
.enum(["default", "analyze"])
|
|
15
|
+
.default("default")
|
|
16
|
+
.describe("The mode to use to fetch experiments. Default mode returns summary info about experiments. Analyze mode will also fetch experiment results, allowing for better analysis, interpretation, and reporting."),
|
|
9
17
|
...paginationSchema,
|
|
10
|
-
},
|
|
18
|
+
}, {
|
|
19
|
+
readOnlyHint: true,
|
|
20
|
+
}, async ({ limit, offset, mostRecent, project, mode }) => {
|
|
11
21
|
try {
|
|
12
22
|
// Default behavior
|
|
13
23
|
if (!mostRecent || offset > 0) {
|
|
@@ -15,6 +25,9 @@ export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin,
|
|
|
15
25
|
limit: limit.toString(),
|
|
16
26
|
offset: offset.toString(),
|
|
17
27
|
});
|
|
28
|
+
if (project) {
|
|
29
|
+
defaultQueryParams.append("projectId", project);
|
|
30
|
+
}
|
|
18
31
|
const defaultRes = await fetch(`${baseApiUrl}/api/v1/experiments?${defaultQueryParams.toString()}`, {
|
|
19
32
|
headers: {
|
|
20
33
|
Authorization: `Bearer ${apiKey}`,
|
|
@@ -23,6 +36,24 @@ export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin,
|
|
|
23
36
|
});
|
|
24
37
|
await handleResNotOk(defaultRes);
|
|
25
38
|
const data = await defaultRes.json();
|
|
39
|
+
const experiments = data.experiments;
|
|
40
|
+
if (mode === "analyze") {
|
|
41
|
+
for (const [index, experiment] of experiments.entries()) {
|
|
42
|
+
try {
|
|
43
|
+
const resultsRes = await fetch(`${baseApiUrl}/api/v1/experiments/${experiment.id}/results`, {
|
|
44
|
+
headers: {
|
|
45
|
+
Authorization: `Bearer ${apiKey}`,
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
await handleResNotOk(resultsRes);
|
|
49
|
+
const resultsData = await resultsRes.json();
|
|
50
|
+
experiments[index].result = resultsData.result;
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
console.error(`Error fetching results for experiment ${experiment.id} (${experiment.name})`, error);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
26
57
|
return {
|
|
27
58
|
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
28
59
|
};
|
|
@@ -41,6 +72,9 @@ export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin,
|
|
|
41
72
|
limit: limit.toString(),
|
|
42
73
|
offset: calculatedOffset.toString(),
|
|
43
74
|
});
|
|
75
|
+
if (project) {
|
|
76
|
+
mostRecentQueryParams.append("projectId", project);
|
|
77
|
+
}
|
|
44
78
|
const mostRecentRes = await fetch(`${baseApiUrl}/api/v1/experiments?${mostRecentQueryParams.toString()}`, {
|
|
45
79
|
headers: {
|
|
46
80
|
Authorization: `Bearer ${apiKey}`,
|
|
@@ -51,6 +85,23 @@ export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin,
|
|
|
51
85
|
if (mostRecentData.experiments &&
|
|
52
86
|
Array.isArray(mostRecentData.experiments)) {
|
|
53
87
|
mostRecentData.experiments = mostRecentData.experiments.reverse();
|
|
88
|
+
if (mode === "analyze") {
|
|
89
|
+
for (const [index, experiment,] of mostRecentData.experiments.entries()) {
|
|
90
|
+
try {
|
|
91
|
+
const resultsRes = await fetch(`${baseApiUrl}/api/v1/experiments/${experiment.id}/results`, {
|
|
92
|
+
headers: {
|
|
93
|
+
Authorization: `Bearer ${apiKey}`,
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
await handleResNotOk(resultsRes);
|
|
97
|
+
const resultsData = await resultsRes.json();
|
|
98
|
+
mostRecentData.experiments[index].result = resultsData.result;
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
console.error(`Error fetching results for experiment ${experiment.id} (${experiment.name})`, error);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
54
105
|
}
|
|
55
106
|
return {
|
|
56
107
|
content: [
|
|
@@ -62,87 +113,18 @@ export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin,
|
|
|
62
113
|
throw new Error(`Error fetching experiments: ${error}`);
|
|
63
114
|
}
|
|
64
115
|
});
|
|
65
|
-
/**
|
|
66
|
-
* Tool: create_force_rule
|
|
67
|
-
*/
|
|
68
|
-
server.tool("create_force_rule", "Create a new force rule on an existing feature. If the existing feature isn't apparent, create a new feature using create_feature_flag first. A force rule sets a feature to a specific value based on a condition. For A/B tests and experiments, use create_experiment instead.", {
|
|
69
|
-
featureId: z
|
|
70
|
-
.string()
|
|
71
|
-
.describe("The ID of the feature to create the rule on"),
|
|
72
|
-
description: z.string().optional(),
|
|
73
|
-
condition: z
|
|
74
|
-
.string()
|
|
75
|
-
.describe("Applied to everyone by default. Write conditions in MongoDB-style query syntax.")
|
|
76
|
-
.optional(),
|
|
77
|
-
value: z
|
|
78
|
-
.string()
|
|
79
|
-
.describe("The type of the value should match the feature type"),
|
|
80
|
-
fileExtension: z
|
|
81
|
-
.enum(SUPPORTED_FILE_EXTENSIONS)
|
|
82
|
-
.describe("The extension of the current file. If it's unclear, ask the user."),
|
|
83
|
-
}, async ({ featureId, description, condition, value, fileExtension }) => {
|
|
84
|
-
try {
|
|
85
|
-
// Fetch feature defaults first and surface to user
|
|
86
|
-
const defaults = await getDefaults(apiKey, baseApiUrl);
|
|
87
|
-
const defaultEnvironments = defaults.environments;
|
|
88
|
-
const payload = {
|
|
89
|
-
// Loop through the environments and create a rule for each one keyed by environment name
|
|
90
|
-
environments: defaultEnvironments.reduce((acc, env) => {
|
|
91
|
-
acc[env] = {
|
|
92
|
-
enabled: false,
|
|
93
|
-
rules: [
|
|
94
|
-
{
|
|
95
|
-
type: "force",
|
|
96
|
-
description,
|
|
97
|
-
condition,
|
|
98
|
-
value,
|
|
99
|
-
},
|
|
100
|
-
],
|
|
101
|
-
};
|
|
102
|
-
return acc;
|
|
103
|
-
}, {}),
|
|
104
|
-
};
|
|
105
|
-
const res = await fetch(`${baseApiUrl}/api/v1/features/${featureId}`, {
|
|
106
|
-
method: "POST",
|
|
107
|
-
headers: {
|
|
108
|
-
Authorization: `Bearer ${apiKey}`,
|
|
109
|
-
"Content-Type": "application/json",
|
|
110
|
-
},
|
|
111
|
-
body: JSON.stringify(payload),
|
|
112
|
-
});
|
|
113
|
-
await handleResNotOk(res);
|
|
114
|
-
const data = await res.json();
|
|
115
|
-
const linkToGrowthBook = generateLinkToGrowthBook(appOrigin, "features", featureId);
|
|
116
|
-
const { docs, language, stub } = getDocsMetadata(fileExtension);
|
|
117
|
-
const text = `This is the API response: ${JSON.stringify(data, null, 2)}
|
|
118
|
-
|
|
119
|
-
Additionally, here is a template of what to show to the user:
|
|
120
|
-
|
|
121
|
-
**✅ Your feature flag \`my-flag-name\` is ready!.**
|
|
122
|
-
[View it in GrowthBook](${linkToGrowthBook})
|
|
123
|
-
|
|
124
|
-
**How to use it in your code:**
|
|
125
|
-
(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)
|
|
126
|
-
|
|
127
|
-
${stub}
|
|
128
|
-
|
|
129
|
-
**Want to learn more?**
|
|
130
|
-
See the [GrowthBook ${language} docs](${docs})
|
|
131
|
-
`;
|
|
132
|
-
return {
|
|
133
|
-
content: [{ type: "text", text }],
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
catch (error) {
|
|
137
|
-
throw new Error(`Error creating force rule: ${error}`);
|
|
138
|
-
}
|
|
139
|
-
});
|
|
140
116
|
/**
|
|
141
117
|
* Tool: get_experiment
|
|
142
118
|
*/
|
|
143
119
|
server.tool("get_experiment", "Gets a single experiment from GrowthBook", {
|
|
144
120
|
experimentId: z.string().describe("The ID of the experiment to get"),
|
|
145
|
-
|
|
121
|
+
mode: z
|
|
122
|
+
.enum(["default", "analyze"])
|
|
123
|
+
.default("default")
|
|
124
|
+
.describe("The mode to use to fetch the experiment. Default mode returns summary info about the experiment. Analyze mode will also fetch experiment results, allowing for better analysis, interpretation, and reporting."),
|
|
125
|
+
}, {
|
|
126
|
+
readOnlyHint: true,
|
|
127
|
+
}, async ({ experimentId, mode }) => {
|
|
146
128
|
try {
|
|
147
129
|
const res = await fetch(`${baseApiUrl}/api/v1/experiments/${experimentId}`, {
|
|
148
130
|
headers: {
|
|
@@ -152,6 +134,22 @@ export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin,
|
|
|
152
134
|
});
|
|
153
135
|
await handleResNotOk(res);
|
|
154
136
|
const data = await res.json();
|
|
137
|
+
// If analyze mode, fetch results
|
|
138
|
+
if (mode === "analyze") {
|
|
139
|
+
try {
|
|
140
|
+
const resultsRes = await fetch(`${baseApiUrl}/api/v1/experiments/${experimentId}/results`, {
|
|
141
|
+
headers: {
|
|
142
|
+
Authorization: `Bearer ${apiKey}`,
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
await handleResNotOk(resultsRes);
|
|
146
|
+
const resultsData = await resultsRes.json();
|
|
147
|
+
data.result = resultsData.result;
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
console.error(`Error fetching results for experiment ${experimentId}`, error);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
155
153
|
const linkToGrowthBook = generateLinkToGrowthBook(appOrigin, "experiment", experimentId);
|
|
156
154
|
const text = `
|
|
157
155
|
${JSON.stringify(data, null, 2)}
|
|
@@ -169,7 +167,9 @@ export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin,
|
|
|
169
167
|
/**
|
|
170
168
|
* Tool: get_attributes
|
|
171
169
|
*/
|
|
172
|
-
server.tool("get_attributes", "Get all attributes", {},
|
|
170
|
+
server.tool("get_attributes", "Get all attributes", {}, {
|
|
171
|
+
readOnlyHint: true,
|
|
172
|
+
}, async () => {
|
|
173
173
|
try {
|
|
174
174
|
const queryParams = new URLSearchParams();
|
|
175
175
|
queryParams.append("limit", "100");
|
|
@@ -201,7 +201,6 @@ export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin,
|
|
|
201
201
|
.string()
|
|
202
202
|
.optional()
|
|
203
203
|
.describe("Experiment hypothesis. Base hypothesis off the examples from get_defaults. If none are available, use a falsifiable statement about what will happen if the experiment succeeds or fails."),
|
|
204
|
-
value: z.string().describe("The default value of the experiment."),
|
|
205
204
|
variations: z
|
|
206
205
|
.array(z.object({
|
|
207
206
|
name: z
|
|
@@ -217,13 +216,20 @@ export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin,
|
|
|
217
216
|
.describe("The value of the control and each of the variations. The value should be a string, number, boolean, or object. If it's an object, it should be a valid JSON object."),
|
|
218
217
|
}))
|
|
219
218
|
.describe("Experiment variations. The key should be the variation name and the value should be the variation value. Look to variations included in preview experiments for guidance on generation. The default or control variation should always be first."),
|
|
219
|
+
project: z
|
|
220
|
+
.string()
|
|
221
|
+
.describe("The ID of the project to create the experiment in")
|
|
222
|
+
.optional(),
|
|
220
223
|
fileExtension: z
|
|
221
224
|
.enum(SUPPORTED_FILE_EXTENSIONS)
|
|
222
225
|
.describe("The extension of the current file. If it's unclear, ask the user."),
|
|
223
226
|
confirmedDefaultsReviewed: z
|
|
224
227
|
.boolean()
|
|
225
228
|
.describe("Set to true to confirm you have called get_defaults and reviewed the output to guide these parameters."),
|
|
226
|
-
},
|
|
229
|
+
}, {
|
|
230
|
+
readOnlyHint: false,
|
|
231
|
+
destructiveHint: false,
|
|
232
|
+
}, async ({ description, hypothesis, name, variations, fileExtension, confirmedDefaultsReviewed, project, }) => {
|
|
227
233
|
if (!confirmedDefaultsReviewed) {
|
|
228
234
|
return {
|
|
229
235
|
content: [
|
|
@@ -249,6 +255,7 @@ export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin,
|
|
|
249
255
|
key: idx.toString(),
|
|
250
256
|
name: variation.name,
|
|
251
257
|
})),
|
|
258
|
+
...(project && { project }),
|
|
252
259
|
};
|
|
253
260
|
try {
|
|
254
261
|
const experimentRes = await fetch(`${baseApiUrl}/api/v1/experiments`, {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { getDocsMetadata, handleResNotOk, generateLinkToGrowthBook,
|
|
2
|
+
import { getDocsMetadata, handleResNotOk, generateLinkToGrowthBook, paginationSchema, featureFlagSchema, } from "../utils.js";
|
|
3
3
|
import { exec } from "child_process";
|
|
4
4
|
import { getDefaults } from "./defaults.js";
|
|
5
5
|
export function registerFeatureTools({ server, baseApiUrl, apiKey, appOrigin, user, }) {
|
|
@@ -7,25 +7,16 @@ export function registerFeatureTools({ server, baseApiUrl, apiKey, appOrigin, us
|
|
|
7
7
|
* Tool: create_feature_flag
|
|
8
8
|
*/
|
|
9
9
|
server.tool("create_feature_flag", "Creates a new feature flag in GrowthBook and modifies the codebase when relevant.", {
|
|
10
|
-
id:
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
.enum(["string", "number", "boolean", "json"])
|
|
21
|
-
.describe("The value type the feature flag will return"),
|
|
22
|
-
defaultValue: z
|
|
23
|
-
.string()
|
|
24
|
-
.describe("The default value of the feature flag"),
|
|
25
|
-
fileExtension: z
|
|
26
|
-
.enum(SUPPORTED_FILE_EXTENSIONS)
|
|
27
|
-
.describe("The extension of the current file. If it's unclear, ask the user."),
|
|
28
|
-
}, async ({ id, description, valueType, defaultValue, fileExtension }) => {
|
|
10
|
+
id: featureFlagSchema.id,
|
|
11
|
+
valueType: featureFlagSchema.valueType,
|
|
12
|
+
defaultValue: featureFlagSchema.defaultValue,
|
|
13
|
+
description: featureFlagSchema.description.optional().default(""),
|
|
14
|
+
project: featureFlagSchema.project.optional(),
|
|
15
|
+
fileExtension: featureFlagSchema.fileExtension,
|
|
16
|
+
}, {
|
|
17
|
+
readOnlyHint: false,
|
|
18
|
+
destructiveHint: false,
|
|
19
|
+
}, async ({ id, valueType, defaultValue, description, project, fileExtension, }) => {
|
|
29
20
|
// get environments
|
|
30
21
|
let environments = [];
|
|
31
22
|
const defaults = await getDefaults(apiKey, baseApiUrl);
|
|
@@ -57,6 +48,7 @@ export function registerFeatureTools({ server, baseApiUrl, apiKey, appOrigin, us
|
|
|
57
48
|
};
|
|
58
49
|
return acc;
|
|
59
50
|
}, {}),
|
|
51
|
+
...(project && { project }),
|
|
60
52
|
};
|
|
61
53
|
try {
|
|
62
54
|
const res = await fetch(`${baseApiUrl}/api/v1/features`, {
|
|
@@ -72,6 +64,79 @@ export function registerFeatureTools({ server, baseApiUrl, apiKey, appOrigin, us
|
|
|
72
64
|
const { docs, language, stub } = getDocsMetadata(fileExtension);
|
|
73
65
|
const linkToGrowthBook = generateLinkToGrowthBook(appOrigin, "features", id);
|
|
74
66
|
const text = `This is the API response: ${JSON.stringify(data, null, 2)}
|
|
67
|
+
|
|
68
|
+
Additionally, here is a template of what to show to the user:
|
|
69
|
+
|
|
70
|
+
**✅ Your feature flag \`my-flag-name\` is ready!**
|
|
71
|
+
[View it in GrowthBook](${linkToGrowthBook})
|
|
72
|
+
|
|
73
|
+
**How to use it in your code:**
|
|
74
|
+
(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)
|
|
75
|
+
|
|
76
|
+
${stub}
|
|
77
|
+
|
|
78
|
+
**Want to learn more?**
|
|
79
|
+
See the [GrowthBook ${language} docs](${docs})
|
|
80
|
+
`;
|
|
81
|
+
return {
|
|
82
|
+
content: [{ type: "text", text }],
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
throw new Error(`Error creating feature flag: ${error}`);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
/**
|
|
90
|
+
* Tool: create_force_rule
|
|
91
|
+
*/
|
|
92
|
+
server.tool("create_force_rule", "Create a new force rule on an existing feature. If the existing feature isn't apparent, create a new feature using create_feature_flag first. A force rule sets a feature to a specific value based on a condition. For A/B tests and experiments, use create_experiment instead.", {
|
|
93
|
+
featureId: featureFlagSchema.id,
|
|
94
|
+
description: featureFlagSchema.description.optional().default(""),
|
|
95
|
+
fileExtension: featureFlagSchema.fileExtension,
|
|
96
|
+
condition: z
|
|
97
|
+
.string()
|
|
98
|
+
.describe("Applied to everyone by default. Write conditions in MongoDB-style query syntax.")
|
|
99
|
+
.optional(),
|
|
100
|
+
value: z
|
|
101
|
+
.string()
|
|
102
|
+
.describe("The type of the value should match the feature type"),
|
|
103
|
+
}, {
|
|
104
|
+
readOnlyHint: false,
|
|
105
|
+
}, async ({ featureId, description, condition, value, fileExtension }) => {
|
|
106
|
+
try {
|
|
107
|
+
// Fetch feature defaults first and surface to user
|
|
108
|
+
const defaults = await getDefaults(apiKey, baseApiUrl);
|
|
109
|
+
const defaultEnvironments = defaults.environments;
|
|
110
|
+
const payload = {
|
|
111
|
+
// Loop through the environments and create a rule for each one keyed by environment name
|
|
112
|
+
environments: defaultEnvironments.reduce((acc, env) => {
|
|
113
|
+
acc[env] = {
|
|
114
|
+
enabled: false,
|
|
115
|
+
rules: [
|
|
116
|
+
{
|
|
117
|
+
type: "force",
|
|
118
|
+
description,
|
|
119
|
+
condition,
|
|
120
|
+
value,
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
};
|
|
124
|
+
return acc;
|
|
125
|
+
}, {}),
|
|
126
|
+
};
|
|
127
|
+
const res = await fetch(`${baseApiUrl}/api/v1/features/${featureId}`, {
|
|
128
|
+
method: "POST",
|
|
129
|
+
headers: {
|
|
130
|
+
Authorization: `Bearer ${apiKey}`,
|
|
131
|
+
"Content-Type": "application/json",
|
|
132
|
+
},
|
|
133
|
+
body: JSON.stringify(payload),
|
|
134
|
+
});
|
|
135
|
+
await handleResNotOk(res);
|
|
136
|
+
const data = await res.json();
|
|
137
|
+
const linkToGrowthBook = generateLinkToGrowthBook(appOrigin, "features", featureId);
|
|
138
|
+
const { docs, language, stub } = getDocsMetadata(fileExtension);
|
|
139
|
+
const text = `This is the API response: ${JSON.stringify(data, null, 2)}
|
|
75
140
|
|
|
76
141
|
Additionally, here is a template of what to show to the user:
|
|
77
142
|
|
|
@@ -91,20 +156,26 @@ export function registerFeatureTools({ server, baseApiUrl, apiKey, appOrigin, us
|
|
|
91
156
|
};
|
|
92
157
|
}
|
|
93
158
|
catch (error) {
|
|
94
|
-
throw new Error(`Error creating
|
|
159
|
+
throw new Error(`Error creating force rule: ${error}`);
|
|
95
160
|
}
|
|
96
161
|
});
|
|
97
162
|
/**
|
|
98
163
|
* Tool: get_feature_flags
|
|
99
164
|
*/
|
|
100
165
|
server.tool("get_feature_flags", "Fetches all feature flags from the GrowthBook API, with optional limit, offset, and project filtering.", {
|
|
166
|
+
project: featureFlagSchema.project.optional(),
|
|
101
167
|
...paginationSchema,
|
|
102
|
-
},
|
|
168
|
+
}, {
|
|
169
|
+
readOnlyHint: true,
|
|
170
|
+
}, async ({ limit, offset, project }) => {
|
|
103
171
|
try {
|
|
104
172
|
const queryParams = new URLSearchParams({
|
|
105
173
|
limit: limit?.toString(),
|
|
106
174
|
offset: offset?.toString(),
|
|
107
175
|
});
|
|
176
|
+
if (project) {
|
|
177
|
+
queryParams.append("projectId", project);
|
|
178
|
+
}
|
|
108
179
|
const res = await fetch(`${baseApiUrl}/api/v1/features?${queryParams.toString()}`, {
|
|
109
180
|
headers: {
|
|
110
181
|
Authorization: `Bearer ${apiKey}`,
|
|
@@ -125,14 +196,12 @@ export function registerFeatureTools({ server, baseApiUrl, apiKey, appOrigin, us
|
|
|
125
196
|
* Tool: get_single_feature_flag
|
|
126
197
|
*/
|
|
127
198
|
server.tool("get_single_feature_flag", "Fetches a specific feature flag from the GrowthBook API", {
|
|
128
|
-
id:
|
|
129
|
-
|
|
130
|
-
|
|
199
|
+
id: featureFlagSchema.id,
|
|
200
|
+
}, {
|
|
201
|
+
readOnlyHint: true,
|
|
202
|
+
}, async ({ id }) => {
|
|
131
203
|
try {
|
|
132
|
-
const
|
|
133
|
-
if (project)
|
|
134
|
-
queryParams.append("project", project);
|
|
135
|
-
const res = await fetch(`${baseApiUrl}/api/v1/features/${id}?${queryParams.toString()}`, {
|
|
204
|
+
const res = await fetch(`${baseApiUrl}/api/v1/features/${id}`, {
|
|
136
205
|
headers: {
|
|
137
206
|
Authorization: `Bearer ${apiKey}`,
|
|
138
207
|
"Content-Type": "application/json",
|
|
@@ -143,11 +212,11 @@ export function registerFeatureTools({ server, baseApiUrl, apiKey, appOrigin, us
|
|
|
143
212
|
const linkToGrowthBook = generateLinkToGrowthBook(appOrigin, "features", id);
|
|
144
213
|
const text = `
|
|
145
214
|
${JSON.stringify(data.feature, null, 2)}
|
|
146
|
-
|
|
215
|
+
|
|
147
216
|
Share information about the feature flag with the user. In particular, give details about the enabled environments,
|
|
148
|
-
rules for each environment, and the default value. If the feature flag is archived or doesnt exist, inform the user and
|
|
149
|
-
ask if they want to remove references to the feature flag from the codebase.
|
|
150
|
-
|
|
217
|
+
rules for each environment, and the default value. If the feature flag is archived or doesnt exist, inform the user and
|
|
218
|
+
ask if they want to remove references to the feature flag from the codebase.
|
|
219
|
+
|
|
151
220
|
[View it in GrowthBook](${linkToGrowthBook})
|
|
152
221
|
`;
|
|
153
222
|
return {
|
|
@@ -164,6 +233,8 @@ export function registerFeatureTools({ server, baseApiUrl, apiKey, appOrigin, us
|
|
|
164
233
|
server.tool("get_stale_safe_rollouts", "Fetches all complete safe rollouts (rolled-back or released) from the GrowthBook API", {
|
|
165
234
|
limit: z.number().optional().default(100),
|
|
166
235
|
offset: z.number().optional().default(0),
|
|
236
|
+
}, {
|
|
237
|
+
readOnlyHint: true,
|
|
167
238
|
}, async ({ limit, offset }) => {
|
|
168
239
|
try {
|
|
169
240
|
const queryParams = new URLSearchParams({
|
|
@@ -193,14 +264,14 @@ export function registerFeatureTools({ server, baseApiUrl, apiKey, appOrigin, us
|
|
|
193
264
|
});
|
|
194
265
|
});
|
|
195
266
|
const text = `
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
267
|
+
${JSON.stringify(filteredSafeRollouts, null, 2)}
|
|
268
|
+
|
|
269
|
+
Share information about the rolled-back or released safe rollout rules with the user. Safe Rollout rules are stored under
|
|
270
|
+
environmentSettings, keyed by environment and are within the rules array with a type of "safe-rollout". Ask the user if they
|
|
271
|
+
would like to remove references to the feature associated with the rolled-back or released safe rollout rules and if they do,
|
|
272
|
+
remove the references and associated GrowthBook code and replace the values with controlValue if the safe rollout rule is rolled-back or with the
|
|
273
|
+
variationValue if the safe rollout is released. In addition to the current file, you may need to update other files in the codebase.
|
|
274
|
+
`;
|
|
204
275
|
return {
|
|
205
276
|
content: [{ type: "text", text }],
|
|
206
277
|
};
|
|
@@ -216,6 +287,9 @@ export function registerFeatureTools({ server, baseApiUrl, apiKey, appOrigin, us
|
|
|
216
287
|
currentWorkingDirectory: z
|
|
217
288
|
.string()
|
|
218
289
|
.describe("The current working directory of the user's project"),
|
|
290
|
+
}, {
|
|
291
|
+
readOnlyHint: false,
|
|
292
|
+
idempotentHint: true,
|
|
219
293
|
}, async ({ currentWorkingDirectory }) => {
|
|
220
294
|
function runCommand(command, cwd) {
|
|
221
295
|
return new Promise((resolve, reject) => {
|
|
@@ -4,14 +4,23 @@ export function registerMetricsTools({ server, baseApiUrl, apiKey, appOrigin, })
|
|
|
4
4
|
/**
|
|
5
5
|
* Tool: get_metrics
|
|
6
6
|
*/
|
|
7
|
-
server.tool("get_metrics", "Fetches metrics from the GrowthBook API", {
|
|
7
|
+
server.tool("get_metrics", "Fetches metrics from the GrowthBook API, with optional limit, offset, and project filtering.", {
|
|
8
|
+
project: z
|
|
9
|
+
.string()
|
|
10
|
+
.describe("The ID of the project to filter metrics by")
|
|
11
|
+
.optional(),
|
|
8
12
|
...paginationSchema,
|
|
9
|
-
},
|
|
13
|
+
}, {
|
|
14
|
+
readOnlyHint: true,
|
|
15
|
+
}, async ({ limit, offset, project }) => {
|
|
10
16
|
try {
|
|
11
17
|
const queryParams = new URLSearchParams({
|
|
12
18
|
limit: limit?.toString(),
|
|
13
19
|
offset: offset?.toString(),
|
|
14
20
|
});
|
|
21
|
+
if (project) {
|
|
22
|
+
queryParams.append("projectId", project);
|
|
23
|
+
}
|
|
15
24
|
const metricsRes = await fetch(`${baseApiUrl}/api/v1/metrics?${queryParams.toString()}`, {
|
|
16
25
|
headers: {
|
|
17
26
|
Authorization: `Bearer ${apiKey}`,
|
|
@@ -47,6 +56,8 @@ export function registerMetricsTools({ server, baseApiUrl, apiKey, appOrigin, })
|
|
|
47
56
|
*/
|
|
48
57
|
server.tool("get_metric", "Fetches a metric from the GrowthBook API", {
|
|
49
58
|
metricId: z.string().describe("The ID of the metric to get"),
|
|
59
|
+
}, {
|
|
60
|
+
readOnlyHint: true,
|
|
50
61
|
}, async ({ metricId }) => {
|
|
51
62
|
try {
|
|
52
63
|
let res;
|
|
@@ -5,6 +5,8 @@ import { handleResNotOk, paginationSchema, } from "../utils.js";
|
|
|
5
5
|
export function registerProjectTools({ server, baseApiUrl, apiKey, }) {
|
|
6
6
|
server.tool("get_projects", "Fetches all projects from the GrowthBook API", {
|
|
7
7
|
...paginationSchema,
|
|
8
|
+
}, {
|
|
9
|
+
readOnlyHint: true,
|
|
8
10
|
}, async ({ limit, offset }) => {
|
|
9
11
|
const queryParams = new URLSearchParams({
|
|
10
12
|
limit: limit.toString(),
|
|
@@ -24,9 +26,7 @@ export function registerProjectTools({ server, baseApiUrl, apiKey, }) {
|
|
|
24
26
|
};
|
|
25
27
|
}
|
|
26
28
|
catch (error) {
|
|
27
|
-
|
|
28
|
-
content: [{ type: "text", text: `Error: ${error}` }],
|
|
29
|
-
};
|
|
29
|
+
throw new Error(`Error fetching projects: ${error}`);
|
|
30
30
|
}
|
|
31
31
|
});
|
|
32
32
|
}
|
|
@@ -5,13 +5,22 @@ export function registerSdkConnectionTools({ server, baseApiUrl, apiKey, }) {
|
|
|
5
5
|
* Tool: get_sdk_connections
|
|
6
6
|
*/
|
|
7
7
|
server.tool("get_sdk_connections", "Get all SDK connections. SDK connections are how GrowthBook connects to an app. Users need the client key to fetch features and experiments from the API.", {
|
|
8
|
+
project: z
|
|
9
|
+
.string()
|
|
10
|
+
.describe("The ID of the project to filter SDK connections by")
|
|
11
|
+
.optional(),
|
|
8
12
|
...paginationSchema,
|
|
9
|
-
},
|
|
13
|
+
}, {
|
|
14
|
+
readOnlyHint: true,
|
|
15
|
+
}, async ({ limit, offset, project }) => {
|
|
10
16
|
try {
|
|
11
17
|
const queryParams = new URLSearchParams({
|
|
12
18
|
limit: limit?.toString(),
|
|
13
19
|
offset: offset?.toString(),
|
|
14
20
|
});
|
|
21
|
+
if (project) {
|
|
22
|
+
queryParams.append("projectId", project);
|
|
23
|
+
}
|
|
15
24
|
const res = await fetch(`${baseApiUrl}/api/v1/sdk-connections?${queryParams.toString()}`, {
|
|
16
25
|
headers: {
|
|
17
26
|
Authorization: `Bearer ${apiKey}`,
|
|
@@ -60,12 +69,19 @@ export function registerSdkConnectionTools({ server, baseApiUrl, apiKey, }) {
|
|
|
60
69
|
"edge-other",
|
|
61
70
|
"other",
|
|
62
71
|
])
|
|
63
|
-
.describe("The language
|
|
72
|
+
.describe("The language or platform for the SDK connection."),
|
|
64
73
|
environment: z
|
|
65
74
|
.string()
|
|
66
75
|
.optional()
|
|
67
76
|
.describe("The environment associated with the SDK connection."),
|
|
68
|
-
|
|
77
|
+
projects: z
|
|
78
|
+
.array(z.string())
|
|
79
|
+
.describe("The projects to create the SDK connection in")
|
|
80
|
+
.optional(),
|
|
81
|
+
}, {
|
|
82
|
+
readOnlyHint: false,
|
|
83
|
+
destructiveHint: false,
|
|
84
|
+
}, async ({ name, language, environment, projects }) => {
|
|
69
85
|
if (!environment) {
|
|
70
86
|
try {
|
|
71
87
|
const res = await fetch(`${baseApiUrl}/api/v1/environments`, {
|
|
@@ -94,6 +110,7 @@ export function registerSdkConnectionTools({ server, baseApiUrl, apiKey, }) {
|
|
|
94
110
|
name,
|
|
95
111
|
language,
|
|
96
112
|
environment,
|
|
113
|
+
...(projects && { projects }),
|
|
97
114
|
};
|
|
98
115
|
try {
|
|
99
116
|
const res = await fetch(`${baseApiUrl}/api/v1/sdk-connections`, {
|
|
@@ -8,6 +8,8 @@ export function registerSearchTools({ server }) {
|
|
|
8
8
|
query: z
|
|
9
9
|
.string()
|
|
10
10
|
.describe("The search query to look up in the GrowthBook docs."),
|
|
11
|
+
}, {
|
|
12
|
+
readOnlyHint: true,
|
|
11
13
|
}, async ({ query }) => {
|
|
12
14
|
const hits = await searchGrowthBookDocs(query);
|
|
13
15
|
return {
|
|
@@ -191,3 +191,22 @@ export const paginationSchema = {
|
|
|
191
191
|
.default(false)
|
|
192
192
|
.describe("When true, fetches the most recent items and returns them newest-first. When false (default), returns oldest items first."),
|
|
193
193
|
};
|
|
194
|
+
export const featureFlagSchema = {
|
|
195
|
+
id: z
|
|
196
|
+
.string()
|
|
197
|
+
.regex(/^[a-zA-Z0-9_.:|_-]+$/, "Feature key can only include letters, numbers, and the characters _, -, ., :, and |")
|
|
198
|
+
.describe("A unique key name for the feature"),
|
|
199
|
+
valueType: z
|
|
200
|
+
.enum(["string", "number", "boolean", "json"])
|
|
201
|
+
.describe("The value type the feature flag will return"),
|
|
202
|
+
defaultValue: z.string().describe("The default value of the feature flag"),
|
|
203
|
+
description: z.string().describe("A brief description of the feature flag"),
|
|
204
|
+
archived: z.boolean().describe("Whether the feature flag should be archived"),
|
|
205
|
+
project: z
|
|
206
|
+
.string()
|
|
207
|
+
.describe("The ID of the project to which the feature flag belongs"),
|
|
208
|
+
// Contextual info
|
|
209
|
+
fileExtension: z
|
|
210
|
+
.enum(SUPPORTED_FILE_EXTENSIONS)
|
|
211
|
+
.describe("The extension of the current file. If it's unclear, ask the user."),
|
|
212
|
+
};
|
|
File without changes
|