@browserstack/mcp-server 1.2.36 → 1.3.1-beta.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.
Files changed (40) hide show
  1. package/README.md +2 -0
  2. package/dist/config.d.ts +4 -1
  3. package/dist/config.js +23 -2
  4. package/dist/index.d.ts +1 -0
  5. package/dist/index.js +1 -0
  6. package/dist/lib/tm-base-url.js +7 -1
  7. package/dist/tools/accessibility.js +30 -0
  8. package/dist/tools/appautomate.js +20 -2
  9. package/dist/tools/applive.js +6 -0
  10. package/dist/tools/automate.js +6 -0
  11. package/dist/tools/bstack-sdk.js +7 -1
  12. package/dist/tools/build-insights.js +16 -3
  13. package/dist/tools/get-failure-logs.js +6 -0
  14. package/dist/tools/live.js +7 -1
  15. package/dist/tools/observability.js +6 -0
  16. package/dist/tools/percy-sdk.js +49 -7
  17. package/dist/tools/rca-agent-utils/constants.d.ts +2 -0
  18. package/dist/tools/rca-agent-utils/constants.js +8 -0
  19. package/dist/tools/rca-agent-utils/get-failed-test-id.d.ts +3 -2
  20. package/dist/tools/rca-agent-utils/get-failed-test-id.js +94 -25
  21. package/dist/tools/rca-agent-utils/types.d.ts +10 -0
  22. package/dist/tools/rca-agent.d.ts +1 -0
  23. package/dist/tools/rca-agent.js +30 -6
  24. package/dist/tools/selfheal.js +12 -0
  25. package/dist/tools/testmanagement.js +119 -17
  26. package/dist/tools/tfa-rca-collaboration.d.ts +15 -0
  27. package/dist/tools/tfa-rca-collaboration.js +162 -0
  28. package/dist/tools/tfa-rca-utils/build-failure-themes.d.ts +61 -0
  29. package/dist/tools/tfa-rca-utils/build-failure-themes.js +133 -0
  30. package/dist/tools/tfa-rca-utils/constants.d.ts +67 -0
  31. package/dist/tools/tfa-rca-utils/constants.js +103 -0
  32. package/dist/tools/tfa-rca-utils/submit-turn.d.ts +29 -0
  33. package/dist/tools/tfa-rca-utils/submit-turn.js +188 -0
  34. package/dist/tools/tfa-rca-utils/trigger-report.d.ts +22 -0
  35. package/dist/tools/tfa-rca-utils/trigger-report.js +61 -0
  36. package/dist/tools/tfa-rca-utils/turn-result.d.ts +14 -0
  37. package/dist/tools/tfa-rca-utils/turn-result.js +131 -0
  38. package/dist/tools/tfa-rca-utils/types.d.ts +61 -0
  39. package/dist/tools/tfa-rca-utils/types.js +9 -0
  40. package/package.json +1 -1
package/README.md CHANGED
@@ -41,6 +41,8 @@ Click on the buttons below to install MCP in your respective IDE:
41
41
 
42
42
  <a href="http://mcp.browserstack.com/one-click-setup?client=vscode"><img src="assets/one-click-vs-code.png" alt="Install in VS Code" width="160" height="80"></a>&nbsp;&nbsp;&nbsp;<a href="http://mcp.browserstack.com/one-click-setup?client=cursor"><img src="assets/one-click-cursor.png" alt="Install in Cursor" width="150" height="70"></a>
43
43
 
44
+ > **Note:** The hosted Remote MCP server (`mcp.browserstack.com`) is now fully stateless over Streamable HTTP — any Streamable-HTTP client (Claude, Cursor, VS Code, ChatGPT) connects with no configuration change.
45
+
44
46
  #### Note : Ensure you are using Node version >= `18.0`
45
47
  - Check your node version using `node --version`. Recommended version: `v22.15.0` (LTS)
46
48
  - To Upgrade Node :
package/dist/config.d.ts CHANGED
@@ -8,7 +8,10 @@ export declare class Config {
8
8
  readonly USE_OWN_LOCAL_BINARY_PROCESS: boolean;
9
9
  readonly REMOTE_MCP: boolean;
10
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
+ readonly O11Y_TFA_RCA_BASE_URL: string;
12
+ readonly BROWSERSTACK_AUTOMATION_BASE_URL: string;
13
+ readonly BROWSERSTACK_O11Y_UI_BASE_URL: string;
14
+ constructor(DEV_MODE: boolean, browserstackLocalOptions: Record<string, any>, USE_OWN_LOCAL_BINARY_PROCESS: boolean, REMOTE_MCP: boolean, UPLOAD_BASE_DIR: string | undefined, O11Y_TFA_RCA_BASE_URL: string, BROWSERSTACK_AUTOMATION_BASE_URL: string, BROWSERSTACK_O11Y_UI_BASE_URL: string);
12
15
  }
13
16
  declare const config: Config;
14
17
  export default config;
package/dist/config.js CHANGED
@@ -28,6 +28,12 @@ for (const key of BROWSERSTACK_LOCAL_OPTION_KEYS) {
28
28
  browserstackLocalOptions[key] = envVar;
29
29
  }
30
30
  }
31
+ // Overridable via O11Y_TFA_RCA_BASE_URL to target a staging tenant.
32
+ const DEFAULT_O11Y_TFA_RCA_BASE_URL = "https://api-automation.browserstack.com";
33
+ // Overridable via BROWSERSTACK_AUTOMATION_BASE_URL to target a non-prod env.
34
+ const DEFAULT_BROWSERSTACK_AUTOMATION_BASE_URL = "https://api-automation.browserstack.com";
35
+ // Overridable via BROWSERSTACK_O11Y_UI_BASE_URL to point at a staging UI.
36
+ const DEFAULT_BROWSERSTACK_O11Y_UI_BASE_URL = "https://automation.browserstack.com";
31
37
  /**
32
38
  * USE_OWN_LOCAL_BINARY_PROCESS:
33
39
  * If true, the system will not start a new local binary process, but will use the user's own process.
@@ -38,15 +44,30 @@ export class Config {
38
44
  USE_OWN_LOCAL_BINARY_PROCESS;
39
45
  REMOTE_MCP;
40
46
  UPLOAD_BASE_DIR;
41
- constructor(DEV_MODE, browserstackLocalOptions, USE_OWN_LOCAL_BINARY_PROCESS, REMOTE_MCP, UPLOAD_BASE_DIR) {
47
+ O11Y_TFA_RCA_BASE_URL;
48
+ BROWSERSTACK_AUTOMATION_BASE_URL;
49
+ BROWSERSTACK_O11Y_UI_BASE_URL;
50
+ constructor(DEV_MODE, browserstackLocalOptions, USE_OWN_LOCAL_BINARY_PROCESS, REMOTE_MCP, UPLOAD_BASE_DIR, O11Y_TFA_RCA_BASE_URL, BROWSERSTACK_AUTOMATION_BASE_URL, BROWSERSTACK_O11Y_UI_BASE_URL) {
42
51
  this.DEV_MODE = DEV_MODE;
43
52
  this.browserstackLocalOptions = browserstackLocalOptions;
44
53
  this.USE_OWN_LOCAL_BINARY_PROCESS = USE_OWN_LOCAL_BINARY_PROCESS;
45
54
  this.REMOTE_MCP = REMOTE_MCP;
46
55
  this.UPLOAD_BASE_DIR = UPLOAD_BASE_DIR;
56
+ this.O11Y_TFA_RCA_BASE_URL = O11Y_TFA_RCA_BASE_URL;
57
+ this.BROWSERSTACK_AUTOMATION_BASE_URL = BROWSERSTACK_AUTOMATION_BASE_URL;
58
+ this.BROWSERSTACK_O11Y_UI_BASE_URL = BROWSERSTACK_O11Y_UI_BASE_URL;
47
59
  }
48
60
  }
49
61
  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
62
  ? process.env.MCP_UPLOAD_BASE_DIR
51
- : undefined);
63
+ : undefined, process.env.O11Y_TFA_RCA_BASE_URL &&
64
+ process.env.O11Y_TFA_RCA_BASE_URL.length > 0
65
+ ? process.env.O11Y_TFA_RCA_BASE_URL
66
+ : DEFAULT_O11Y_TFA_RCA_BASE_URL, process.env.BROWSERSTACK_AUTOMATION_BASE_URL &&
67
+ process.env.BROWSERSTACK_AUTOMATION_BASE_URL.length > 0
68
+ ? process.env.BROWSERSTACK_AUTOMATION_BASE_URL
69
+ : DEFAULT_BROWSERSTACK_AUTOMATION_BASE_URL, process.env.BROWSERSTACK_O11Y_UI_BASE_URL &&
70
+ process.env.BROWSERSTACK_O11Y_UI_BASE_URL.length > 0
71
+ ? process.env.BROWSERSTACK_O11Y_UI_BASE_URL
72
+ : DEFAULT_BROWSERSTACK_O11Y_UI_BASE_URL);
52
73
  export default config;
package/dist/index.d.ts CHANGED
@@ -3,4 +3,5 @@ import "dotenv/config";
3
3
  export { setLogger } from "./logger.js";
4
4
  export { BrowserStackMcpServer } from "./server-factory.js";
5
5
  export { trackMCP } from "./lib/instrumentation.js";
6
+ export { default as addTfaRcaCollaborationTools } from "./tools/tfa-rca-collaboration.js";
6
7
  export declare const PackageJsonVersion: any;
package/dist/index.js CHANGED
@@ -36,4 +36,5 @@ process.on("exit", () => {
36
36
  export { setLogger } from "./logger.js";
37
37
  export { BrowserStackMcpServer } from "./server-factory.js";
38
38
  export { trackMCP } from "./lib/instrumentation.js";
39
+ export { default as addTfaRcaCollaborationTools } from "./tools/tfa-rca-collaboration.js";
39
40
  export const PackageJsonVersion = packageJson.version;
@@ -20,6 +20,7 @@ export async function getTMBaseURL(config) {
20
20
  const authString = getBrowserStackAuth(config);
21
21
  const [username, password] = authString.split(":");
22
22
  const authHeader = "Basic " + Buffer.from(`${username}:${password}`).toString("base64");
23
+ const failures = [];
23
24
  for (const baseUrl of TM_BASE_URLS) {
24
25
  try {
25
26
  const res = await apiClient.get({
@@ -27,6 +28,9 @@ export async function getTMBaseURL(config) {
27
28
  headers: { Authorization: authHeader },
28
29
  raise_error: false,
29
30
  });
31
+ if (!res.ok) {
32
+ failures.push(`${baseUrl}: HTTP ${res.status}`);
33
+ }
30
34
  if (res.ok) {
31
35
  // Only populate the cache in single-tenant (stdio) mode; in remote mode
32
36
  // the cache must stay empty so each user discovers their own region.
@@ -38,8 +42,10 @@ export async function getTMBaseURL(config) {
38
42
  }
39
43
  }
40
44
  catch (err) {
45
+ const code = err?.code ?? err?.message;
46
+ failures.push(`${baseUrl}: ${code}`);
41
47
  logger.debug(`Failed TM base URL: ${baseUrl} (${err})`);
42
48
  }
43
49
  }
44
- throw new Error("Unable to connect to BrowserStack Test Management. Please check your credentials and network connection.Please open an issue on GitHub if the problem persists");
50
+ throw new Error(`Unable to connect to BrowserStack Test Management. Please check your credentials and network connection.Please open an issue on GitHub if the problem persists. Details: ${failures.join("; ")}`);
45
51
  }
@@ -213,6 +213,12 @@ export default function addAccessibilityTools(server, config) {
213
213
  query: z
214
214
  .string()
215
215
  .describe("Any accessibility, a11y, WCAG, or web accessibility question"),
216
+ }, {
217
+ title: "Accessibility Expert",
218
+ readOnlyHint: true,
219
+ openWorldHint: false,
220
+ destructiveHint: false,
221
+ idempotentHint: true,
216
222
  }, async (args) => {
217
223
  return await executeAccessibilityRAG(args, server, config);
218
224
  });
@@ -227,6 +233,12 @@ export default function addAccessibilityTools(server, config) {
227
233
  .boolean()
228
234
  .optional()
229
235
  .describe("Enable advanced accessibility rules in the scan settings"),
236
+ }, {
237
+ title: "Start Accessibility Scan",
238
+ readOnlyHint: false,
239
+ openWorldHint: false,
240
+ destructiveHint: false,
241
+ idempotentHint: false,
230
242
  }, async (args, context) => {
231
243
  return await executeAccessibilityScan(args, context, server, config);
232
244
  });
@@ -256,6 +268,12 @@ export default function addAccessibilityTools(server, config) {
256
268
  .string()
257
269
  .optional()
258
270
  .describe("CSS selector for submit button (required for form auth)"),
271
+ }, {
272
+ title: "Create Accessibility Auth Config",
273
+ readOnlyHint: false,
274
+ openWorldHint: false,
275
+ destructiveHint: false,
276
+ idempotentHint: false,
259
277
  }, async (args) => {
260
278
  try {
261
279
  const creds = await elicitCredentialsIfSupported(server, { username: args.username, password: args.password }, [
@@ -287,6 +305,12 @@ export default function addAccessibilityTools(server, config) {
287
305
  });
288
306
  tools.getAccessibilityAuthConfig = server.tool("getAccessibilityAuthConfig", "Retrieve an existing authentication configuration by ID.", {
289
307
  configId: z.number().describe("ID of the auth configuration to retrieve"),
308
+ }, {
309
+ title: "Get Accessibility Auth Config",
310
+ readOnlyHint: true,
311
+ openWorldHint: false,
312
+ destructiveHint: false,
313
+ idempotentHint: true,
290
314
  }, async (args) => {
291
315
  return await executeGetAuthConfig(args, server, config);
292
316
  });
@@ -301,6 +325,12 @@ export default function addAccessibilityTools(server, config) {
301
325
  .number()
302
326
  .optional()
303
327
  .describe("Character offset for pagination (default: 0)"),
328
+ }, {
329
+ title: "Fetch Accessibility Issues",
330
+ readOnlyHint: true,
331
+ openWorldHint: false,
332
+ destructiveHint: false,
333
+ idempotentHint: true,
304
334
  }, async (args) => {
305
335
  return await executeFetchAccessibilityIssues(args, server, config);
306
336
  });
@@ -207,6 +207,12 @@ export default function addAppAutomationTools(server, config) {
207
207
  appPath: z
208
208
  .string()
209
209
  .describe("The path to the .apk or .ipa file. Required for app installation."),
210
+ }, {
211
+ title: "Take App Screenshot",
212
+ readOnlyHint: false,
213
+ openWorldHint: false,
214
+ destructiveHint: false,
215
+ idempotentHint: false,
210
216
  }, async (args) => {
211
217
  try {
212
218
  trackMCP("takeAppScreenshot", server.server.getClientVersion(), undefined, config);
@@ -226,7 +232,13 @@ export default function addAppAutomationTools(server, config) {
226
232
  };
227
233
  }
228
234
  });
229
- tools.runAppTestsOnBrowserStack = server.tool("runAppTestsOnBrowserStack", RUN_APP_AUTOMATE_DESCRIPTION, RUN_APP_AUTOMATE_SCHEMA, async (args) => {
235
+ tools.runAppTestsOnBrowserStack = server.tool("runAppTestsOnBrowserStack", RUN_APP_AUTOMATE_DESCRIPTION, RUN_APP_AUTOMATE_SCHEMA, {
236
+ title: "Run App Tests on BrowserStack",
237
+ readOnlyHint: false,
238
+ openWorldHint: false,
239
+ destructiveHint: false,
240
+ idempotentHint: false,
241
+ }, async (args) => {
230
242
  try {
231
243
  trackMCP("runAppTestsOnBrowserStack", server.server.getClientVersion(), undefined, config);
232
244
  const devicesAsArrays = args.devices.map((device) => [device.platform, device.deviceName, device.osVersion]);
@@ -246,7 +258,13 @@ export default function addAppAutomationTools(server, config) {
246
258
  };
247
259
  }
248
260
  });
249
- tools.setupBrowserStackAppAutomateTests = server.tool("setupBrowserStackAppAutomateTests", SETUP_APP_AUTOMATE_DESCRIPTION, SETUP_APP_AUTOMATE_SCHEMA, async (args) => {
261
+ tools.setupBrowserStackAppAutomateTests = server.tool("setupBrowserStackAppAutomateTests", SETUP_APP_AUTOMATE_DESCRIPTION, SETUP_APP_AUTOMATE_SCHEMA, {
262
+ title: "Set Up App Automate Tests",
263
+ readOnlyHint: true,
264
+ openWorldHint: false,
265
+ destructiveHint: false,
266
+ idempotentHint: true,
267
+ }, async (args) => {
250
268
  try {
251
269
  return await setupAppAutomateHandler(args, config);
252
270
  }
@@ -67,6 +67,12 @@ export default function addAppLiveTools(server, config) {
67
67
  appPath: z
68
68
  .string()
69
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."),
70
+ }, {
71
+ title: "Run App Live Session",
72
+ readOnlyHint: false,
73
+ openWorldHint: false,
74
+ destructiveHint: false,
75
+ idempotentHint: false,
70
76
  }, async (args) => {
71
77
  try {
72
78
  trackMCP("runAppLiveSession", server.server.getClientVersion(), undefined, config);
@@ -58,6 +58,12 @@ export default function addAutomationTools(server, config) {
58
58
  sessionType: z
59
59
  .enum([SessionType.Automate, SessionType.AppAutomate])
60
60
  .describe("Type of BrowserStack session"),
61
+ }, {
62
+ title: "Fetch Automation Screenshots",
63
+ readOnlyHint: true,
64
+ openWorldHint: false,
65
+ destructiveHint: false,
66
+ idempotentHint: true,
61
67
  }, async (args) => {
62
68
  try {
63
69
  trackMCP("fetchAutomationScreenshots", server.server.getClientVersion(), undefined, config);
@@ -5,7 +5,13 @@ import { handleMCPError } from "../lib/utils.js";
5
5
  import { trackMCP } from "../lib/instrumentation.js";
6
6
  export function registerRunBrowserStackTestsTool(server, config) {
7
7
  const tools = {};
8
- tools.setupBrowserStackAutomateTests = server.tool("setupBrowserStackAutomateTests", RUN_ON_BROWSERSTACK_DESCRIPTION, RunTestsOnBrowserStackParamsShape, async (args) => {
8
+ tools.setupBrowserStackAutomateTests = server.tool("setupBrowserStackAutomateTests", RUN_ON_BROWSERSTACK_DESCRIPTION, RunTestsOnBrowserStackParamsShape, {
9
+ title: "Set Up Automate Tests",
10
+ readOnlyHint: true,
11
+ openWorldHint: false,
12
+ destructiveHint: false,
13
+ idempotentHint: true,
14
+ }, async (args) => {
9
15
  try {
10
16
  trackMCP("runTestsOnBrowserStack", server.server.getClientVersion(), config);
11
17
  return await runTestsOnBrowserStackHandler(args, config);
@@ -7,9 +7,13 @@ export async function fetchBuildInsightsTool(args, config) {
7
7
  try {
8
8
  const buildUrl = `https://api-automation.browserstack.com/ext/v1/builds/${args.buildId}`;
9
9
  const qualityGateUrl = `https://api-automation.browserstack.com/ext/v1/quality-gates/${args.buildId}`;
10
+ // Quality gate data is optional — a failure there should not block build insights
10
11
  const [buildData, qualityData] = await Promise.all([
11
12
  fetchFromBrowserStackAPI(buildUrl, config),
12
- fetchFromBrowserStackAPI(qualityGateUrl, config),
13
+ fetchFromBrowserStackAPI(qualityGateUrl, config).catch((error) => {
14
+ logger.warn("Failed to fetch quality gate data", error);
15
+ return null;
16
+ }),
13
17
  ]);
14
18
  // Select useful fields for users
15
19
  const insights = {
@@ -25,9 +29,12 @@ export async function fetchBuildInsightsTool(args, config) {
25
29
  unique_errors: buildData.unique_errors?.overview,
26
30
  observability_url: buildData?.observability_url,
27
31
  ci_build_url: buildData.ci_info?.build_url,
28
- quality_gate_result: qualityData.quality_gate_result,
32
+ branch: buildData.vcs_info?.branch,
33
+ commit_sha: buildData.vcs_info?.sha,
34
+ vcs_name: buildData.vcs_info?.name,
35
+ quality_gate_result: qualityData?.quality_gate_result,
29
36
  };
30
- const qualityProfiles = qualityData.quality_profiles?.map((profile) => ({
37
+ const qualityProfiles = qualityData?.quality_profiles?.map((profile) => ({
31
38
  name: profile.name,
32
39
  result: profile.result,
33
40
  }));
@@ -54,6 +61,12 @@ export default function addBuildInsightsTools(server, config) {
54
61
  const tools = {};
55
62
  tools.fetchBuildInsights = server.tool("fetchBuildInsights", "Fetches insights about a BrowserStack build by combining build details and quality gate results.", {
56
63
  buildId: z.string().describe("The build UUID of the BrowserStack build"),
64
+ }, {
65
+ title: "Fetch Build Insights",
66
+ readOnlyHint: true,
67
+ openWorldHint: false,
68
+ destructiveHint: false,
69
+ idempotentHint: true,
57
70
  }, async (args) => {
58
71
  try {
59
72
  trackMCP("fetchBuildInsights", server.server.getClientVersion(), config);
@@ -119,6 +119,12 @@ export default function registerGetFailureLogs(server, config) {
119
119
  AppAutomateLogType.CrashLogs,
120
120
  ]))
121
121
  .describe("The types of logs to fetch."),
122
+ }, {
123
+ title: "Get Failure Logs",
124
+ readOnlyHint: true,
125
+ openWorldHint: false,
126
+ destructiveHint: false,
127
+ idempotentHint: true,
122
128
  }, async (args) => {
123
129
  try {
124
130
  trackMCP("getFailureLogs", server.server.getClientVersion(), undefined, config);
@@ -88,7 +88,13 @@ async function runBrowserSession(rawArgs, config) {
88
88
  }
89
89
  export default function addBrowserLiveTools(server, config) {
90
90
  const tools = {};
91
- tools.runBrowserLiveSession = server.tool("runBrowserLiveSession", "Launch a BrowserStack Live session (desktop or mobile).", LiveArgsShape, async (args) => {
91
+ tools.runBrowserLiveSession = server.tool("runBrowserLiveSession", "Launch a BrowserStack Live session (desktop or mobile).", LiveArgsShape, {
92
+ title: "Run Browser Live Session",
93
+ readOnlyHint: false,
94
+ openWorldHint: true,
95
+ destructiveHint: false,
96
+ idempotentHint: false,
97
+ }, async (args) => {
92
98
  try {
93
99
  trackMCP("runBrowserLiveSession", server.server.getClientVersion(), undefined, config);
94
100
  return await runBrowserSession(args, config);
@@ -39,6 +39,12 @@ export default function addObservabilityTools(server, config) {
39
39
  projectName: z
40
40
  .string()
41
41
  .describe("Name of the project to get failures for. This is the 'projectName' key in the browserstack.yml file. If not sure, ask the user for the project name."),
42
+ }, {
43
+ title: "Get Failures in Last Run",
44
+ readOnlyHint: true,
45
+ openWorldHint: false,
46
+ destructiveHint: false,
47
+ idempotentHint: true,
42
48
  }, async (args) => {
43
49
  try {
44
50
  trackMCP("getFailuresInLastRun", server.server.getClientVersion(), undefined, config);
@@ -30,7 +30,13 @@ export function registerPercyTools(server, config) {
30
30
  ],
31
31
  };
32
32
  });
33
- tools.percyVisualTestIntegrationAgent = server.tool("percyVisualTestIntegrationAgent", SIMULATE_PERCY_CHANGE_DESCRIPTION, SetUpPercyParamsShape, async (args) => {
33
+ tools.percyVisualTestIntegrationAgent = server.tool("percyVisualTestIntegrationAgent", SIMULATE_PERCY_CHANGE_DESCRIPTION, SetUpPercyParamsShape, {
34
+ title: "Percy Visual Test Integration Agent",
35
+ readOnlyHint: true,
36
+ openWorldHint: false,
37
+ destructiveHint: false,
38
+ idempotentHint: true,
39
+ }, async (args) => {
34
40
  try {
35
41
  trackMCP("VisualTestIntegrationAgent", server.server.getClientVersion(), config);
36
42
  return simulatePercyChangeHandler(args, config);
@@ -39,7 +45,13 @@ export function registerPercyTools(server, config) {
39
45
  return handleMCPError("VisualTestIntegrationAgent", server, config, error);
40
46
  }
41
47
  });
42
- tools.setupPercyVisualTesting = server.tool("expandPercyVisualTesting", SETUP_PERCY_DESCRIPTION, SetUpPercyParamsShape, async (args) => {
48
+ tools.setupPercyVisualTesting = server.tool("expandPercyVisualTesting", SETUP_PERCY_DESCRIPTION, SetUpPercyParamsShape, {
49
+ title: "Expand Percy Visual Testing",
50
+ readOnlyHint: true,
51
+ openWorldHint: false,
52
+ destructiveHint: false,
53
+ idempotentHint: true,
54
+ }, async (args) => {
43
55
  try {
44
56
  trackMCP("setupPercyVisualTesting", server.server.getClientVersion(), config);
45
57
  return setUpPercyHandler(args, config);
@@ -48,7 +60,13 @@ export function registerPercyTools(server, config) {
48
60
  return handleMCPError("setupPercyVisualTesting", server, config, error);
49
61
  }
50
62
  });
51
- tools.addPercySnapshotCommands = server.tool("addPercySnapshotCommands", PERCY_SNAPSHOT_COMMANDS_DESCRIPTION, UpdateTestFileWithInstructionsParams, async (args) => {
63
+ tools.addPercySnapshotCommands = server.tool("addPercySnapshotCommands", PERCY_SNAPSHOT_COMMANDS_DESCRIPTION, UpdateTestFileWithInstructionsParams, {
64
+ title: "Add Percy Snapshot Commands",
65
+ readOnlyHint: true,
66
+ openWorldHint: false,
67
+ destructiveHint: false,
68
+ idempotentHint: true,
69
+ }, async (args) => {
52
70
  try {
53
71
  trackMCP("addPercySnapshotCommands", server.server.getClientVersion(), config);
54
72
  return await updateTestsWithPercyCommands(args);
@@ -57,7 +75,13 @@ export function registerPercyTools(server, config) {
57
75
  return handleMCPError("addPercySnapshotCommands", server, config, error);
58
76
  }
59
77
  });
60
- tools.listTestFiles = server.tool("listTestFiles", LIST_TEST_FILES_DESCRIPTION, {}, async () => {
78
+ tools.listTestFiles = server.tool("listTestFiles", LIST_TEST_FILES_DESCRIPTION, {}, {
79
+ title: "List Test Files",
80
+ readOnlyHint: true,
81
+ openWorldHint: false,
82
+ destructiveHint: false,
83
+ idempotentHint: true,
84
+ }, async () => {
61
85
  try {
62
86
  trackMCP("listTestFiles", server.server.getClientVersion(), config);
63
87
  return addListTestFiles();
@@ -66,7 +90,13 @@ export function registerPercyTools(server, config) {
66
90
  return handleMCPError("listTestFiles", server, config, error);
67
91
  }
68
92
  });
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) => {
93
+ 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, {
94
+ title: "Run Percy Scan",
95
+ readOnlyHint: true,
96
+ openWorldHint: false,
97
+ destructiveHint: false,
98
+ idempotentHint: true,
99
+ }, async (args) => {
70
100
  try {
71
101
  trackMCP("runPercyScan", server.server.getClientVersion(), config);
72
102
  return runPercyScan(args);
@@ -75,7 +105,13 @@ export function registerPercyTools(server, config) {
75
105
  return handleMCPError("runPercyScan", server, config, error);
76
106
  }
77
107
  });
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) => {
108
+ 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, {
109
+ title: "Fetch Percy Changes",
110
+ readOnlyHint: true,
111
+ openWorldHint: false,
112
+ destructiveHint: false,
113
+ idempotentHint: true,
114
+ }, async (args) => {
79
115
  try {
80
116
  trackMCP("fetchPercyChanges", server.server.getClientVersion(), config);
81
117
  return await fetchPercyChanges(args, config);
@@ -84,7 +120,13 @@ export function registerPercyTools(server, config) {
84
120
  return handleMCPError("fetchPercyChanges", server, config, error);
85
121
  }
86
122
  });
87
- tools.managePercyBuildApproval = server.tool("managePercyBuildApproval", "Approve or reject a Percy build", ManagePercyBuildApprovalParamsShape, async (args) => {
123
+ tools.managePercyBuildApproval = server.tool("managePercyBuildApproval", "Approve or reject a Percy build", ManagePercyBuildApprovalParamsShape, {
124
+ title: "Manage Percy Build Approval",
125
+ readOnlyHint: false,
126
+ openWorldHint: false,
127
+ destructiveHint: true,
128
+ idempotentHint: true,
129
+ }, async (args) => {
88
130
  try {
89
131
  trackMCP("managePercyBuildApproval", server.server.getClientVersion(), config);
90
132
  return await approveOrDeclinePercyBuild(args, config);
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { TestStatus } from "./types.js";
3
+ export declare function getAutomationBaseUrl(): string;
3
4
  export declare const FETCH_RCA_PARAMS: {
4
5
  testId: z.ZodArray<z.ZodNumber>;
5
6
  };
@@ -10,4 +11,5 @@ export declare const GET_BUILD_ID_PARAMS: {
10
11
  export declare const LIST_TEST_IDS_PARAMS: {
11
12
  buildId: z.ZodString;
12
13
  status: z.ZodEnum<typeof TestStatus>;
14
+ includeFailureDetail: z.ZodOptional<z.ZodBoolean>;
13
15
  };
@@ -1,5 +1,9 @@
1
1
  import { z } from "zod";
2
+ import appConfig from "../../config.js";
2
3
  import { TestStatus } from "./types.js";
4
+ export function getAutomationBaseUrl() {
5
+ return appConfig.BROWSERSTACK_AUTOMATION_BASE_URL;
6
+ }
3
7
  export const FETCH_RCA_PARAMS = {
4
8
  testId: z
5
9
  .array(z.number().int())
@@ -21,4 +25,8 @@ export const LIST_TEST_IDS_PARAMS = {
21
25
  status: z
22
26
  .nativeEnum(TestStatus)
23
27
  .describe("Filter tests by status. If not provided, all tests are returned. Example for RCA usecase always use failed status"),
28
+ includeFailureDetail: z
29
+ .boolean()
30
+ .optional()
31
+ .describe("Add per-test failure signature for clustering. Default false."),
24
32
  };
@@ -1,3 +1,4 @@
1
1
  import { TestStatus, FailedTestInfo, TestDetails } from "./types.js";
2
- export declare function getTestIds(buildId: string, authString: string, status?: TestStatus): Promise<FailedTestInfo[]>;
3
- export declare function extractFailedTestIds(hierarchy: TestDetails[], status?: TestStatus): FailedTestInfo[];
2
+ export declare function getTestIds(buildId: string, authString: string, status?: TestStatus, includeFailureDetail?: boolean): Promise<FailedTestInfo[]>;
3
+ export declare function extractTestIds(hierarchy: TestDetails[], status?: TestStatus, includeFailureDetail?: boolean): FailedTestInfo[];
4
+ export declare const extractFailedTestIds: typeof extractTestIds;
@@ -1,8 +1,15 @@
1
1
  import logger from "../../logger.js";
2
- export async function getTestIds(buildId, authString, status) {
3
- const baseUrl = `https://api-automation.browserstack.com/ext/v1/builds/${buildId}/testRuns`;
2
+ import { getAutomationBaseUrl } from "./constants.js";
3
+ import { TestStatus, } from "./types.js";
4
+ // Keeps the response payload lean — never full stack traces.
5
+ const ERROR_SUMMARY_MAX = 200;
6
+ // Safety bound on pagination; hitting it is logged, never silent.
7
+ const MAX_PAGES = 100;
8
+ export async function getTestIds(buildId, authString, status, includeFailureDetail = false) {
9
+ // No `status` → returns ALL tests; a `status` narrows the query.
10
+ const baseUrl = `${getAutomationBaseUrl()}/ext/v1/builds/${buildId}/testRuns`;
4
11
  let url = status ? `${baseUrl}?test_statuses=${status}` : baseUrl;
5
- let allFailedTests = [];
12
+ let allTests = [];
6
13
  let requestNumber = 0;
7
14
  // Construct Basic auth header
8
15
  const encodedCredentials = Buffer.from(authString).toString("base64");
@@ -20,15 +27,16 @@ export async function getTestIds(buildId, authString, status) {
20
27
  throw new Error(`Failed to fetch test runs: ${response.status} ${response.statusText}`);
21
28
  }
22
29
  const data = (await response.json());
23
- // Extract failed IDs from current page
30
+ // Extract test IDs from the current page (all tests unless narrowed).
24
31
  if (data.hierarchy && data.hierarchy.length > 0) {
25
- const currentFailedTests = extractFailedTestIds(data.hierarchy, status);
26
- allFailedTests = allFailedTests.concat(currentFailedTests);
32
+ const currentTests = extractTestIds(data.hierarchy, status, includeFailureDetail);
33
+ allTests = allTests.concat(currentTests);
27
34
  }
28
- // Check for pagination termination conditions
29
- if (!data.pagination?.has_next ||
30
- !data.pagination.next_page ||
31
- requestNumber >= 5) {
35
+ if (!data.pagination?.has_next || !data.pagination.next_page) {
36
+ break;
37
+ }
38
+ if (requestNumber >= MAX_PAGES) {
39
+ logger.warn(`listTestIds: hit MAX_PAGES (${MAX_PAGES}) for build ${buildId}; result may be partial`);
32
40
  break;
33
41
  }
34
42
  const params = {
@@ -38,31 +46,92 @@ export async function getTestIds(buildId, authString, status) {
38
46
  params.test_statuses = status;
39
47
  url = `${baseUrl}?${new URLSearchParams(params).toString()}`;
40
48
  }
41
- // Return unique failed test IDs
42
- return allFailedTests;
49
+ return allTests;
43
50
  }
44
51
  catch (error) {
45
- logger.error("Error fetching failed tests:", error);
52
+ logger.error("Error fetching test runs:", error);
46
53
  throw error;
47
54
  }
48
55
  }
49
- export function extractFailedTestIds(hierarchy, status) {
50
- let failedTests = [];
56
+ export function extractTestIds(hierarchy, status, includeFailureDetail = false) {
57
+ let tests = [];
51
58
  for (const node of hierarchy) {
52
- if (node.details?.status === status) {
53
- if (node.details?.observability_url) {
54
- const idMatch = node.details.observability_url.match(/details=(\d+)/);
55
- if (idMatch) {
56
- failedTests.push({
57
- test_id: idMatch[1],
58
- test_name: node.display_name || `Test ${idMatch[1]}`,
59
- });
59
+ // observability_url `details=<id>` filters out suite/hook nodes. run_count
60
+ // is not required — JUnit-uploaded builds report run_count=0 for real tests too.
61
+ const nodeStatus = node.details?.status;
62
+ const statusMatches = status === undefined || nodeStatus === status;
63
+ if (statusMatches && node.details?.observability_url) {
64
+ const idMatch = node.details.observability_url.match(/details=(\d+)/);
65
+ if (idMatch) {
66
+ const entry = {
67
+ test_id: idMatch[1],
68
+ test_name: node.display_name || `Test ${idMatch[1]}`,
69
+ status: nodeStatus,
70
+ };
71
+ // Failure signatures only exist for failed tests; include when asked.
72
+ if (includeFailureDetail && nodeStatus === TestStatus.FAILED) {
73
+ const signature = buildFailureSignature(node.details);
74
+ if (signature)
75
+ entry.failure = signature;
60
76
  }
77
+ tests.push(entry);
61
78
  }
62
79
  }
63
80
  if (node.children && node.children.length > 0) {
64
- failedTests = failedTests.concat(extractFailedTestIds(node.children, status));
81
+ tests = tests.concat(extractTestIds(node.children, status, includeFailureDetail));
82
+ }
83
+ }
84
+ return tests;
85
+ }
86
+ // Back-compat alias — prefer extractTestIds. Kept so existing imports/tests
87
+ // referencing the old name continue to resolve.
88
+ export const extractFailedTestIds = extractTestIds;
89
+ // Build a trimmed failure signature from a test node's `details`. Returns
90
+ // undefined when no signal is available so the field is simply omitted.
91
+ function buildFailureSignature(details) {
92
+ if (!details)
93
+ return undefined;
94
+ const signature = {};
95
+ if (details.failure_categories != null) {
96
+ signature.category = Array.isArray(details.failure_categories)
97
+ ? details.failure_categories.filter(Boolean).join(", ")
98
+ : String(details.failure_categories);
99
+ }
100
+ const errorSummary = extractFirstFailureLine(details);
101
+ if (errorSummary)
102
+ signature.error_summary = errorSummary;
103
+ if (details.file_path)
104
+ signature.file_path = String(details.file_path);
105
+ if (typeof details.is_flaky === "boolean")
106
+ signature.is_flaky = details.is_flaky;
107
+ if (typeof details.is_always_failing === "boolean")
108
+ signature.is_always_failing = details.is_always_failing;
109
+ if (typeof details.is_new_failure === "boolean")
110
+ signature.is_new_failure = details.is_new_failure;
111
+ return Object.keys(signature).length > 0 ? signature : undefined;
112
+ }
113
+ // First non-empty line of the first retry's TEST_FAILURE log, capped. Handles
114
+ // both string entries and object entries ({ message } / { text }).
115
+ function extractFirstFailureLine(details) {
116
+ const retries = details?.retries;
117
+ if (!Array.isArray(retries))
118
+ return undefined;
119
+ for (const retry of retries) {
120
+ const failures = retry?.logs?.TEST_FAILURE;
121
+ if (!failures)
122
+ continue;
123
+ const entries = Array.isArray(failures) ? failures : [failures];
124
+ for (const failure of entries) {
125
+ const text = typeof failure === "string"
126
+ ? failure
127
+ : (failure?.message ?? failure?.text ?? "");
128
+ const firstLine = String(text)
129
+ .split("\n")
130
+ .map((line) => line.trim())
131
+ .find((line) => line.length > 0);
132
+ if (firstLine)
133
+ return firstLine.slice(0, ERROR_SUMMARY_MAX);
65
134
  }
66
135
  }
67
- return failedTests;
136
+ return undefined;
68
137
  }