@browserstack/mcp-server 1.4.0 → 1.4.1

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
@@ -292,6 +292,8 @@ Select the “Installed” tab. Click the “Configure MCP Servers” button at
292
292
  }
293
293
  ```
294
294
 
295
+ > **File & app uploads:** tools that upload a local file/app (`uploadProductRequirementFile`, `takeAppScreenshot`, `runAppTestsOnBrowserStack`, `runAppLiveSession`) require the `MCP_UPLOAD_BASE_DIR` env var set to a directory containing those files; uploads are restricted to it.
296
+
295
297
  ### 💡 List of BrowserStack MCP Tools
296
298
 
297
299
  As of now we support 46 tools.
@@ -1,7 +1,7 @@
1
1
  export interface UploadValidationOptions {
2
2
  allowedExtensions: readonly string[];
3
3
  maxSizeBytes: number;
4
- allowedBaseDir?: string;
4
+ allowedBaseDir: string | undefined;
5
5
  }
6
6
  /**
7
7
  * Canonicalizes and validates a user-supplied upload path. Returns the resolved
@@ -13,7 +13,8 @@ export interface UploadValidationOptions {
13
13
  * - File extension is in `allowedExtensions` (case-insensitive)
14
14
  * - No path segment is a hidden dir/file (starts with `.`); blocks ~/.ssh,
15
15
  * ~/.aws, .env, etc. even after symlink resolution
16
- * - If `allowedBaseDir` is set, the canonical path must live inside it
16
+ * - `allowedBaseDir` is mandatory: uploads are refused unless it is configured
17
+ * (via MCP_UPLOAD_BASE_DIR), and the canonical path must live inside it
17
18
  */
18
19
  export declare function validateUploadPath(filePath: string, options: UploadValidationOptions): string;
19
20
  export declare const APP_BINARY_EXTENSIONS: readonly [".apk", ".aab", ".ipa", ".app", ".zip"];
@@ -10,7 +10,8 @@ import path from "path";
10
10
  * - File extension is in `allowedExtensions` (case-insensitive)
11
11
  * - No path segment is a hidden dir/file (starts with `.`); blocks ~/.ssh,
12
12
  * ~/.aws, .env, etc. even after symlink resolution
13
- * - If `allowedBaseDir` is set, the canonical path must live inside it
13
+ * - `allowedBaseDir` is mandatory: uploads are refused unless it is configured
14
+ * (via MCP_UPLOAD_BASE_DIR), and the canonical path must live inside it
14
15
  */
15
16
  export function validateUploadPath(filePath, options) {
16
17
  if (typeof filePath !== "string" || filePath.trim().length === 0) {
@@ -48,20 +49,22 @@ export function validateUploadPath(filePath, options) {
48
49
  if (!allowed.includes(ext)) {
49
50
  throw new Error(`Upload rejected: file extension "${ext || "(none)"}" is not in the allowed list (${allowed.join(", ")}).`);
50
51
  }
51
- if (options.allowedBaseDir) {
52
- let baseCanonical;
53
- try {
54
- baseCanonical = fs.realpathSync(path.resolve(options.allowedBaseDir));
55
- }
56
- catch {
57
- throw new Error(`Upload rejected: configured MCP_UPLOAD_BASE_DIR does not exist (${options.allowedBaseDir}).`);
58
- }
59
- const baseWithSep = baseCanonical.endsWith(path.sep)
60
- ? baseCanonical
61
- : baseCanonical + path.sep;
62
- if (canonical !== baseCanonical && !canonical.startsWith(baseWithSep)) {
63
- throw new Error(`Upload rejected: file must be located inside ${baseCanonical}.`);
64
- }
52
+ if (!options.allowedBaseDir) {
53
+ throw new Error("Upload rejected: MCP_UPLOAD_BASE_DIR is not set. Set it to the directory " +
54
+ "containing the files to upload, then restart the MCP server.");
55
+ }
56
+ let baseCanonical;
57
+ try {
58
+ baseCanonical = fs.realpathSync(path.resolve(options.allowedBaseDir));
59
+ }
60
+ catch {
61
+ throw new Error(`Upload rejected: configured MCP_UPLOAD_BASE_DIR does not exist (${options.allowedBaseDir}).`);
62
+ }
63
+ const baseWithSep = baseCanonical.endsWith(path.sep)
64
+ ? baseCanonical
65
+ : baseCanonical + path.sep;
66
+ if (canonical !== baseCanonical && !canonical.startsWith(baseWithSep)) {
67
+ throw new Error(`Upload rejected: file must be located inside ${baseCanonical}.`);
65
68
  }
66
69
  return canonical;
67
70
  }
@@ -20,7 +20,8 @@ export const RUN_APP_AUTOMATE_SCHEMA = {
20
20
  " xcodebuild clean -scheme YOUR_SCHEME && \\\n" +
21
21
  " xcodebuild archive -scheme YOUR_SCHEME -configuration Release -archivePath build/app.xcarchive && \\\n" +
22
22
  " xcodebuild -exportArchive -archivePath build/app.xcarchive -exportPath build/ipa -exportOptionsPlist exportOptions.plist\n\n" +
23
- "If in other directory, provide existing app path"),
23
+ "If in other directory, provide existing app path.\n" +
24
+ "The resolved file must be located inside the directory set in MCP_UPLOAD_BASE_DIR."),
24
25
  testSuitePath: z
25
26
  .string()
26
27
  .describe("Path to your test suite file:\n" +
@@ -30,7 +31,8 @@ export const RUN_APP_AUTOMATE_SCHEMA = {
30
31
  " xcodebuild test-without-building -scheme YOUR_SCHEME -destination 'generic/platform=iOS' && \\\n" +
31
32
  " cd ~/Library/Developer/Xcode/DerivedData/*/Build/Products/Debug-iphonesimulator/ && \\\n" +
32
33
  " zip -r Tests.zip *.xctestrun *-Runner.app\n\n" +
33
- "If in other directory, provide existing test file path"),
34
+ "If in other directory, provide existing test file path.\n" +
35
+ "The resolved file must be located inside the directory set in MCP_UPLOAD_BASE_DIR."),
34
36
  devices: z
35
37
  .array(MobileDeviceSchema)
36
38
  .max(3)
@@ -206,7 +206,7 @@ export default function addAppAutomationTools(server, config) {
206
206
  .describe("Platform to run the app on. Either 'android' or 'ios'."),
207
207
  appPath: z
208
208
  .string()
209
- .describe("The path to the .apk or .ipa file. Required for app installation."),
209
+ .describe("The path to the .apk or .ipa file. Required for app installation. Must be located inside the directory set in MCP_UPLOAD_BASE_DIR."),
210
210
  }, {
211
211
  title: "Take App Screenshot",
212
212
  readOnlyHint: false,
@@ -266,9 +266,11 @@ export default function addAppAutomationTools(server, config) {
266
266
  idempotentHint: true,
267
267
  }, async (args) => {
268
268
  try {
269
+ trackMCP("setupBrowserStackAppAutomateTests", server.server.getClientVersion(), undefined, config);
269
270
  return await setupAppAutomateHandler(args, config);
270
271
  }
271
272
  catch (error) {
273
+ trackMCP("setupBrowserStackAppAutomateTests", server.server.getClientVersion(), error, config);
272
274
  const error_message = error instanceof Error ? error.message : "Unknown error";
273
275
  return {
274
276
  content: [
@@ -66,7 +66,7 @@ export default function addAppLiveTools(server, config) {
66
66
  .describe("Which platform to run on, examples: 'android', 'ios'. Set this based on the app path provided."),
67
67
  appPath: z
68
68
  .string()
69
- .describe("The path to the .ipa or .apk file to install on the device. Always ask the user for the app path, do not assume it."),
69
+ .describe("The path to the .ipa or .apk file to install on the device. Always ask the user for the app path, do not assume it. Must be located inside the directory set in MCP_UPLOAD_BASE_DIR."),
70
70
  }, {
71
71
  title: "Run App Live Session",
72
72
  readOnlyHint: false,
@@ -13,11 +13,11 @@ export function registerRunBrowserStackTestsTool(server, config) {
13
13
  idempotentHint: true,
14
14
  }, async (args) => {
15
15
  try {
16
- trackMCP("runTestsOnBrowserStack", server.server.getClientVersion(), config);
16
+ trackMCP("setupBrowserStackAutomateTests", server.server.getClientVersion(), undefined, config);
17
17
  return await runTestsOnBrowserStackHandler(args, config);
18
18
  }
19
19
  catch (error) {
20
- return handleMCPError("runTestsOnBrowserStack", server, config, error);
20
+ return handleMCPError("setupBrowserStackAutomateTests", server, config, error);
21
21
  }
22
22
  });
23
23
  return tools;
@@ -108,7 +108,7 @@ export default function addBuildInsightsTools(server, config) {
108
108
  idempotentHint: true,
109
109
  }, async (args) => {
110
110
  try {
111
- trackMCP("fetchBuildInsights", server.server.getClientVersion(), config);
111
+ trackMCP("fetchBuildInsights", server.server.getClientVersion(), undefined, config);
112
112
  return await fetchBuildInsightsTool(args, config);
113
113
  }
114
114
  catch (error) {
@@ -38,11 +38,11 @@ export function registerPercyTools(server, config) {
38
38
  idempotentHint: true,
39
39
  }, async (args) => {
40
40
  try {
41
- trackMCP("VisualTestIntegrationAgent", server.server.getClientVersion(), config);
41
+ trackMCP("percyVisualTestIntegrationAgent", server.server.getClientVersion(), undefined, config);
42
42
  return simulatePercyChangeHandler(args, config);
43
43
  }
44
44
  catch (error) {
45
- return handleMCPError("VisualTestIntegrationAgent", server, config, error);
45
+ return handleMCPError("percyVisualTestIntegrationAgent", server, config, error);
46
46
  }
47
47
  });
48
48
  tools.setupPercyVisualTesting = server.tool("expandPercyVisualTesting", SETUP_PERCY_DESCRIPTION, SetUpPercyParamsShape, {
@@ -53,11 +53,11 @@ export function registerPercyTools(server, config) {
53
53
  idempotentHint: true,
54
54
  }, async (args) => {
55
55
  try {
56
- trackMCP("setupPercyVisualTesting", server.server.getClientVersion(), config);
56
+ trackMCP("expandPercyVisualTesting", server.server.getClientVersion(), undefined, config);
57
57
  return setUpPercyHandler(args, config);
58
58
  }
59
59
  catch (error) {
60
- return handleMCPError("setupPercyVisualTesting", server, config, error);
60
+ return handleMCPError("expandPercyVisualTesting", server, config, error);
61
61
  }
62
62
  });
63
63
  tools.addPercySnapshotCommands = server.tool("addPercySnapshotCommands", PERCY_SNAPSHOT_COMMANDS_DESCRIPTION, UpdateTestFileWithInstructionsParams, {
@@ -68,7 +68,7 @@ export function registerPercyTools(server, config) {
68
68
  idempotentHint: true,
69
69
  }, async (args) => {
70
70
  try {
71
- trackMCP("addPercySnapshotCommands", server.server.getClientVersion(), config);
71
+ trackMCP("addPercySnapshotCommands", server.server.getClientVersion(), undefined, config);
72
72
  return await updateTestsWithPercyCommands(args);
73
73
  }
74
74
  catch (error) {
@@ -83,7 +83,7 @@ export function registerPercyTools(server, config) {
83
83
  idempotentHint: true,
84
84
  }, async () => {
85
85
  try {
86
- trackMCP("listTestFiles", server.server.getClientVersion(), config);
86
+ trackMCP("listTestFiles", server.server.getClientVersion(), undefined, config);
87
87
  return addListTestFiles();
88
88
  }
89
89
  catch (error) {
@@ -98,7 +98,7 @@ export function registerPercyTools(server, config) {
98
98
  idempotentHint: true,
99
99
  }, async (args) => {
100
100
  try {
101
- trackMCP("runPercyScan", server.server.getClientVersion(), config);
101
+ trackMCP("runPercyScan", server.server.getClientVersion(), undefined, config);
102
102
  return runPercyScan(args);
103
103
  }
104
104
  catch (error) {
@@ -113,7 +113,7 @@ export function registerPercyTools(server, config) {
113
113
  idempotentHint: true,
114
114
  }, async (args) => {
115
115
  try {
116
- trackMCP("fetchPercyChanges", server.server.getClientVersion(), config);
116
+ trackMCP("fetchPercyChanges", server.server.getClientVersion(), undefined, config);
117
117
  return await fetchPercyChanges(args, config);
118
118
  }
119
119
  catch (error) {
@@ -128,7 +128,7 @@ export function registerPercyTools(server, config) {
128
128
  idempotentHint: true,
129
129
  }, async (args) => {
130
130
  try {
131
- trackMCP("managePercyBuildApproval", server.server.getClientVersion(), config);
131
+ trackMCP("managePercyBuildApproval", server.server.getClientVersion(), undefined, config);
132
132
  return await approveOrDeclinePercyBuild(args, config);
133
133
  }
134
134
  catch (error) {
@@ -1,5 +1,5 @@
1
1
  import { apiClient } from "../../../lib/apiClient.js";
2
- import { TCG_TRIGGER_URL, TCG_POLL_URL, FETCH_DETAILS_URL, FORM_FIELDS_URL, BULK_CREATE_URL, TC_DETAILS_MAX_BATCH, } from "./config.js";
2
+ import { TCG_TRIGGER_URL, TCG_POLL_URL, FETCH_DETAILS_URL, FORM_FIELDS_URL, BULK_CREATE_URL, TC_DETAILS_MAX_BATCH, TCG_POLL_INTERVAL_MS, TCG_POLL_MAX_WAIT_MS, } from "./config.js";
3
3
  import { createTestCasePayload } from "./helpers.js";
4
4
  import { getBrowserStackAuth } from "../../../lib/get-auth.js";
5
5
  import { getTMBaseURL } from "../../../lib/tm-base-url.js";
@@ -118,9 +118,15 @@ export async function pollTestCaseDetails(traceRequestId, config) {
118
118
  let done = false;
119
119
  const tmBaseUrl = await getTMBaseURL(config);
120
120
  const TCG_POLL_URL_VALUE = TCG_POLL_URL(tmBaseUrl);
121
+ const deadline = Date.now() + TCG_POLL_MAX_WAIT_MS;
121
122
  while (!done) {
123
+ // Bail out if the backend never sends a "termination" message, so a stuck
124
+ // job cannot keep this loop (and its callers) alive forever.
125
+ if (Date.now() > deadline) {
126
+ throw new Error(`TCG test-case detail polling timed out after ${TCG_POLL_MAX_WAIT_MS}ms (trace ${traceRequestId})`);
127
+ }
122
128
  // add a bit of jitter to avoid synchronized polling storms
123
- await new Promise((r) => setTimeout(r, 10000 + Math.random() * 5000));
129
+ await new Promise((r) => setTimeout(r, TCG_POLL_INTERVAL_MS + Math.random() * 5000));
124
130
  const poll = await apiClient.post({
125
131
  url: `${TCG_POLL_URL_VALUE}?x-bstack-traceRequestId=${encodeURIComponent(traceRequestId)}`,
126
132
  headers: {
@@ -159,7 +165,20 @@ export async function pollScenariosTestDetails(args, traceId, context, documentI
159
165
  const TCG_POLL_URL_VALUE = TCG_POLL_URL(tmBaseUrl);
160
166
  // Promisify interval-style polling using a wrapper
161
167
  await new Promise((resolve, reject) => {
162
- const intervalId = setInterval(async () => {
168
+ const timers = {};
169
+ const stop = () => {
170
+ if (timers.interval)
171
+ clearInterval(timers.interval);
172
+ if (timers.timeout)
173
+ clearTimeout(timers.timeout);
174
+ };
175
+ // Hard wall-clock deadline: if the backend never sends a "termination"
176
+ // message, reject instead of letting the interval fire forever.
177
+ timers.timeout = setTimeout(() => {
178
+ stop();
179
+ reject(new Error(`TCG scenario polling timed out after ${TCG_POLL_MAX_WAIT_MS}ms (trace ${traceId})`));
180
+ }, TCG_POLL_MAX_WAIT_MS);
181
+ timers.interval = setInterval(async () => {
163
182
  try {
164
183
  const poll = await apiClient.post({
165
184
  url: `${TCG_POLL_URL_VALUE}?x-bstack-traceRequestId=${encodeURIComponent(traceId)}`,
@@ -169,7 +188,7 @@ export async function pollScenariosTestDetails(args, traceId, context, documentI
169
188
  body: {},
170
189
  });
171
190
  if (poll.status !== 200) {
172
- clearInterval(intervalId);
191
+ stop();
173
192
  reject(new Error(`Polling error: ${poll.statusText || poll.status}`));
174
193
  return;
175
194
  }
@@ -221,16 +240,16 @@ export async function pollScenariosTestDetails(args, traceId, context, documentI
221
240
  }
222
241
  }
223
242
  if (msg.type === "termination") {
224
- clearInterval(intervalId);
243
+ stop();
225
244
  resolve();
226
245
  }
227
246
  }
228
247
  }
229
248
  catch (err) {
230
- clearInterval(intervalId);
249
+ stop();
231
250
  reject(err);
232
251
  }
233
- }, 10000); // 10 second interval
252
+ }, TCG_POLL_INTERVAL_MS);
234
253
  });
235
254
  // once all detail fetches are triggered, wait for them to complete
236
255
  const detailsList = await Promise.all(detailPromises);
@@ -1,4 +1,6 @@
1
1
  export declare const TC_DETAILS_MAX_BATCH = 10;
2
+ export declare const TCG_POLL_INTERVAL_MS = 10000;
3
+ export declare const TCG_POLL_MAX_WAIT_MS: number;
2
4
  export declare const TCG_TRIGGER_URL: (baseUrl: string) => string;
3
5
  export declare const TCG_POLL_URL: (baseUrl: string) => string;
4
6
  export declare const FETCH_DETAILS_URL: (baseUrl: string) => string;
@@ -1,4 +1,9 @@
1
1
  export const TC_DETAILS_MAX_BATCH = 10;
2
+ // Wall-clock bounds for the TCG generation polling loops. Without a hard
3
+ // deadline, a job that never emits a "termination" message keeps the loop
4
+ // (and every caller awaiting it) alive forever.
5
+ export const TCG_POLL_INTERVAL_MS = 10_000;
6
+ export const TCG_POLL_MAX_WAIT_MS = 10 * 60 * 1000; // 10 minutes
2
7
  export const TCG_TRIGGER_URL = (baseUrl) => `${baseUrl}/api/v1/integration/tcg/test-generation/suggest-test-cases`;
3
8
  export const TCG_POLL_URL = (baseUrl) => `${baseUrl}/api/v1/integration/tcg/test-generation/test-cases-polling`;
4
9
  export const FETCH_DETAILS_URL = (baseUrl) => `${baseUrl}/api/v1/integration/tcg/test-generation/fetch-test-case-details`;
@@ -264,11 +264,11 @@ export async function uploadProductRequirementFileTool(args, config, server) {
264
264
  */
265
265
  export async function createTestCasesFromFileTool(args, context, config, server) {
266
266
  try {
267
- trackMCP("createTestCasesFromFile", server.server.getClientVersion(), undefined);
267
+ trackMCP("createTestCasesFromFile", server.server.getClientVersion(), undefined, config);
268
268
  return await createTestCasesFromFile(args, context, config);
269
269
  }
270
270
  catch (err) {
271
- trackMCP("createTestCasesFromFile", server.server.getClientVersion(), err);
271
+ trackMCP("createTestCasesFromFile", server.server.getClientVersion(), err, config);
272
272
  return {
273
273
  content: [
274
274
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@browserstack/mcp-server",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "BrowserStack's Official MCP Server",
5
5
  "mcpName": "io.github.browserstack/mcp-server",
6
6
  "main": "dist/index.js",