@growthbook/mcp 1.2.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@growthbook/mcp",
3
- "version": "1.2.0",
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",
@@ -8,15 +8,16 @@
8
8
  "test": "echo \"Error: no test specified\" && exit 1",
9
9
  "build": "tsc",
10
10
  "dev": "tsc --watch",
11
- "bump:patch": "pnpm version patch --no-git-tag-version",
12
- "bump:minor": "pnpm version minor --no-git-tag-version",
13
- "bump:major": "pnpm version major --no-git-tag-version"
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"
14
15
  },
15
16
  "bin": {
16
- "mcp": "build/index.js"
17
+ "mcp": "server/index.js"
17
18
  },
18
19
  "files": [
19
- "build"
20
+ "server"
20
21
  ],
21
22
  "keywords": [
22
23
  "growthbook",
@@ -27,7 +28,6 @@
27
28
  ],
28
29
  "author": "GrowthBook",
29
30
  "license": "MIT",
30
- "packageManager": "pnpm@10.6.1",
31
31
  "dependencies": {
32
32
  "@modelcontextprotocol/sdk": "^1.17.2",
33
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
- await server.connect(transport);
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.", {}, async () => {
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.", {}, async () => {
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.", {}, async () => {
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: {
@@ -10,8 +10,14 @@ export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin,
10
10
  .string()
11
11
  .describe("The ID of the project to filter experiments by")
12
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."),
13
17
  ...paginationSchema,
14
- }, async ({ limit, offset, mostRecent, project }) => {
18
+ }, {
19
+ readOnlyHint: true,
20
+ }, async ({ limit, offset, mostRecent, project, mode }) => {
15
21
  try {
16
22
  // Default behavior
17
23
  if (!mostRecent || offset > 0) {
@@ -30,6 +36,24 @@ export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin,
30
36
  });
31
37
  await handleResNotOk(defaultRes);
32
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
+ }
33
57
  return {
34
58
  content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
35
59
  };
@@ -61,6 +85,23 @@ export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin,
61
85
  if (mostRecentData.experiments &&
62
86
  Array.isArray(mostRecentData.experiments)) {
63
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
+ }
64
105
  }
65
106
  return {
66
107
  content: [
@@ -77,7 +118,13 @@ export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin,
77
118
  */
78
119
  server.tool("get_experiment", "Gets a single experiment from GrowthBook", {
79
120
  experimentId: z.string().describe("The ID of the experiment to get"),
80
- }, async ({ experimentId }) => {
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 }) => {
81
128
  try {
82
129
  const res = await fetch(`${baseApiUrl}/api/v1/experiments/${experimentId}`, {
83
130
  headers: {
@@ -87,6 +134,22 @@ export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin,
87
134
  });
88
135
  await handleResNotOk(res);
89
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
+ }
90
153
  const linkToGrowthBook = generateLinkToGrowthBook(appOrigin, "experiment", experimentId);
91
154
  const text = `
92
155
  ${JSON.stringify(data, null, 2)}
@@ -104,7 +167,9 @@ export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin,
104
167
  /**
105
168
  * Tool: get_attributes
106
169
  */
107
- server.tool("get_attributes", "Get all attributes", {}, async () => {
170
+ server.tool("get_attributes", "Get all attributes", {}, {
171
+ readOnlyHint: true,
172
+ }, async () => {
108
173
  try {
109
174
  const queryParams = new URLSearchParams();
110
175
  queryParams.append("limit", "100");
@@ -161,6 +226,9 @@ export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin,
161
226
  confirmedDefaultsReviewed: z
162
227
  .boolean()
163
228
  .describe("Set to true to confirm you have called get_defaults and reviewed the output to guide these parameters."),
229
+ }, {
230
+ readOnlyHint: false,
231
+ destructiveHint: false,
164
232
  }, async ({ description, hypothesis, name, variations, fileExtension, confirmedDefaultsReviewed, project, }) => {
165
233
  if (!confirmedDefaultsReviewed) {
166
234
  return {
@@ -13,6 +13,9 @@ export function registerFeatureTools({ server, baseApiUrl, apiKey, appOrigin, us
13
13
  description: featureFlagSchema.description.optional().default(""),
14
14
  project: featureFlagSchema.project.optional(),
15
15
  fileExtension: featureFlagSchema.fileExtension,
16
+ }, {
17
+ readOnlyHint: false,
18
+ destructiveHint: false,
16
19
  }, async ({ id, valueType, defaultValue, description, project, fileExtension, }) => {
17
20
  // get environments
18
21
  let environments = [];
@@ -97,6 +100,8 @@ export function registerFeatureTools({ server, baseApiUrl, apiKey, appOrigin, us
97
100
  value: z
98
101
  .string()
99
102
  .describe("The type of the value should match the feature type"),
103
+ }, {
104
+ readOnlyHint: false,
100
105
  }, async ({ featureId, description, condition, value, fileExtension }) => {
101
106
  try {
102
107
  // Fetch feature defaults first and surface to user
@@ -160,6 +165,8 @@ export function registerFeatureTools({ server, baseApiUrl, apiKey, appOrigin, us
160
165
  server.tool("get_feature_flags", "Fetches all feature flags from the GrowthBook API, with optional limit, offset, and project filtering.", {
161
166
  project: featureFlagSchema.project.optional(),
162
167
  ...paginationSchema,
168
+ }, {
169
+ readOnlyHint: true,
163
170
  }, async ({ limit, offset, project }) => {
164
171
  try {
165
172
  const queryParams = new URLSearchParams({
@@ -190,6 +197,8 @@ export function registerFeatureTools({ server, baseApiUrl, apiKey, appOrigin, us
190
197
  */
191
198
  server.tool("get_single_feature_flag", "Fetches a specific feature flag from the GrowthBook API", {
192
199
  id: featureFlagSchema.id,
200
+ }, {
201
+ readOnlyHint: true,
193
202
  }, async ({ id }) => {
194
203
  try {
195
204
  const res = await fetch(`${baseApiUrl}/api/v1/features/${id}`, {
@@ -224,6 +233,8 @@ export function registerFeatureTools({ server, baseApiUrl, apiKey, appOrigin, us
224
233
  server.tool("get_stale_safe_rollouts", "Fetches all complete safe rollouts (rolled-back or released) from the GrowthBook API", {
225
234
  limit: z.number().optional().default(100),
226
235
  offset: z.number().optional().default(0),
236
+ }, {
237
+ readOnlyHint: true,
227
238
  }, async ({ limit, offset }) => {
228
239
  try {
229
240
  const queryParams = new URLSearchParams({
@@ -276,6 +287,9 @@ export function registerFeatureTools({ server, baseApiUrl, apiKey, appOrigin, us
276
287
  currentWorkingDirectory: z
277
288
  .string()
278
289
  .describe("The current working directory of the user's project"),
290
+ }, {
291
+ readOnlyHint: false,
292
+ idempotentHint: true,
279
293
  }, async ({ currentWorkingDirectory }) => {
280
294
  function runCommand(command, cwd) {
281
295
  return new Promise((resolve, reject) => {
@@ -10,6 +10,8 @@ export function registerMetricsTools({ server, baseApiUrl, apiKey, appOrigin, })
10
10
  .describe("The ID of the project to filter metrics by")
11
11
  .optional(),
12
12
  ...paginationSchema,
13
+ }, {
14
+ readOnlyHint: true,
13
15
  }, async ({ limit, offset, project }) => {
14
16
  try {
15
17
  const queryParams = new URLSearchParams({
@@ -54,6 +56,8 @@ export function registerMetricsTools({ server, baseApiUrl, apiKey, appOrigin, })
54
56
  */
55
57
  server.tool("get_metric", "Fetches a metric from the GrowthBook API", {
56
58
  metricId: z.string().describe("The ID of the metric to get"),
59
+ }, {
60
+ readOnlyHint: true,
57
61
  }, async ({ metricId }) => {
58
62
  try {
59
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(),
@@ -10,6 +10,8 @@ export function registerSdkConnectionTools({ server, baseApiUrl, apiKey, }) {
10
10
  .describe("The ID of the project to filter SDK connections by")
11
11
  .optional(),
12
12
  ...paginationSchema,
13
+ }, {
14
+ readOnlyHint: true,
13
15
  }, async ({ limit, offset, project }) => {
14
16
  try {
15
17
  const queryParams = new URLSearchParams({
@@ -76,6 +78,9 @@ export function registerSdkConnectionTools({ server, baseApiUrl, apiKey, }) {
76
78
  .array(z.string())
77
79
  .describe("The projects to create the SDK connection in")
78
80
  .optional(),
81
+ }, {
82
+ readOnlyHint: false,
83
+ destructiveHint: false,
79
84
  }, async ({ name, language, environment, projects }) => {
80
85
  if (!environment) {
81
86
  try {
@@ -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 {
File without changes
File without changes