@browserstack/mcp-server 1.1.2 → 1.1.4

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.js CHANGED
@@ -1,7 +1,3 @@
1
- if (!process.env.BROWSERSTACK_ACCESS_KEY ||
2
- !process.env.BROWSERSTACK_USERNAME) {
3
- throw new Error("Unable to start MCP server. Please set the BROWSERSTACK_ACCESS_KEY and BROWSERSTACK_USERNAME environment variables. Go to https://www.browserstack.com/accounts/profile/details to access them");
4
- }
5
1
  export class Config {
6
2
  browserstackUsername;
7
3
  browserstackAccessKey;
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ import addTestManagementTools from "./tools/testmanagement.js";
14
14
  import addAppAutomationTools from "./tools/appautomate.js";
15
15
  import addFailureLogsTools from "./tools/getFailureLogs.js";
16
16
  import addAutomateTools from "./tools/automate.js";
17
- import { trackMCP } from "./lib/instrumentation.js";
17
+ import { setupOnInitialized } from "./oninitialized.js";
18
18
  function registerTools(server) {
19
19
  addSDKTools(server);
20
20
  addAppLiveTools(server);
@@ -30,14 +30,13 @@ const server = new McpServer({
30
30
  name: "BrowserStack MCP Server",
31
31
  version: packageJson.version,
32
32
  });
33
+ setupOnInitialized(server);
33
34
  registerTools(server);
34
35
  async function main() {
35
36
  logger.info("Launching BrowserStack MCP server, version %s", packageJson.version);
36
37
  // Start receiving messages on stdin and sending messages on stdout
37
38
  const transport = new StdioServerTransport();
38
39
  await server.connect(transport);
39
- logger.info("MCP server started successfully");
40
- trackMCP("started", server.server.getClientVersion());
41
40
  }
42
41
  main().catch(console.error);
43
42
  // Ensure logs are flushed before exit
package/dist/lib/local.js CHANGED
@@ -65,15 +65,19 @@ export async function killExistingBrowserStackLocalProcesses() {
65
65
  // Continue execution as there may not be any processes running
66
66
  }
67
67
  }
68
- export async function ensureLocalBinarySetup() {
68
+ export async function ensureLocalBinarySetup(localIdentifier) {
69
69
  logger.info("Ensuring local binary setup as it is required for private URLs...");
70
70
  const localBinary = new Local();
71
71
  await killExistingBrowserStackLocalProcesses();
72
+ const requestBody = {
73
+ key: config.browserstackAccessKey,
74
+ username: config.browserstackUsername,
75
+ };
76
+ if (localIdentifier) {
77
+ requestBody.localIdentifier = localIdentifier;
78
+ }
72
79
  return await new Promise((resolve, reject) => {
73
- localBinary.start({
74
- key: config.browserstackAccessKey,
75
- username: config.browserstackUsername,
76
- }, (error) => {
80
+ localBinary.start(requestBody, (error) => {
77
81
  if (error) {
78
82
  logger.error(`Unable to start BrowserStack Local... please check your credentials and try again. Error: ${error}`);
79
83
  reject(new Error(`Unable to configure local tunnel binary, please check your credentials and try again. Error: ${error}`));
package/dist/lib/utils.js CHANGED
@@ -24,9 +24,3 @@ export async function assertOkResponse(response, action) {
24
24
  throw new Error(`Failed to fetch logs for ${action}: ${response.statusText}`);
25
25
  }
26
26
  }
27
- export function filterLinesByKeywords(logText, keywords) {
28
- return logText
29
- .split(/\r?\n/)
30
- .map((line) => line.trim())
31
- .filter((line) => keywords.some((keyword) => line.toLowerCase().includes(keyword)));
32
- }
@@ -0,0 +1,16 @@
1
+ import config from "./config.js";
2
+ import { trackMCP } from "./lib/instrumentation.js";
3
+ export function setupOnInitialized(server) {
4
+ const nodeVersion = process.versions.node;
5
+ // Check for Node.js version
6
+ if (nodeVersion < "18.0.0") {
7
+ throw new Error("Node version is not supported. Please upgrade to 18.0.0 or later.");
8
+ }
9
+ // Check for BrowserStack credentials
10
+ if (!config.browserstackUsername || !config.browserstackAccessKey) {
11
+ throw new Error("BrowserStack credentials are missing. Please provide a valid username and access key.");
12
+ }
13
+ server.server.oninitialized = () => {
14
+ trackMCP("started", server.server.getClientVersion());
15
+ };
16
+ }
@@ -36,13 +36,17 @@ async function runAccessibilityScan(name, pageURL, context) {
36
36
  }
37
37
  // Fetch CSV report link
38
38
  const reportLink = await reportFetcher.getReportLink(scanId, scanRunId);
39
- const { records } = await parseAccessibilityReportFromCSV(reportLink);
39
+ const { records, page_length, total_issues } = await parseAccessibilityReportFromCSV(reportLink);
40
40
  return {
41
41
  content: [
42
42
  {
43
43
  type: "text",
44
44
  text: `✅ Accessibility scan "${name}" completed. check the BrowserStack dashboard for more details [https://scanner.browserstack.com/site-scanner/scan-details/${name}].`,
45
45
  },
46
+ {
47
+ type: "text",
48
+ text: `We found ${total_issues} issues. Below are the details of the ${page_length} most critical issues.`,
49
+ },
46
50
  {
47
51
  type: "text",
48
52
  text: `Scan results: ${JSON.stringify(records, null, 2)}`,
@@ -17,6 +17,7 @@ export async function parseAccessibilityReportFromCSV(reportLink, { maxCharacter
17
17
  how_to_fix: row["How to fix this issue"],
18
18
  severity: (row["Severity"] || "unknown").trim(),
19
19
  }));
20
+ const totalIssues = all.length;
20
21
  // 2) Sort by severity
21
22
  const order = {
22
23
  critical: 0,
@@ -43,9 +44,12 @@ export async function parseAccessibilityReportFromCSV(reportLink, { maxCharacter
43
44
  page.push(all[i]);
44
45
  charCursor += recStr.length;
45
46
  }
46
- const hasMore = idx + page.length < all.length;
47
+ const pageLength = page.length;
48
+ const hasMore = idx + pageLength < totalIssues;
47
49
  return {
48
50
  records: page,
49
51
  next_page: hasMore ? charCursor : null,
52
+ page_length: pageLength,
53
+ total_issues: totalIssues,
50
54
  };
51
55
  }
@@ -1,13 +1,55 @@
1
1
  import axios from "axios";
2
2
  import config from "../../config.js";
3
+ import logger from "../../logger.js";
4
+ import { isLocalURL, ensureLocalBinarySetup, killExistingBrowserStackLocalProcesses, } from "../../lib/local.js";
3
5
  export class AccessibilityScanner {
4
6
  auth = {
5
7
  username: config.browserstackUsername,
6
8
  password: config.browserstackAccessKey,
7
9
  };
8
10
  async startScan(name, urlList) {
11
+ // Check if any URL is local
12
+ const hasLocal = urlList.some(isLocalURL);
13
+ const localIdentifier = crypto.randomUUID();
14
+ const localHosts = new Set(["127.0.0.1", "localhost", "0.0.0.0"]);
15
+ const BS_LOCAL_DOMAIN = "bs-local.com";
16
+ if (hasLocal) {
17
+ await ensureLocalBinarySetup(localIdentifier);
18
+ }
19
+ else {
20
+ await killExistingBrowserStackLocalProcesses();
21
+ }
22
+ const transformedUrlList = urlList.map((url) => {
23
+ try {
24
+ const parsed = new URL(url);
25
+ if (localHosts.has(parsed.hostname)) {
26
+ parsed.hostname = BS_LOCAL_DOMAIN;
27
+ return parsed.toString();
28
+ }
29
+ return url;
30
+ }
31
+ catch (e) {
32
+ logger.warn(`[AccessibilityScan] Invalid URL skipped: ${e}`);
33
+ return url;
34
+ }
35
+ });
36
+ const baseRequestBody = {
37
+ name,
38
+ urlList: transformedUrlList,
39
+ recurring: false,
40
+ };
41
+ let requestBody = baseRequestBody;
42
+ if (hasLocal) {
43
+ const localConfig = {
44
+ localTestingInfo: {
45
+ localIdentifier,
46
+ localEnabled: true,
47
+ },
48
+ };
49
+ requestBody = { ...baseRequestBody, ...localConfig };
50
+ }
9
51
  try {
10
- const { data } = await axios.post("https://api-accessibility.browserstack.com/api/website-scanner/v1/scans", { name, urlList, recurring: false }, { auth: this.auth });
52
+ const { data } = await axios.post("https://api-accessibility.browserstack.com/api/website-scanner/v1/scans", requestBody, { auth: this.auth });
11
53
  if (!data.success)
12
54
  throw new Error(`Unable to start scan: ${data.errors?.join(", ")}`);
13
55
  return data;
@@ -1,5 +1,5 @@
1
1
  import config from "../../config.js";
2
- import { assertOkResponse, filterLinesByKeywords } from "../../lib/utils.js";
2
+ import { filterLinesByKeywords, validateLogResponse } from "./utils.js";
3
3
  const auth = Buffer.from(`${config.browserstackUsername}:${config.browserstackAccessKey}`).toString("base64");
4
4
  // DEVICE LOGS
5
5
  export async function retrieveDeviceLogs(sessionId, buildId) {
@@ -10,9 +10,14 @@ export async function retrieveDeviceLogs(sessionId, buildId) {
10
10
  Authorization: `Basic ${auth}`,
11
11
  },
12
12
  });
13
- await assertOkResponse(response, "device logs");
13
+ const validationError = validateLogResponse(response, "device logs");
14
+ if (validationError)
15
+ return validationError.message;
14
16
  const logText = await response.text();
15
- return filterDeviceFailures(logText);
17
+ const logs = filterDeviceFailures(logText);
18
+ return logs.length > 0
19
+ ? `Device Failures (${logs.length} found):\n${JSON.stringify(logs, null, 2)}`
20
+ : "No device failures found";
16
21
  }
17
22
  // APPIUM LOGS
18
23
  export async function retrieveAppiumLogs(sessionId, buildId) {
@@ -23,9 +28,14 @@ export async function retrieveAppiumLogs(sessionId, buildId) {
23
28
  Authorization: `Basic ${auth}`,
24
29
  },
25
30
  });
26
- await assertOkResponse(response, "Appium logs");
31
+ const validationError = validateLogResponse(response, "Appium logs");
32
+ if (validationError)
33
+ return validationError.message;
27
34
  const logText = await response.text();
28
- return filterAppiumFailures(logText);
35
+ const logs = filterAppiumFailures(logText);
36
+ return logs.length > 0
37
+ ? `Appium Failures (${logs.length} found):\n${JSON.stringify(logs, null, 2)}`
38
+ : "No Appium failures found";
29
39
  }
30
40
  // CRASH LOGS
31
41
  export async function retrieveCrashLogs(sessionId, buildId) {
@@ -36,9 +46,14 @@ export async function retrieveCrashLogs(sessionId, buildId) {
36
46
  Authorization: `Basic ${auth}`,
37
47
  },
38
48
  });
39
- await assertOkResponse(response, "crash logs");
49
+ const validationError = validateLogResponse(response, "crash logs");
50
+ if (validationError)
51
+ return validationError.message;
40
52
  const logText = await response.text();
41
- return filterCrashFailures(logText);
53
+ const logs = filterCrashFailures(logText);
54
+ return logs.length > 0
55
+ ? `Crash Failures (${logs.length} found):\n${JSON.stringify(logs, null, 2)}`
56
+ : "No crash failures found";
42
57
  }
43
58
  // FILTER HELPERS
44
59
  export function filterDeviceFailures(logText) {
@@ -1,5 +1,5 @@
1
1
  import config from "../../config.js";
2
- import { assertOkResponse, filterLinesByKeywords } from "../../lib/utils.js";
2
+ import { filterLinesByKeywords, validateLogResponse, } from "./utils.js";
3
3
  const auth = Buffer.from(`${config.browserstackUsername}:${config.browserstackAccessKey}`).toString("base64");
4
4
  // NETWORK LOGS
5
5
  export async function retrieveNetworkFailures(sessionId) {
@@ -11,30 +11,30 @@ export async function retrieveNetworkFailures(sessionId) {
11
11
  Authorization: `Basic ${auth}`,
12
12
  },
13
13
  });
14
- await assertOkResponse(response, "network logs");
14
+ const validationError = validateLogResponse(response, "network logs");
15
+ if (validationError)
16
+ return validationError.message;
15
17
  const networklogs = await response.json();
16
- // Filter for failure logs
17
- const failureEntries = networklogs.log.entries.filter((entry) => {
18
- return (entry.response.status === 0 ||
19
- entry.response.status >= 400 ||
20
- entry.response._error !== undefined);
21
- });
22
- // Return only the failure entries with some context
23
- return failureEntries.map((entry) => ({
24
- startedDateTime: entry.startedDateTime,
25
- request: {
26
- method: entry.request?.method,
27
- url: entry.request?.url,
28
- queryString: entry.request?.queryString,
29
- },
30
- response: {
31
- status: entry.response?.status,
32
- statusText: entry.response?.statusText,
33
- _error: entry.response?._error,
34
- },
35
- serverIPAddress: entry.serverIPAddress,
36
- time: entry.time,
37
- }));
18
+ const failureEntries = networklogs.log.entries.filter((entry) => entry.response.status === 0 ||
19
+ entry.response.status >= 400 ||
20
+ entry.response._error !== undefined);
21
+ return failureEntries.length > 0
22
+ ? `Network Failures (${failureEntries.length} found):\n${JSON.stringify(failureEntries.map((entry) => ({
23
+ startedDateTime: entry.startedDateTime,
24
+ request: {
25
+ method: entry.request?.method,
26
+ url: entry.request?.url,
27
+ queryString: entry.request?.queryString,
28
+ },
29
+ response: {
30
+ status: entry.response?.status,
31
+ statusText: entry.response?.statusText,
32
+ _error: entry.response?._error,
33
+ },
34
+ serverIPAddress: entry.serverIPAddress,
35
+ time: entry.time,
36
+ })), null, 2)}`
37
+ : "No network failures found";
38
38
  }
39
39
  // SESSION LOGS
40
40
  export async function retrieveSessionFailures(sessionId) {
@@ -45,9 +45,14 @@ export async function retrieveSessionFailures(sessionId) {
45
45
  Authorization: `Basic ${auth}`,
46
46
  },
47
47
  });
48
- await assertOkResponse(response, "session logs");
48
+ const validationError = validateLogResponse(response, "session logs");
49
+ if (validationError)
50
+ return validationError.message;
49
51
  const logText = await response.text();
50
- return filterSessionFailures(logText);
52
+ const logs = filterSessionFailures(logText);
53
+ return logs.length > 0
54
+ ? `Session Failures (${logs.length} found):\n${JSON.stringify(logs, null, 2)}`
55
+ : "No session failures found";
51
56
  }
52
57
  // CONSOLE LOGS
53
58
  export async function retrieveConsoleFailures(sessionId) {
@@ -58,9 +63,14 @@ export async function retrieveConsoleFailures(sessionId) {
58
63
  Authorization: `Basic ${auth}`,
59
64
  },
60
65
  });
61
- await assertOkResponse(response, "console logs");
66
+ const validationError = validateLogResponse(response, "console logs");
67
+ if (validationError)
68
+ return validationError.message;
62
69
  const logText = await response.text();
63
- return filterConsoleFailures(logText);
70
+ const logs = filterConsoleFailures(logText);
71
+ return logs.length > 0
72
+ ? `Console Failures (${logs.length} found):\n${JSON.stringify(logs, null, 2)}`
73
+ : "No console failures found";
64
74
  }
65
75
  // FILTER: session logs
66
76
  export function filterSessionFailures(logText) {
@@ -0,0 +1,20 @@
1
+ export function validateLogResponse(response, logType) {
2
+ if (!response.ok) {
3
+ if (response.status === 404) {
4
+ return { message: `No ${logType} available for this session` };
5
+ }
6
+ if (response.status === 401 || response.status === 403) {
7
+ return {
8
+ message: `Unable to access ${logType} - please check your credentials`,
9
+ };
10
+ }
11
+ return { message: `Unable to fetch ${logType} at this time` };
12
+ }
13
+ return null;
14
+ }
15
+ export function filterLinesByKeywords(logText, keywords) {
16
+ return logText
17
+ .split(/\r?\n/)
18
+ .map((line) => line.trim())
19
+ .filter((line) => keywords.some((keyword) => line.toLowerCase().includes(keyword)));
20
+ }
@@ -1,8 +1,8 @@
1
1
  import { z } from "zod";
2
2
  import logger from "../logger.js";
3
+ import { trackMCP } from "../lib/instrumentation.js";
3
4
  import { retrieveNetworkFailures, retrieveSessionFailures, retrieveConsoleFailures, } from "./failurelogs-utils/automate.js";
4
5
  import { retrieveDeviceLogs, retrieveAppiumLogs, retrieveCrashLogs, } from "./failurelogs-utils/app-automate.js";
5
- import { trackMCP } from "../lib/instrumentation.js";
6
6
  import { AppAutomateLogType, AutomateLogType, SessionType, } from "../lib/constants.js";
7
7
  // Main log fetcher function
8
8
  export async function getFailureLogs(args) {
@@ -47,68 +47,39 @@ export async function getFailureLogs(args) {
47
47
  ],
48
48
  };
49
49
  }
50
+ let response;
50
51
  // eslint-disable-next-line no-useless-catch
51
52
  try {
52
53
  for (const logType of validLogTypes) {
53
54
  switch (logType) {
54
55
  case AutomateLogType.NetworkLogs: {
55
- const logs = await retrieveNetworkFailures(args.sessionId);
56
- results.push({
57
- type: "text",
58
- text: logs.length > 0
59
- ? `Network Failures (${logs.length} found):\n${JSON.stringify(logs, null, 2)}`
60
- : "No network failures found",
61
- });
56
+ response = await retrieveNetworkFailures(args.sessionId);
57
+ results.push({ type: "text", text: response });
62
58
  break;
63
59
  }
64
60
  case AutomateLogType.SessionLogs: {
65
- const logs = await retrieveSessionFailures(args.sessionId);
66
- results.push({
67
- type: "text",
68
- text: logs.length > 0
69
- ? `Session Failures (${logs.length} found):\n${JSON.stringify(logs, null, 2)}`
70
- : "No session failures found",
71
- });
61
+ response = await retrieveSessionFailures(args.sessionId);
62
+ results.push({ type: "text", text: response });
72
63
  break;
73
64
  }
74
65
  case AutomateLogType.ConsoleLogs: {
75
- const logs = await retrieveConsoleFailures(args.sessionId);
76
- results.push({
77
- type: "text",
78
- text: logs.length > 0
79
- ? `Console Failures (${logs.length} found):\n${JSON.stringify(logs, null, 2)}`
80
- : "No console failures found",
81
- });
66
+ response = await retrieveConsoleFailures(args.sessionId);
67
+ results.push({ type: "text", text: response });
82
68
  break;
83
69
  }
84
70
  case AppAutomateLogType.DeviceLogs: {
85
- const logs = await retrieveDeviceLogs(args.sessionId, args.buildId);
86
- results.push({
87
- type: "text",
88
- text: logs.length > 0
89
- ? `Device Failures (${logs.length} found):\n${JSON.stringify(logs, null, 2)}`
90
- : "No device failures found",
91
- });
71
+ response = await retrieveDeviceLogs(args.sessionId, args.buildId);
72
+ results.push({ type: "text", text: response });
92
73
  break;
93
74
  }
94
75
  case AppAutomateLogType.AppiumLogs: {
95
- const logs = await retrieveAppiumLogs(args.sessionId, args.buildId);
96
- results.push({
97
- type: "text",
98
- text: logs.length > 0
99
- ? `Appium Failures (${logs.length} found):\n${JSON.stringify(logs, null, 2)}`
100
- : "No Appium failures found",
101
- });
76
+ response = await retrieveAppiumLogs(args.sessionId, args.buildId);
77
+ results.push({ type: "text", text: response });
102
78
  break;
103
79
  }
104
80
  case AppAutomateLogType.CrashLogs: {
105
- const logs = await retrieveCrashLogs(args.sessionId, args.buildId);
106
- results.push({
107
- type: "text",
108
- text: logs.length > 0
109
- ? `Crash Failures (${logs.length} found):\n${JSON.stringify(logs, null, 2)}`
110
- : "No crash failures found",
111
- });
81
+ response = await retrieveCrashLogs(args.sessionId, args.buildId);
82
+ results.push({ type: "text", text: response });
112
83
  break;
113
84
  }
114
85
  }
@@ -2,6 +2,7 @@ import axios from "axios";
2
2
  import config from "../../config.js";
3
3
  import { z } from "zod";
4
4
  import { formatAxiosError } from "../../lib/error.js"; // or correct path
5
+ import { projectIdentifierToId } from "../testmanagement-utils/TCG-utils/api.js";
5
6
  // Schema for combined project/folder creation
6
7
  export const CreateProjFoldSchema = z.object({
7
8
  project_name: z
@@ -78,11 +79,16 @@ export async function createProjectOrFolder(args) {
78
79
  }
79
80
  // Folder created successfully
80
81
  const folder = res.data.folder;
82
+ const projectId = await projectIdentifierToId(projId);
81
83
  return {
82
84
  content: [
83
85
  {
84
86
  type: "text",
85
- text: `Folder created: ID=${folder.id}, name=${folder.name} in project with identifier ${projId}`,
87
+ text: `Folder successfully created:
88
+ - ID: ${folder.id}
89
+ - Name: ${folder.name}
90
+ - Project Identifier: ${projId}
91
+ Access it here: https://test-management.browserstack.com/projects/${projectId}/folder/${folder.id}/`,
86
92
  },
87
93
  ],
88
94
  };
@@ -2,6 +2,7 @@ import axios from "axios";
2
2
  import config from "../../config.js";
3
3
  import { z } from "zod";
4
4
  import { formatAxiosError } from "../../lib/error.js"; // or correct
5
+ import { projectIdentifierToId } from "./TCG-utils/api.js";
5
6
  export const CreateTestCaseSchema = z.object({
6
7
  project_identifier: z
7
8
  .string()
@@ -90,13 +91,21 @@ export async function createTestCase(params) {
90
91
  };
91
92
  }
92
93
  const tc = data.test_case;
94
+ const projectId = await projectIdentifierToId(params.project_identifier);
93
95
  return {
94
96
  content: [
95
97
  {
96
98
  type: "text",
97
- text: `Successfully created test case ${tc.identifier}: ${tc.title}`,
99
+ text: `Test case successfully created:
100
+ - Identifier: ${tc.identifier}
101
+ - Title: ${tc.title}
102
+
103
+ You can view it here: https://test-management.browserstack.com/projects/${projectId}/folder/search?q=${tc.identifier}`,
104
+ },
105
+ {
106
+ type: "text",
107
+ text: JSON.stringify(tc, null, 2),
98
108
  },
99
- { type: "text", text: JSON.stringify(tc, null, 2) },
100
109
  ],
101
110
  };
102
111
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@browserstack/mcp-server",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
4
4
  "description": "BrowserStack's Official MCP Server",
5
5
  "main": "dist/index.js",
6
6
  "repository": {