@browserstack/mcp-server 1.2.16 → 1.2.17

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/dist/config.d.ts CHANGED
@@ -7,7 +7,8 @@ export declare class Config {
7
7
  readonly browserstackLocalOptions: Record<string, any>;
8
8
  readonly USE_OWN_LOCAL_BINARY_PROCESS: boolean;
9
9
  readonly REMOTE_MCP: boolean;
10
- constructor(DEV_MODE: boolean, browserstackLocalOptions: Record<string, any>, USE_OWN_LOCAL_BINARY_PROCESS: boolean, REMOTE_MCP: boolean);
10
+ readonly UPLOAD_BASE_DIR: string | undefined;
11
+ constructor(DEV_MODE: boolean, browserstackLocalOptions: Record<string, any>, USE_OWN_LOCAL_BINARY_PROCESS: boolean, REMOTE_MCP: boolean, UPLOAD_BASE_DIR: string | undefined);
11
12
  }
12
13
  declare const config: Config;
13
14
  export default config;
package/dist/config.js CHANGED
@@ -37,12 +37,16 @@ export class Config {
37
37
  browserstackLocalOptions;
38
38
  USE_OWN_LOCAL_BINARY_PROCESS;
39
39
  REMOTE_MCP;
40
- constructor(DEV_MODE, browserstackLocalOptions, USE_OWN_LOCAL_BINARY_PROCESS, REMOTE_MCP) {
40
+ UPLOAD_BASE_DIR;
41
+ constructor(DEV_MODE, browserstackLocalOptions, USE_OWN_LOCAL_BINARY_PROCESS, REMOTE_MCP, UPLOAD_BASE_DIR) {
41
42
  this.DEV_MODE = DEV_MODE;
42
43
  this.browserstackLocalOptions = browserstackLocalOptions;
43
44
  this.USE_OWN_LOCAL_BINARY_PROCESS = USE_OWN_LOCAL_BINARY_PROCESS;
44
45
  this.REMOTE_MCP = REMOTE_MCP;
46
+ this.UPLOAD_BASE_DIR = UPLOAD_BASE_DIR;
45
47
  }
46
48
  }
47
- const config = new Config(process.env.DEV_MODE === "true", browserstackLocalOptions, process.env.USE_OWN_LOCAL_BINARY_PROCESS === "true", process.env.REMOTE_MCP === "true");
49
+ const config = new Config(process.env.DEV_MODE === "true", browserstackLocalOptions, process.env.USE_OWN_LOCAL_BINARY_PROCESS === "true", process.env.REMOTE_MCP === "true", process.env.MCP_UPLOAD_BASE_DIR && process.env.MCP_UPLOAD_BASE_DIR.length > 0
50
+ ? process.env.MCP_UPLOAD_BASE_DIR
51
+ : undefined);
48
52
  export default config;
@@ -1,6 +1,7 @@
1
1
  import { apiClient } from "./apiClient.js";
2
2
  import logger from "../logger.js";
3
3
  import { getBrowserStackAuth } from "./get-auth.js";
4
+ import appConfig from "../config.js";
4
5
  const TM_BASE_URLS = [
5
6
  "https://test-management.browserstack.com",
6
7
  "https://test-management-eu.browserstack.com",
@@ -8,7 +9,10 @@ const TM_BASE_URLS = [
8
9
  ];
9
10
  let cachedBaseUrl = null;
10
11
  export async function getTMBaseURL(config) {
11
- if (cachedBaseUrl) {
12
+ // Skip the module-level cache in remote (multi-tenant) mode: it is process-shared,
13
+ // so the first user's region would be served to every subsequent user — breaking
14
+ // requests for users on a different region's BrowserStack account.
15
+ if (!appConfig.REMOTE_MCP && cachedBaseUrl) {
12
16
  logger.debug(`Using cached TM base URL: ${cachedBaseUrl}`);
13
17
  return cachedBaseUrl;
14
18
  }
@@ -24,7 +28,11 @@ export async function getTMBaseURL(config) {
24
28
  raise_error: false,
25
29
  });
26
30
  if (res.ok) {
27
- cachedBaseUrl = baseUrl;
31
+ // Only populate the cache in single-tenant (stdio) mode; in remote mode
32
+ // the cache must stay empty so each user discovers their own region.
33
+ if (!appConfig.REMOTE_MCP) {
34
+ cachedBaseUrl = baseUrl;
35
+ }
28
36
  logger.info(`Selected TM base URL: ${baseUrl}`);
29
37
  return baseUrl;
30
38
  }
@@ -0,0 +1,23 @@
1
+ export interface UploadValidationOptions {
2
+ allowedExtensions: readonly string[];
3
+ maxSizeBytes: number;
4
+ allowedBaseDir?: string;
5
+ }
6
+ /**
7
+ * Canonicalizes and validates a user-supplied upload path. Returns the resolved
8
+ * absolute path that callers should stream from. Throws on any rule violation.
9
+ *
10
+ * Rules enforced:
11
+ * - Path resolves (via realpath) to an existing regular file
12
+ * - File size is within `maxSizeBytes`
13
+ * - File extension is in `allowedExtensions` (case-insensitive)
14
+ * - No path segment is a hidden dir/file (starts with `.`); blocks ~/.ssh,
15
+ * ~/.aws, .env, etc. even after symlink resolution
16
+ * - If `allowedBaseDir` is set, the canonical path must live inside it
17
+ */
18
+ export declare function validateUploadPath(filePath: string, options: UploadValidationOptions): string;
19
+ export declare const APP_BINARY_EXTENSIONS: readonly [".apk", ".aab", ".ipa", ".app", ".zip"];
20
+ export declare const TEST_MANAGEMENT_ATTACHMENT_EXTENSIONS: readonly [".pdf", ".txt", ".md", ".doc", ".docx", ".png", ".jpg", ".jpeg", ".gif", ".csv", ".xls", ".xlsx", ".json", ".html", ".zip"];
21
+ export declare const ONE_MB: number;
22
+ export declare const MAX_APP_UPLOAD_BYTES: number;
23
+ export declare const MAX_ATTACHMENT_UPLOAD_BYTES: number;
@@ -0,0 +1,94 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ /**
4
+ * Canonicalizes and validates a user-supplied upload path. Returns the resolved
5
+ * absolute path that callers should stream from. Throws on any rule violation.
6
+ *
7
+ * Rules enforced:
8
+ * - Path resolves (via realpath) to an existing regular file
9
+ * - File size is within `maxSizeBytes`
10
+ * - File extension is in `allowedExtensions` (case-insensitive)
11
+ * - No path segment is a hidden dir/file (starts with `.`); blocks ~/.ssh,
12
+ * ~/.aws, .env, etc. even after symlink resolution
13
+ * - If `allowedBaseDir` is set, the canonical path must live inside it
14
+ */
15
+ export function validateUploadPath(filePath, options) {
16
+ if (typeof filePath !== "string" || filePath.trim().length === 0) {
17
+ throw new Error("Upload rejected: file path is empty.");
18
+ }
19
+ let canonical;
20
+ try {
21
+ canonical = fs.realpathSync(path.resolve(filePath));
22
+ }
23
+ catch {
24
+ throw new Error(`File not found at path: ${filePath}`);
25
+ }
26
+ let stats;
27
+ try {
28
+ stats = fs.statSync(canonical);
29
+ }
30
+ catch {
31
+ throw new Error(`File not found at path: ${filePath}`);
32
+ }
33
+ if (!stats.isFile()) {
34
+ throw new Error(`Upload rejected: path does not point to a regular file: ${filePath}`);
35
+ }
36
+ if (stats.size > options.maxSizeBytes) {
37
+ const maxMb = Math.round(options.maxSizeBytes / (1024 * 1024));
38
+ throw new Error(`Upload rejected: file exceeds maximum allowed size of ${maxMb} MB.`);
39
+ }
40
+ const segments = canonical.split(path.sep).filter((s) => s.length > 0);
41
+ for (const seg of segments) {
42
+ if (seg.startsWith(".") && seg !== "." && seg !== "..") {
43
+ throw new Error(`Upload rejected: path traverses a hidden directory or file ("${seg}"). Move the file to a non-hidden location or set MCP_UPLOAD_BASE_DIR.`);
44
+ }
45
+ }
46
+ const ext = path.extname(canonical).toLowerCase();
47
+ const allowed = options.allowedExtensions.map((e) => e.toLowerCase());
48
+ if (!allowed.includes(ext)) {
49
+ throw new Error(`Upload rejected: file extension "${ext || "(none)"}" is not in the allowed list (${allowed.join(", ")}).`);
50
+ }
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
+ }
65
+ }
66
+ return canonical;
67
+ }
68
+ export const APP_BINARY_EXTENSIONS = [
69
+ ".apk",
70
+ ".aab",
71
+ ".ipa",
72
+ ".app",
73
+ ".zip",
74
+ ];
75
+ export const TEST_MANAGEMENT_ATTACHMENT_EXTENSIONS = [
76
+ ".pdf",
77
+ ".txt",
78
+ ".md",
79
+ ".doc",
80
+ ".docx",
81
+ ".png",
82
+ ".jpg",
83
+ ".jpeg",
84
+ ".gif",
85
+ ".csv",
86
+ ".xls",
87
+ ".xlsx",
88
+ ".json",
89
+ ".html",
90
+ ".zip",
91
+ ];
92
+ export const ONE_MB = 1024 * 1024;
93
+ export const MAX_APP_UPLOAD_BYTES = 4 * 1024 * ONE_MB; // 4 GB — matches BrowserStack app upload limit
94
+ export const MAX_ATTACHMENT_UPLOAD_BYTES = 100 * ONE_MB; // 100 MB
@@ -144,7 +144,7 @@ async function createAuthConfig(args, config) {
144
144
  async function executeCreateAuthConfig(args, server, config) {
145
145
  try {
146
146
  trackMCP("createAccessibilityAuthConfig", server.server.getClientVersion(), undefined, config);
147
- logger.info(`Creating auth config: ${JSON.stringify(args)}`);
147
+ logger.info(`Creating auth config: ${JSON.stringify({ ...args, password: "***" })}`);
148
148
  const result = await createAuthConfig(args, config);
149
149
  return createSuccessResponse([
150
150
  `✅ Auth config "${args.name}" created successfully with ID: ${result.data?.id}`,
@@ -38,6 +38,6 @@ export declare function uploadEspressoApp(appPath: string, config: BrowserStackC
38
38
  export declare function uploadEspressoTestSuite(testSuitePath: string, config: BrowserStackConfig): Promise<string>;
39
39
  export declare function uploadXcuiApp(appPath: string, config: BrowserStackConfig): Promise<string>;
40
40
  export declare function uploadXcuiTestSuite(testSuitePath: string, config: BrowserStackConfig): Promise<string>;
41
- export declare function triggerEspressoBuild(app_url: string, test_suite_url: string, devices: string[], project: string): Promise<string>;
41
+ export declare function triggerEspressoBuild(app_url: string, test_suite_url: string, devices: string[], project: string, config: BrowserStackConfig): Promise<string>;
42
42
  export declare function triggerXcuiBuild(app_url: string, test_suite_url: string, devices: string[], project: string, config: BrowserStackConfig): Promise<string>;
43
43
  export {};
@@ -2,6 +2,9 @@ import fs from "fs";
2
2
  import FormData from "form-data";
3
3
  import { apiClient } from "../../../lib/apiClient.js";
4
4
  import { customFuzzySearch } from "../../../lib/fuzzy.js";
5
+ import { getBrowserStackAuth } from "../../../lib/get-auth.js";
6
+ import { validateUploadPath, APP_BINARY_EXTENSIONS, MAX_APP_UPLOAD_BYTES, } from "../../../lib/upload-validator.js";
7
+ import appConfig from "../../../config.js";
5
8
  /**
6
9
  * Finds devices that exactly match the provided display name.
7
10
  * Uses fuzzy search first, and then filters for exact case-insensitive match.
@@ -73,12 +76,13 @@ export function validateArgs(args) {
73
76
  * Uploads an application file to AppAutomate and returns the app URL
74
77
  */
75
78
  export async function uploadApp(appPath, username, password) {
76
- const filePath = appPath;
77
- if (!fs.existsSync(filePath)) {
78
- throw new Error(`File not found at path: ${filePath}`);
79
- }
79
+ const safePath = validateUploadPath(appPath, {
80
+ allowedExtensions: APP_BINARY_EXTENSIONS,
81
+ maxSizeBytes: MAX_APP_UPLOAD_BYTES,
82
+ allowedBaseDir: appConfig.UPLOAD_BASE_DIR,
83
+ });
80
84
  const formData = new FormData();
81
- formData.append("file", fs.createReadStream(filePath));
85
+ formData.append("file", fs.createReadStream(safePath));
82
86
  const response = await apiClient.post({
83
87
  url: "https://api-cloud.browserstack.com/app-automate/upload",
84
88
  headers: {
@@ -95,12 +99,14 @@ export async function uploadApp(appPath, username, password) {
95
99
  }
96
100
  }
97
101
  // Helper to upload a file to a given BrowserStack endpoint and return a specific property from the response.
98
- async function uploadFileToBrowserStack(filePath, endpoint, responseKey, config) {
99
- if (!fs.existsSync(filePath)) {
100
- throw new Error(`File not found at path: ${filePath}`);
101
- }
102
+ async function uploadFileToBrowserStack(filePath, endpoint, responseKey, config, validation) {
103
+ const safePath = validateUploadPath(filePath, {
104
+ allowedExtensions: validation.allowedExtensions,
105
+ maxSizeBytes: MAX_APP_UPLOAD_BYTES,
106
+ allowedBaseDir: appConfig.UPLOAD_BASE_DIR,
107
+ });
102
108
  const formData = new FormData();
103
- formData.append("file", fs.createReadStream(filePath));
109
+ formData.append("file", fs.createReadStream(safePath));
104
110
  const authHeader = "Basic " +
105
111
  Buffer.from(`${config["browserstack-username"]}:${config["browserstack-access-key"]}`).toString("base64");
106
112
  const response = await apiClient.post({
@@ -118,31 +124,27 @@ async function uploadFileToBrowserStack(filePath, endpoint, responseKey, config)
118
124
  }
119
125
  //Uploads an Android app (.apk or .aab) to BrowserStack Espresso endpoint and returns the app_url
120
126
  export async function uploadEspressoApp(appPath, config) {
121
- return uploadFileToBrowserStack(appPath, "https://api-cloud.browserstack.com/app-automate/espresso/v2/app", "app_url", config);
127
+ return uploadFileToBrowserStack(appPath, "https://api-cloud.browserstack.com/app-automate/espresso/v2/app", "app_url", config, { allowedExtensions: [".apk", ".aab"] });
122
128
  }
123
129
  //Uploads an Espresso test suite (.apk) to BrowserStack and returns the test_suite_url
124
130
  export async function uploadEspressoTestSuite(testSuitePath, config) {
125
- return uploadFileToBrowserStack(testSuitePath, "https://api-cloud.browserstack.com/app-automate/espresso/v2/test-suite", "test_suite_url", config);
131
+ return uploadFileToBrowserStack(testSuitePath, "https://api-cloud.browserstack.com/app-automate/espresso/v2/test-suite", "test_suite_url", config, { allowedExtensions: [".apk"] });
126
132
  }
127
133
  //Uploads an iOS app (.ipa) to BrowserStack XCUITest endpoint and returns the app_url
128
134
  export async function uploadXcuiApp(appPath, config) {
129
- return uploadFileToBrowserStack(appPath, "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/app", "app_url", config);
135
+ return uploadFileToBrowserStack(appPath, "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/app", "app_url", config, { allowedExtensions: [".ipa"] });
130
136
  }
131
137
  //Uploads an XCUITest test suite (.zip) to BrowserStack and returns the test_suite_url
132
138
  export async function uploadXcuiTestSuite(testSuitePath, config) {
133
- return uploadFileToBrowserStack(testSuitePath, "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/test-suite", "test_suite_url", config);
139
+ return uploadFileToBrowserStack(testSuitePath, "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/test-suite", "test_suite_url", config, { allowedExtensions: [".zip"] });
134
140
  }
135
141
  // Triggers an Espresso test run on BrowserStack and returns the build_id
136
- export async function triggerEspressoBuild(app_url, test_suite_url, devices, project) {
137
- const auth = {
138
- username: process.env.BROWSERSTACK_USERNAME || "",
139
- password: process.env.BROWSERSTACK_ACCESS_KEY || "",
140
- };
142
+ export async function triggerEspressoBuild(app_url, test_suite_url, devices, project, config) {
143
+ const authHeader = "Basic " + Buffer.from(getBrowserStackAuth(config)).toString("base64");
141
144
  const response = await apiClient.post({
142
145
  url: "https://api-cloud.browserstack.com/app-automate/espresso/v2/build",
143
146
  headers: {
144
- Authorization: "Basic " +
145
- Buffer.from(`${auth.username}:${auth.password}`).toString("base64"),
147
+ Authorization: authHeader,
146
148
  "Content-Type": "application/json",
147
149
  },
148
150
  body: {
@@ -133,7 +133,7 @@ async function runAppTestsOnBrowserStack(args, config) {
133
133
  const [, deviceName, osVersion] = device;
134
134
  return `${deviceName}-${osVersion}`;
135
135
  });
136
- const build_id = await triggerEspressoBuild(app_url, test_suite_url, deviceStrings, args.project);
136
+ const build_id = await triggerEspressoBuild(app_url, test_suite_url, deviceStrings, args.project, config);
137
137
  return {
138
138
  content: [
139
139
  {
@@ -1,12 +1,16 @@
1
1
  import { apiClient } from "../../lib/apiClient.js";
2
2
  import FormData from "form-data";
3
3
  import fs from "fs";
4
+ import { validateUploadPath, APP_BINARY_EXTENSIONS, MAX_APP_UPLOAD_BYTES, } from "../../lib/upload-validator.js";
5
+ import appConfig from "../../config.js";
4
6
  export async function uploadApp(filePath, username, password) {
5
- if (!fs.existsSync(filePath)) {
6
- throw new Error(`File not found at path: ${filePath}`);
7
- }
7
+ const safePath = validateUploadPath(filePath, {
8
+ allowedExtensions: APP_BINARY_EXTENSIONS,
9
+ maxSizeBytes: MAX_APP_UPLOAD_BYTES,
10
+ allowedBaseDir: appConfig.UPLOAD_BASE_DIR,
11
+ });
8
12
  const formData = new FormData();
9
- formData.append("file", fs.createReadStream(filePath));
13
+ formData.append("file", fs.createReadStream(safePath));
10
14
  try {
11
15
  const response = await apiClient.post({
12
16
  url: "https://api-cloud.browserstack.com/app-live/upload",
@@ -1,6 +1,7 @@
1
1
  import { trackMCP } from "../index.js";
2
2
  import { fetchPercyChanges } from "./review-agent.js";
3
3
  import { addListTestFiles } from "./list-test-files.js";
4
+ import { runPercyScan } from "./run-percy-scan.js";
4
5
  import { SetUpPercyParamsShape } from "./sdk-utils/common/schema.js";
5
6
  import { updateTestsWithPercyCommands } from "./add-percy-snapshots.js";
6
7
  import { approveOrDeclinePercyBuild } from "./review-agent-utils/percy-approve-reject.js";
@@ -8,10 +9,7 @@ import { setUpPercyHandler, simulatePercyChangeHandler, } from "./sdk-utils/hand
8
9
  import { z } from "zod";
9
10
  import { SETUP_PERCY_DESCRIPTION, LIST_TEST_FILES_DESCRIPTION, PERCY_SNAPSHOT_COMMANDS_DESCRIPTION, SIMULATE_PERCY_CHANGE_DESCRIPTION, } from "./sdk-utils/common/constants.js";
10
11
  import { UpdateTestFileWithInstructionsParams } from "./percy-snapshot-utils/constants.js";
11
- import {
12
- // PMAA-100: kept commented so the registration block below is easy to restore once the proper fix lands.
13
- // RunPercyScanParamsShape,
14
- FetchPercyChangesParamsShape, ManagePercyBuildApprovalParamsShape, } from "./sdk-utils/common/schema.js";
12
+ import { RunPercyScanParamsShape, FetchPercyChangesParamsShape, ManagePercyBuildApprovalParamsShape, } from "./sdk-utils/common/schema.js";
15
13
  import { handleMCPError } from "../lib/utils.js";
16
14
  export function registerPercyTools(server, config) {
17
15
  const tools = {};
@@ -68,22 +66,15 @@ export function registerPercyTools(server, config) {
68
66
  return handleMCPError("listTestFiles", server, config, error);
69
67
  }
70
68
  });
71
- // PMAA-100: runPercyScan temporarily disabled fetched Percy token was being
72
- // returned in plaintext within tool output (see HackerOne #3576387). Re-enable
73
- // once the token is replaced with a placeholder in run-percy-scan.ts.
74
- // tools.runPercyScan = server.tool(
75
- // "runPercyScan",
76
- // "Run a Percy visual test scan. Example prompts : Run this Percy build/scan. Never run percy scan/build without this tool",
77
- // RunPercyScanParamsShape,
78
- // async (args) => {
79
- // try {
80
- // trackMCP("runPercyScan", server.server.getClientVersion()!, config);
81
- // return runPercyScan(args, config);
82
- // } catch (error) {
83
- // return handleMCPError("runPercyScan", server, config, error);
84
- // }
85
- // },
86
- // );
69
+ tools.runPercyScan = server.tool("runPercyScan", "Run a Percy visual test scan. Example prompts : Run this Percy build/scan. Never run percy scan/build without this tool", RunPercyScanParamsShape, async (args) => {
70
+ try {
71
+ trackMCP("runPercyScan", server.server.getClientVersion(), config);
72
+ return runPercyScan(args);
73
+ }
74
+ catch (error) {
75
+ return handleMCPError("runPercyScan", server, config, error);
76
+ }
77
+ });
87
78
  tools.fetchPercyChanges = server.tool("fetchPercyChanges", "Retrieves and summarizes all visual changes detected by Percy AI between the latest and previous builds, helping quickly review what has changed in your project.", FetchPercyChangesParamsShape, async (args) => {
88
79
  try {
89
80
  trackMCP("fetchPercyChanges", server.server.getClientVersion(), config);
@@ -1,8 +1,7 @@
1
1
  import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2
2
  import { PercyIntegrationTypeEnum } from "./sdk-utils/common/types.js";
3
- import { BrowserStackConfig } from "../lib/types.js";
4
3
  export declare function runPercyScan(args: {
5
4
  projectName: string;
6
5
  integrationType: PercyIntegrationTypeEnum;
7
6
  instruction?: string;
8
- }, config: BrowserStackConfig): Promise<CallToolResult>;
7
+ }): Promise<CallToolResult>;
@@ -1,14 +1,8 @@
1
- import { getBrowserStackAuth } from "../lib/get-auth.js";
2
- import { fetchPercyToken } from "./sdk-utils/percy-web/fetchPercyToken.js";
3
1
  import { storedPercyResults } from "../lib/inmemory-store.js";
4
2
  import { getFrameworkTestCommand, PERCY_FALLBACK_STEPS, } from "./sdk-utils/percy-web/constants.js";
5
3
  import path from "path";
6
- export async function runPercyScan(args, config) {
7
- const { projectName, integrationType, instruction } = args;
8
- const authorization = getBrowserStackAuth(config);
9
- const percyToken = await fetchPercyToken(projectName, authorization, {
10
- type: integrationType,
11
- });
4
+ export async function runPercyScan(args) {
5
+ const { projectName, instruction } = args;
12
6
  // Check if we have stored data and project matches
13
7
  const stored = storedPercyResults.get();
14
8
  // Compute if we have updated files to run
@@ -16,7 +10,7 @@ export async function runPercyScan(args, config) {
16
10
  const updatedFiles = hasUpdatedFiles ? getUpdatedFiles(stored) : [];
17
11
  // Build steps array with conditional spread
18
12
  const steps = [
19
- generatePercyTokenInstructions(percyToken),
13
+ generatePercyTokenInstructions(),
20
14
  ...(hasUpdatedFiles ? generateUpdatedFilesSteps(stored, updatedFiles) : []),
21
15
  ...(instruction && !hasUpdatedFiles
22
16
  ? generateInstructionSteps(instruction)
@@ -35,12 +29,14 @@ export async function runPercyScan(args, config) {
35
29
  ],
36
30
  };
37
31
  }
38
- function generatePercyTokenInstructions(percyToken) {
39
- return `Set the environment variable for your project:
32
+ function generatePercyTokenInstructions() {
33
+ return `Set the PERCY_TOKEN environment variable for your project. Retrieve your project's token from the Percy dashboard (https://percy.io → Project Settings → Project Token) and add it to your project's .env file (PERCY_TOKEN=<your Percy project token>) or export it in your shell:
40
34
 
41
- export PERCY_TOKEN="${percyToken}"
35
+ - macOS/Linux: export PERCY_TOKEN="<your Percy project token>"
36
+ - Windows (PS): $env:PERCY_TOKEN="<your Percy project token>"
37
+ - Windows (CMD): set PERCY_TOKEN=<your Percy project token>
42
38
 
43
- (For Windows: use 'setx PERCY_TOKEN "${percyToken}"' or 'set PERCY_TOKEN=${percyToken}' as appropriate.)`;
39
+ Do not paste the token into chat or commit it.`;
44
40
  }
45
41
  const toAbs = (p) => p ? path.resolve(p) : undefined;
46
42
  function checkForUpdatedFiles(stored, // storedPercyResults structure
@@ -33,7 +33,7 @@ const GRADLE_SETUP_INSTRUCTIONS = `
33
33
  jvmArgs "-javaagent:\${browserstackSDKArtifact.file}"
34
34
  `;
35
35
  // Generates Maven archetype command for Windows platform
36
- function getMavenCommandForWindows(framework, mavenFramework) {
36
+ function getMavenCommandForWindows(username, accessKey, mavenFramework) {
37
37
  return (`mvn archetype:generate -B ` +
38
38
  `-DarchetypeGroupId="${MAVEN_ARCHETYPE_GROUP_ID}" ` +
39
39
  `-DarchetypeArtifactId="${MAVEN_ARCHETYPE_ARTIFACT_ID}" ` +
@@ -41,8 +41,8 @@ function getMavenCommandForWindows(framework, mavenFramework) {
41
41
  `-DgroupId="${MAVEN_ARCHETYPE_GROUP_ID}" ` +
42
42
  `-DartifactId="${MAVEN_ARCHETYPE_ARTIFACT_ID}" ` +
43
43
  `-Dversion="${MAVEN_ARCHETYPE_VERSION}" ` +
44
- `-DBROWSERSTACK_USERNAME="${process.env.BROWSERSTACK_USERNAME}" ` +
45
- `-DBROWSERSTACK_ACCESS_KEY="${process.env.BROWSERSTACK_ACCESS_KEY}" ` +
44
+ `-DBROWSERSTACK_USERNAME="${username}" ` +
45
+ `-DBROWSERSTACK_ACCESS_KEY="${accessKey}" ` +
46
46
  `-DBROWSERSTACK_FRAMEWORK="${mavenFramework}"`);
47
47
  }
48
48
  // Generates Maven archetype command for Unix-like platforms (macOS/Linux)
@@ -60,7 +60,7 @@ function getJavaSDKInstructions(framework, username, accessKey) {
60
60
  const isWindows = process.platform === "win32";
61
61
  const platformLabel = isWindows ? "Windows" : "macOS/Linux";
62
62
  const mavenCommand = isWindows
63
- ? getMavenCommandForWindows(framework, mavenFramework)
63
+ ? getMavenCommandForWindows(username, accessKey, mavenFramework)
64
64
  : getMavenCommandForUnix(username, accessKey, mavenFramework);
65
65
  return `---STEP---
66
66
  Install BrowserStack Java SDK
@@ -1,7 +1,5 @@
1
1
  import { formatToolResult } from "./common/utils.js";
2
2
  import { PercyIntegrationTypeEnum } from "./common/types.js";
3
- import { getBrowserStackAuth } from "../../lib/get-auth.js";
4
- import { fetchPercyToken } from "./percy-web/fetchPercyToken.js";
5
3
  import { runPercyWeb } from "./percy-web/handler.js";
6
4
  import { runPercyAutomateOnly } from "./percy-automate/handler.js";
7
5
  import { runBstackSDKOnly } from "./bstack/sdkHandler.js";
@@ -32,7 +30,6 @@ export async function setUpPercyHandler(rawInput, config) {
32
30
  filePaths: input.filePaths || [],
33
31
  testFiles: {},
34
32
  });
35
- const authorization = getBrowserStackAuth(config);
36
33
  const folderPaths = input.folderPaths || [];
37
34
  const filePaths = input.filePaths || [];
38
35
  const percyInput = {
@@ -50,9 +47,7 @@ export async function setUpPercyHandler(rawInput, config) {
50
47
  if (!supportCheck.supported) {
51
48
  return percyUnsupportedResult(PercyIntegrationTypeEnum.WEB, supportCheck);
52
49
  }
53
- // Fetch the Percy token
54
- const percyToken = await fetchPercyToken(input.projectName, authorization, { type: PercyIntegrationTypeEnum.WEB });
55
- const result = runPercyWeb(percyInput, percyToken);
50
+ const result = runPercyWeb(percyInput);
56
51
  return await formatToolResult(result, "percy-web");
57
52
  }
58
53
  else if (input.integrationType === PercyIntegrationTypeEnum.AUTOMATE) {
@@ -93,8 +88,7 @@ export async function setUpPercyHandler(rawInput, config) {
93
88
  };
94
89
  const sdkResult = await runBstackSDKOnly(sdkInput, config, true);
95
90
  // Percy Automate instructions
96
- const percyToken = await fetchPercyToken(input.projectName, authorization, { type: PercyIntegrationTypeEnum.AUTOMATE });
97
- const percyAutomateResult = runPercyAutomateOnly(percyInput, percyToken);
91
+ const percyAutomateResult = runPercyAutomateOnly(percyInput);
98
92
  // Combine steps: warning, SDK steps, Percy Automate steps
99
93
  const steps = [
100
94
  {
@@ -1,3 +1,3 @@
1
1
  import { RunTestsInstructionResult } from "../common/types.js";
2
2
  import { SetUpPercyInput } from "../common/schema.js";
3
- export declare function runPercyAutomateOnly(input: SetUpPercyInput, percyToken: string): RunTestsInstructionResult;
3
+ export declare function runPercyAutomateOnly(input: SetUpPercyInput): RunTestsInstructionResult;
@@ -1,5 +1,5 @@
1
1
  import { SUPPORTED_CONFIGURATIONS } from "./frameworks.js";
2
- export function runPercyAutomateOnly(input, percyToken) {
2
+ export function runPercyAutomateOnly(input) {
3
3
  const steps = [];
4
4
  // Assume configuration is supported due to guardrails at orchestration layer
5
5
  const languageConfig = SUPPORTED_CONFIGURATIONS[input.detectedLanguage];
@@ -15,7 +15,7 @@ export function runPercyAutomateOnly(input, percyToken) {
15
15
  steps.push({
16
16
  type: "instruction",
17
17
  title: "Set Percy Token in Environment",
18
- content: `Here is percy token if required {${percyToken}}`,
18
+ content: `Retrieve your project's token from the Percy dashboard (https://percy.io → Project Settings → Project Token) and add it to your project's .env file (PERCY_TOKEN=<your Percy project token>) or export it in your shell (e.g. export PERCY_TOKEN="<your Percy project token>"). Do not paste the token into chat or commit it.`,
19
19
  });
20
20
  steps.push({
21
21
  type: "instruction",
@@ -1,4 +1,4 @@
1
1
  import { RunTestsInstructionResult } from "../common/types.js";
2
2
  import { SetUpPercyInput } from "../common/schema.js";
3
3
  export declare let percyWebSetupInstructions: string;
4
- export declare function runPercyWeb(input: SetUpPercyInput, percyToken: string): RunTestsInstructionResult;
4
+ export declare function runPercyWeb(input: SetUpPercyInput): RunTestsInstructionResult;
@@ -1,6 +1,6 @@
1
1
  import { SUPPORTED_CONFIGURATIONS } from "./frameworks.js";
2
2
  export let percyWebSetupInstructions = "";
3
- export function runPercyWeb(input, percyToken) {
3
+ export function runPercyWeb(input) {
4
4
  const steps = [];
5
5
  // Assume configuration is supported due to guardrails at orchestration layer
6
6
  const languageConfig = SUPPORTED_CONFIGURATIONS[input.detectedLanguage];
@@ -12,9 +12,12 @@ export function runPercyWeb(input, percyToken) {
12
12
  steps.push({
13
13
  type: "instruction",
14
14
  title: "Set Percy Token in Environment",
15
- content: `Set the environment variable for your project:
16
- export PERCY_TOKEN="${percyToken}"
17
- (For Windows: use 'setx PERCY_TOKEN "${percyToken}"' or 'set PERCY_TOKEN=${percyToken}' as appropriate.)`,
15
+ content: `Retrieve your project's token from the Percy dashboard (https://percy.io → Project Settings → Project Token) and add it to your project's .env file (PERCY_TOKEN=<your Percy project token>) or export it in your shell:
16
+ macOS/Linux: export PERCY_TOKEN="<your Percy project token>"
17
+ Windows (PS): $env:PERCY_TOKEN="<your Percy project token>"
18
+ Windows (CMD): set PERCY_TOKEN=<your Percy project token>
19
+
20
+ Do not paste the token into chat or commit it.`,
18
21
  });
19
22
  steps.push({
20
23
  type: "instruction",
@@ -141,7 +141,7 @@ export async function updateTestCase(params, config) {
141
141
  if (params.preconditions !== undefined)
142
142
  testCaseBody.preconditions = params.preconditions;
143
143
  if (params.test_case_steps !== undefined)
144
- testCaseBody.steps = params.test_case_steps;
144
+ testCaseBody.test_case_steps = params.test_case_steps;
145
145
  if (params.owner !== undefined)
146
146
  testCaseBody.owner = params.owner;
147
147
  if (params.status !== undefined)
@@ -8,6 +8,8 @@ import { getBrowserStackAuth } from "../../lib/get-auth.js";
8
8
  import { signedUrlMap } from "../../lib/inmemory-store.js";
9
9
  import { projectIdentifierToId } from "./TCG-utils/api.js";
10
10
  import { getTMBaseURL } from "../../lib/tm-base-url.js";
11
+ import { validateUploadPath, TEST_MANAGEMENT_ATTACHMENT_EXTENSIONS, MAX_ATTACHMENT_UPLOAD_BYTES, } from "../../lib/upload-validator.js";
12
+ import appConfig from "../../config.js";
11
13
  /**
12
14
  * Schema for the upload file tool
13
15
  */
@@ -25,22 +27,17 @@ export const UploadFileSchema = z.object({
25
27
  export async function uploadFile(args, config) {
26
28
  const { project_identifier, file_path } = args;
27
29
  try {
28
- // Validate file exists
29
- if (!fs.existsSync(file_path)) {
30
- return {
31
- content: [
32
- {
33
- type: "text",
34
- text: `File ${file_path} does not exist.`,
35
- },
36
- ],
37
- isError: true,
38
- };
39
- }
30
+ // Canonicalize path and enforce upload safety rules (extension, size,
31
+ // hidden-directory traversal, optional base-dir containment).
32
+ const safePath = validateUploadPath(file_path, {
33
+ allowedExtensions: TEST_MANAGEMENT_ATTACHMENT_EXTENSIONS,
34
+ maxSizeBytes: MAX_ATTACHMENT_UPLOAD_BYTES,
35
+ allowedBaseDir: appConfig.UPLOAD_BASE_DIR,
36
+ });
40
37
  // Get the project ID
41
38
  const projectIdResponse = await projectIdentifierToId(project_identifier, config);
42
39
  const formData = new FormData();
43
- formData.append("attachments[]", fs.createReadStream(file_path));
40
+ formData.append("attachments[]", fs.createReadStream(safePath));
44
41
  const tmBaseUrl = await getTMBaseURL(config);
45
42
  const uploadUrl = `${tmBaseUrl}/api/v1/projects/${projectIdResponse}/generic/attachments/ai_uploads`;
46
43
  const response = await apiClient.post({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@browserstack/mcp-server",
3
- "version": "1.2.16",
3
+ "version": "1.2.17",
4
4
  "description": "BrowserStack's Official MCP Server",
5
5
  "mcpName": "io.github.browserstack/mcp-server",
6
6
  "main": "dist/index.js",