@growthbook/mcp 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,6 +17,24 @@ Use the following env variables to configure the MCP server.
17
17
  | GB_EMAIL | Required | Your email address used with GrowthBook. Used when creating feature flags and experiments.|
18
18
  | GB_API_URL | Optional | Your GrowthBook API URL. Defaults to `https://api.growthbook.io`. |
19
19
  | GB_APP_ORIGIN | Optional | Your GrowthBook app URL Defaults to `https://app.growthbook.io`. |
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. |
20
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
21
39
 
22
40
  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.5.0",
4
+ "version": "1.6.0",
5
5
  "description": "MCP Server for interacting with GrowthBook",
6
6
  "access": "public",
7
7
  "homepage": "https://github.com/growthbook/growthbook-mcp",
@@ -19,7 +19,8 @@
19
19
  "bump:patch": "npm version patch --no-git-tag-version && npm run sync-version",
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
- "mcpb:build": "npx -y @anthropic-ai/mcpb -- pack"
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
24
  },
24
25
  "bin": {
25
26
  "mcp": "server/index.js"
@@ -37,15 +38,15 @@
37
38
  "author": "GrowthBook",
38
39
  "license": "MIT",
39
40
  "dependencies": {
40
- "@modelcontextprotocol/sdk": "^1.17.2",
41
- "env-paths": "^3.0.0",
42
- "zod": "^3.25.67"
41
+ "@modelcontextprotocol/sdk": "^1.25.3",
42
+ "env-paths": "^4.0.0",
43
+ "zod": "^4.3.6"
43
44
  },
44
45
  "devDependencies": {
45
- "@types/node": "^24.2.1",
46
- "@vitest/coverage-v8": "^3.2.4",
46
+ "@types/node": "^25.1.0",
47
+ "@vitest/coverage-v8": "^4.0.18",
47
48
  "typescript": "^5.9.2",
48
- "vitest": "^3.2.4"
49
+ "vitest": "^4.0.18"
49
50
  },
50
51
  "type": "module"
51
52
  }
@@ -1,4 +1,4 @@
1
- import { handleResNotOk, fetchWithRateLimit, } from "../utils.js";
1
+ import { handleResNotOk, fetchWithRateLimit, buildHeaders, } from "../utils.js";
2
2
  import envPaths from "env-paths";
3
3
  import { writeFile, readFile, mkdir, unlink } from "fs/promises";
4
4
  import { join } from "path";
@@ -10,18 +10,14 @@ const userDefaultsFile = join(experimentDefaultsDir, "user-defaults.json");
10
10
  export async function createDefaults(apiKey, baseApiUrl) {
11
11
  try {
12
12
  const experimentsResponse = await fetchWithRateLimit(`${baseApiUrl}/api/v1/experiments`, {
13
- headers: {
14
- Authorization: `Bearer ${apiKey}`,
15
- },
13
+ headers: buildHeaders(apiKey, false),
16
14
  });
17
15
  await handleResNotOk(experimentsResponse);
18
16
  const experimentData = await experimentsResponse.json();
19
17
  if (experimentData.experiments.length === 0) {
20
18
  // No experiments: return assignment query and environments if possible
21
19
  const assignmentQueryResponse = await fetchWithRateLimit(`${baseApiUrl}/api/v1/data-sources`, {
22
- headers: {
23
- Authorization: `Bearer ${apiKey}`,
24
- },
20
+ headers: buildHeaders(apiKey, false),
25
21
  });
26
22
  await handleResNotOk(assignmentQueryResponse);
27
23
  const dataSourceData = await assignmentQueryResponse.json();
@@ -30,9 +26,7 @@ export async function createDefaults(apiKey, baseApiUrl) {
30
26
  }
31
27
  const assignmentQuery = dataSourceData.dataSources[0].assignmentQueries[0].id;
32
28
  const environmentsResponse = await fetchWithRateLimit(`${baseApiUrl}/api/v1/environments`, {
33
- headers: {
34
- Authorization: `Bearer ${apiKey}`,
35
- },
29
+ headers: buildHeaders(apiKey, false),
36
30
  });
37
31
  await handleResNotOk(environmentsResponse);
38
32
  const environmentsData = await environmentsResponse.json();
@@ -55,10 +49,7 @@ export async function createDefaults(apiKey, baseApiUrl) {
55
49
  if (experimentData.hasMore) {
56
50
  const mostRecentExperiments = await fetchWithRateLimit(`${baseApiUrl}/api/v1/experiments?offset=${experimentData.total -
57
51
  Math.min(50, experimentData.count + experimentData.offset)}&limit=${Math.min(50, experimentData.count + experimentData.offset)}`, {
58
- headers: {
59
- Authorization: `Bearer ${apiKey}`,
60
- "Content-Type": "application/json",
61
- },
52
+ headers: buildHeaders(apiKey),
62
53
  });
63
54
  await handleResNotOk(mostRecentExperiments);
64
55
  const mostRecentExperimentData = await mostRecentExperiments.json();
@@ -113,9 +104,7 @@ export async function createDefaults(apiKey, baseApiUrl) {
113
104
  }
114
105
  // Fetch environments
115
106
  const environmentsResponse = await fetchWithRateLimit(`${baseApiUrl}/api/v1/environments`, {
116
- headers: {
117
- Authorization: `Bearer ${apiKey}`,
118
- },
107
+ headers: buildHeaders(apiKey, false),
119
108
  });
120
109
  await handleResNotOk(environmentsResponse);
121
110
  const environmentsData = await environmentsResponse.json();
@@ -1,4 +1,4 @@
1
- import { handleResNotOk, fetchWithRateLimit, } from "../utils.js";
1
+ import { handleResNotOk, fetchWithRateLimit, buildHeaders, } from "../utils.js";
2
2
  import { z } from "zod";
3
3
  /**
4
4
  * Tool: get_environments
@@ -14,10 +14,7 @@ export function registerEnvironmentTools({ server, baseApiUrl, apiKey, }) {
14
14
  }, async () => {
15
15
  try {
16
16
  const res = await fetchWithRateLimit(`${baseApiUrl}/api/v1/environments`, {
17
- headers: {
18
- Authorization: `Bearer ${apiKey}`,
19
- "Content-Type": "application/json",
20
- },
17
+ headers: buildHeaders(apiKey),
21
18
  });
22
19
  await handleResNotOk(res);
23
20
  const data = await res.json();
@@ -1,4 +1,4 @@
1
- import { fetchWithRateLimit, handleResNotOk } from "../../utils.js";
1
+ import { fetchWithRateLimit, handleResNotOk, buildHeaders } from "../../utils.js";
2
2
  import { computeVerdict, formatLift, getYearMonth, median, round, } from "./summary-logic.js";
3
3
  // Metric Lookup with caching
4
4
  const metricCache = new Map();
@@ -48,10 +48,7 @@ async function getMetricLookup(baseApiUrl, apiKey, metricIds) {
48
48
  const regularResults = await processBatch(regularMetricIds, MAX_CONCURRENT_FETCHES, async (metricId) => {
49
49
  try {
50
50
  const res = await fetchWithRateLimit(`${baseApiUrl}/api/v1/metrics/${metricId}`, {
51
- headers: {
52
- Authorization: `Bearer ${apiKey}`,
53
- "Content-Type": "application/json",
54
- },
51
+ headers: buildHeaders(apiKey),
55
52
  });
56
53
  await handleResNotOk(res);
57
54
  const data = await res.json();
@@ -74,10 +71,7 @@ async function getMetricLookup(baseApiUrl, apiKey, metricIds) {
74
71
  const factResults = await processBatch(factMetricIds, MAX_CONCURRENT_FETCHES, async (metricId) => {
75
72
  try {
76
73
  const res = await fetchWithRateLimit(`${baseApiUrl}/api/v1/fact-metrics/${metricId}`, {
77
- headers: {
78
- Authorization: `Bearer ${apiKey}`,
79
- "Content-Type": "application/json",
80
- },
74
+ headers: buildHeaders(apiKey),
81
75
  });
82
76
  await handleResNotOk(res);
83
77
  const data = await res.json();
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { generateLinkToGrowthBook, getDocsMetadata, handleResNotOk, SUPPORTED_FILE_EXTENSIONS, paginationSchema, fetchWithRateLimit, fetchWithPagination, featureFlagSchema, fetchFeatureFlag, mergeRuleIntoFeatureFlag, } from "../../utils.js";
2
+ import { generateLinkToGrowthBook, getDocsMetadata, handleResNotOk, SUPPORTED_FILE_EXTENSIONS, paginationSchema, fetchWithRateLimit, fetchWithPagination, featureFlagSchema, fetchFeatureFlag, mergeRuleIntoFeatureFlag, buildHeaders, } from "../../utils.js";
3
3
  import { getDefaults } from "../defaults.js";
4
4
  import { handleSummaryMode } from "./experiment-summary.js";
5
5
  export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin, user, }) {
@@ -31,10 +31,7 @@ export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin,
31
31
  if (experimentId) {
32
32
  try {
33
33
  const res = await fetchWithRateLimit(`${baseApiUrl}/api/v1/experiments/${experimentId}`, {
34
- headers: {
35
- Authorization: `Bearer ${apiKey}`,
36
- "Content-Type": "application/json",
37
- },
34
+ headers: buildHeaders(apiKey),
38
35
  });
39
36
  await handleResNotOk(res);
40
37
  const data = await res.json();
@@ -45,9 +42,7 @@ export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin,
45
42
  }
46
43
  try {
47
44
  const resultsRes = await fetchWithRateLimit(`${baseApiUrl}/api/v1/experiments/${experimentId}/results`, {
48
- headers: {
49
- Authorization: `Bearer ${apiKey}`,
50
- },
45
+ headers: buildHeaders(apiKey, false),
51
46
  });
52
47
  await handleResNotOk(resultsRes);
53
48
  const resultsData = await resultsRes.json();
@@ -104,9 +99,7 @@ export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin,
104
99
  }
105
100
  try {
106
101
  const resultsRes = await fetchWithRateLimit(`${baseApiUrl}/api/v1/experiments/${experiment.id}/results`, {
107
- headers: {
108
- Authorization: `Bearer ${apiKey}`,
109
- },
102
+ headers: buildHeaders(apiKey, false),
110
103
  });
111
104
  await handleResNotOk(resultsRes);
112
105
  const resultsData = await resultsRes.json();
@@ -160,10 +153,7 @@ export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin,
160
153
  const queryParams = new URLSearchParams();
161
154
  queryParams.append("limit", "100");
162
155
  const res = await fetchWithRateLimit(`${baseApiUrl}/api/v1/attributes?${queryParams.toString()}`, {
163
- headers: {
164
- Authorization: `Bearer ${apiKey}`,
165
- "Content-Type": "application/json",
166
- },
156
+ headers: buildHeaders(apiKey),
167
157
  });
168
158
  await handleResNotOk(res);
169
159
  const data = await res.json();
@@ -258,10 +248,7 @@ export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin,
258
248
  try {
259
249
  const experimentRes = await fetchWithRateLimit(`${baseApiUrl}/api/v1/experiments`, {
260
250
  method: "POST",
261
- headers: {
262
- Authorization: `Bearer ${apiKey}`,
263
- "Content-Type": "application/json",
264
- },
251
+ headers: buildHeaders(apiKey),
265
252
  body: JSON.stringify(experimentPayload),
266
253
  });
267
254
  await handleResNotOk(experimentRes);
@@ -283,10 +270,7 @@ export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin,
283
270
  const flagPayload = mergeRuleIntoFeatureFlag(existingFeature, newRule, experimentDefaults.environments);
284
271
  const flagRes = await fetchWithRateLimit(`${baseApiUrl}/api/v1/features/${featureId}`, {
285
272
  method: "POST",
286
- headers: {
287
- Authorization: `Bearer ${apiKey}`,
288
- "Content-Type": "application/json",
289
- },
273
+ headers: buildHeaders(apiKey),
290
274
  body: JSON.stringify(flagPayload),
291
275
  });
292
276
  await handleResNotOk(flagRes);
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { getDocsMetadata, handleResNotOk, generateLinkToGrowthBook, paginationSchema, featureFlagSchema, fetchWithRateLimit, fetchWithPagination, fetchFeatureFlag, mergeRuleIntoFeatureFlag, } from "../utils.js";
2
+ import { getDocsMetadata, handleResNotOk, generateLinkToGrowthBook, paginationSchema, featureFlagSchema, fetchWithRateLimit, fetchWithPagination, fetchFeatureFlag, mergeRuleIntoFeatureFlag, buildHeaders, } 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, }) {
@@ -23,10 +23,7 @@ export function registerFeatureTools({ server, baseApiUrl, apiKey, appOrigin, us
23
23
  }
24
24
  else {
25
25
  const envRes = await fetchWithRateLimit(`${baseApiUrl}/api/v1/features/environments`, {
26
- headers: {
27
- Authorization: `Bearer ${apiKey}`,
28
- "Content-Type": "application/json",
29
- },
26
+ headers: buildHeaders(apiKey),
30
27
  });
31
28
  await handleResNotOk(envRes);
32
29
  const envData = await envRes.json();
@@ -51,10 +48,7 @@ export function registerFeatureTools({ server, baseApiUrl, apiKey, appOrigin, us
51
48
  try {
52
49
  const res = await fetchWithRateLimit(`${baseApiUrl}/api/v1/features`, {
53
50
  method: "POST",
54
- headers: {
55
- Authorization: `Bearer ${apiKey}`,
56
- "Content-Type": "application/json",
57
- },
51
+ headers: buildHeaders(apiKey),
58
52
  body: JSON.stringify(payload),
59
53
  });
60
54
  await handleResNotOk(res);
@@ -123,10 +117,7 @@ export function registerFeatureTools({ server, baseApiUrl, apiKey, appOrigin, us
123
117
  const payload = mergeRuleIntoFeatureFlag(existingFeature, newRule, defaultEnvironments);
124
118
  const res = await fetchWithRateLimit(`${baseApiUrl}/api/v1/features/${featureId}`, {
125
119
  method: "POST",
126
- headers: {
127
- Authorization: `Bearer ${apiKey}`,
128
- "Content-Type": "application/json",
129
- },
120
+ headers: buildHeaders(apiKey),
130
121
  body: JSON.stringify(payload),
131
122
  });
132
123
  await handleResNotOk(res);
@@ -134,7 +125,7 @@ export function registerFeatureTools({ server, baseApiUrl, apiKey, appOrigin, us
134
125
  const linkToGrowthBook = generateLinkToGrowthBook(appOrigin, "features", featureId);
135
126
  const { docs, language, stub } = getDocsMetadata(fileExtension);
136
127
  const text = `This is the API response: ${JSON.stringify(data)}
137
-
128
+
138
129
  Additionally, here is a template of what to show to the user:
139
130
 
140
131
  **✅ Your feature flag \`my-flag-name\` is ready!.**
@@ -175,10 +166,7 @@ export function registerFeatureTools({ server, baseApiUrl, apiKey, appOrigin, us
175
166
  if (featureFlagId) {
176
167
  try {
177
168
  const res = await fetchWithRateLimit(`${baseApiUrl}/api/v1/features/${featureFlagId}`, {
178
- headers: {
179
- Authorization: `Bearer ${apiKey}`,
180
- "Content-Type": "application/json",
181
- },
169
+ headers: buildHeaders(apiKey),
182
170
  });
183
171
  await handleResNotOk(res);
184
172
  const data = await res.json();
@@ -233,10 +221,7 @@ ask if they want to remove references to the feature flag from the codebase.
233
221
  offset: offset?.toString(),
234
222
  });
235
223
  const res = await fetchWithRateLimit(`${baseApiUrl}/api/v1/features?${queryParams.toString()}`, {
236
- headers: {
237
- Authorization: `Bearer ${apiKey}`,
238
- "Content-Type": "application/json",
239
- },
224
+ headers: buildHeaders(apiKey),
240
225
  });
241
226
  await handleResNotOk(res);
242
227
  const data = await res.json();
@@ -308,7 +293,8 @@ ask if they want to remove references to the feature flag from the codebase.
308
293
  content: [
309
294
  {
310
295
  type: "text",
311
- text: `✅ Types generated successfully:\n${output}`,
296
+ text: `✅ Types generated successfully:\n${output}. Offer to add a script to the project's package.json file to regenerate types when needed. The command is:
297
+ "npx -y growthbook@latest features generate-types -u ${baseApiUrl}"`,
312
298
  },
313
299
  ],
314
300
  };
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { generateLinkToGrowthBook, handleResNotOk, paginationSchema, fetchWithRateLimit, fetchWithPagination, } from "../utils.js";
2
+ import { generateLinkToGrowthBook, handleResNotOk, paginationSchema, fetchWithRateLimit, fetchWithPagination, buildHeaders, } from "../utils.js";
3
3
  export function registerMetricsTools({ server, baseApiUrl, apiKey, appOrigin, }) {
4
4
  /**
5
5
  * Tool: get_metrics
@@ -27,18 +27,12 @@ export function registerMetricsTools({ server, baseApiUrl, apiKey, appOrigin, })
27
27
  let res;
28
28
  if (metricId.startsWith("fact__")) {
29
29
  res = await fetchWithRateLimit(`${baseApiUrl}/api/v1/fact-metrics/${metricId}`, {
30
- headers: {
31
- Authorization: `Bearer ${apiKey}`,
32
- "Content-Type": "application/json",
33
- },
30
+ headers: buildHeaders(apiKey),
34
31
  });
35
32
  }
36
33
  else {
37
34
  res = await fetchWithRateLimit(`${baseApiUrl}/api/v1/metrics/${metricId}`, {
38
- headers: {
39
- Authorization: `Bearer ${apiKey}`,
40
- "Content-Type": "application/json",
41
- },
35
+ headers: buildHeaders(apiKey),
42
36
  });
43
37
  }
44
38
  await handleResNotOk(res);
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { handleResNotOk, paginationSchema, fetchWithRateLimit, fetchWithPagination, } from "../utils.js";
2
+ import { handleResNotOk, paginationSchema, fetchWithRateLimit, fetchWithPagination, buildHeaders, } from "../utils.js";
3
3
  export function registerSdkConnectionTools({ server, baseApiUrl, apiKey, }) {
4
4
  /**
5
5
  * Tool: get_sdk_connections
@@ -85,10 +85,7 @@ export function registerSdkConnectionTools({ server, baseApiUrl, apiKey, }) {
85
85
  if (!environment) {
86
86
  try {
87
87
  const res = await fetchWithRateLimit(`${baseApiUrl}/api/v1/environments`, {
88
- headers: {
89
- Authorization: `Bearer ${apiKey}`,
90
- "Content-Type": "application/json",
91
- },
88
+ headers: buildHeaders(apiKey),
92
89
  });
93
90
  await handleResNotOk(res);
94
91
  const data = await res.json();
@@ -115,10 +112,7 @@ export function registerSdkConnectionTools({ server, baseApiUrl, apiKey, }) {
115
112
  try {
116
113
  const res = await fetchWithRateLimit(`${baseApiUrl}/api/v1/sdk-connections`, {
117
114
  method: "POST",
118
- headers: {
119
- Authorization: `Bearer ${apiKey}`,
120
- "Content-Type": "application/json",
121
- },
115
+ headers: buildHeaders(apiKey),
122
116
  body: JSON.stringify(payload),
123
117
  });
124
118
  await handleResNotOk(res);
package/server/utils.js CHANGED
@@ -46,14 +46,72 @@ export function getApiKey() {
46
46
  }
47
47
  export function getApiUrl() {
48
48
  const defaultApiUrl = "https://api.growthbook.io";
49
- const userApiUrl = process.env.GB_API_URL;
49
+ let userApiUrl = process.env.GB_API_URL;
50
+ userApiUrl = userApiUrl?.trim().replace(/\/+$/, "");
50
51
  return `${userApiUrl || defaultApiUrl}`;
51
52
  }
52
53
  export function getAppOrigin() {
53
54
  const defaultAppOrigin = "https://app.growthbook.io";
54
- const userAppOrigin = process.env.GB_APP_ORIGIN;
55
+ let userAppOrigin = process.env.GB_APP_ORIGIN;
56
+ userAppOrigin = userAppOrigin?.trim().replace(/\/+$/, "");
55
57
  return `${userAppOrigin || defaultAppOrigin}`;
56
58
  }
59
+ /**
60
+ * Parses custom HTTP headers from environment variables with the prefix GB_HTTP_HEADER_*
61
+ * Converts environment variable names to proper HTTP header format:
62
+ * GB_HTTP_HEADER_X_TENANT_ID -> X-Tenant-ID
63
+ * GB_HTTP_HEADER_CF_ACCESS_TOKEN -> Cf-Access-Token
64
+ *
65
+ * Example usage:
66
+ * GB_HTTP_HEADER_X_TENANT_ID=abc123 -> { "X-Tenant-ID": "abc123" }
67
+ * GB_HTTP_HEADER_CF_ACCESS_TOKEN=<token> -> { "Cf-Access-Token": "<token>" }
68
+ */
69
+ export function getCustomHeaders() {
70
+ const customHeaders = {};
71
+ const headerPrefix = "GB_HTTP_HEADER_";
72
+ for (const [key, value] of Object.entries(process.env)) {
73
+ if (key.startsWith(headerPrefix) && value) {
74
+ // Extract the header name part after the prefix
75
+ const headerNamePart = key.slice(headerPrefix.length);
76
+ // Convert underscore-separated name to proper HTTP header format
77
+ // Example: X_TENANT_ID -> X-Tenant-ID, CF_ACCESS_TOKEN -> Cf-Access-Token
78
+ const headerName = headerNamePart
79
+ .split("_")
80
+ .map((part, index) => {
81
+ // Special handling for common prefixes like X, API, etc.
82
+ if (part.length === 1 || part === "API" || part === "ID") {
83
+ return part;
84
+ }
85
+ // Capitalize first letter, lowercase the rest
86
+ return part.charAt(0).toUpperCase() + part.slice(1).toLowerCase();
87
+ })
88
+ .join("-");
89
+ customHeaders[headerName] = value;
90
+ }
91
+ }
92
+ return customHeaders;
93
+ }
94
+ /**
95
+ * Builds HTTP headers for GrowthBook API requests, merging required headers
96
+ * with any custom headers configured via GB_HTTP_HEADER_* environment variables.
97
+ *
98
+ * Custom headers are applied first, then required headers (Authorization, Content-Type)
99
+ * are added. This ensures required headers always take precedence.
100
+ *
101
+ * @param apiKey - The GrowthBook API key for authorization
102
+ * @param includeContentType - Whether to include Content-Type header (default: true)
103
+ * @returns Headers object ready for fetch requests
104
+ */
105
+ export function buildHeaders(apiKey, includeContentType = true) {
106
+ const headers = {
107
+ ...getCustomHeaders(),
108
+ Authorization: `Bearer ${apiKey}`,
109
+ };
110
+ if (includeContentType) {
111
+ headers["Content-Type"] = "application/json";
112
+ }
113
+ return headers;
114
+ }
57
115
  export function getDocsMetadata(extension) {
58
116
  switch (extension) {
59
117
  case ".tsx":
@@ -370,10 +428,7 @@ export async function fetchWithRateLimit(url, options, retries = 3) {
370
428
  */
371
429
  export async function fetchFeatureFlag(baseApiUrl, apiKey, featureId) {
372
430
  const res = await fetchWithRateLimit(`${baseApiUrl}/api/v1/features/${featureId}`, {
373
- headers: {
374
- Authorization: `Bearer ${apiKey}`,
375
- "Content-Type": "application/json",
376
- },
431
+ headers: buildHeaders(apiKey),
377
432
  });
378
433
  await handleResNotOk(res);
379
434
  const data = await res.json();
@@ -430,20 +485,14 @@ export async function fetchWithPagination(baseApiUrl, apiKey, endpoint, limit, o
430
485
  });
431
486
  }
432
487
  const res = await fetchWithRateLimit(`${baseApiUrl}${endpoint}?${queryParams.toString()}`, {
433
- headers: {
434
- Authorization: `Bearer ${apiKey}`,
435
- "Content-Type": "application/json",
436
- },
488
+ headers: buildHeaders(apiKey),
437
489
  });
438
490
  await handleResNotOk(res);
439
491
  return await res.json();
440
492
  }
441
493
  // Most recent behavior: fetch total count first, then calculate offset
442
494
  const countRes = await fetchWithRateLimit(`${baseApiUrl}${endpoint}?limit=1`, {
443
- headers: {
444
- Authorization: `Bearer ${apiKey}`,
445
- "Content-Type": "application/json",
446
- },
495
+ headers: buildHeaders(apiKey),
447
496
  });
448
497
  await handleResNotOk(countRes);
449
498
  const countData = await countRes.json();
@@ -462,10 +511,7 @@ export async function fetchWithPagination(baseApiUrl, apiKey, endpoint, limit, o
462
511
  });
463
512
  }
464
513
  const mostRecentRes = await fetchWithRateLimit(`${baseApiUrl}${endpoint}?${mostRecentQueryParams.toString()}`, {
465
- headers: {
466
- Authorization: `Bearer ${apiKey}`,
467
- "Content-Type": "application/json",
468
- },
514
+ headers: buildHeaders(apiKey),
469
515
  });
470
516
  await handleResNotOk(mostRecentRes);
471
517
  return await mostRecentRes.json();