@browserstack/mcp-server 1.0.8 → 1.0.10

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 ADDED
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Config = void 0;
4
+ if (!process.env.BROWSERSTACK_ACCESS_KEY ||
5
+ !process.env.BROWSERSTACK_USERNAME) {
6
+ 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");
7
+ }
8
+ class Config {
9
+ browserstackUsername;
10
+ browserstackAccessKey;
11
+ constructor(browserstackUsername, browserstackAccessKey) {
12
+ this.browserstackUsername = browserstackUsername;
13
+ this.browserstackAccessKey = browserstackAccessKey;
14
+ }
15
+ }
16
+ exports.Config = Config;
17
+ const config = new Config(process.env.BROWSERSTACK_USERNAME, process.env.BROWSERSTACK_ACCESS_KEY);
18
+ exports.default = config;
package/dist/index.js ADDED
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __importDefault = (this && this.__importDefault) || function (mod) {
4
+ return (mod && mod.__esModule) ? mod : { "default": mod };
5
+ };
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
8
+ const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
9
+ const package_json_1 = __importDefault(require("../package.json"));
10
+ require("dotenv/config");
11
+ const logger_1 = __importDefault(require("./logger"));
12
+ const bstack_sdk_1 = __importDefault(require("./tools/bstack-sdk"));
13
+ const applive_1 = __importDefault(require("./tools/applive"));
14
+ const observability_1 = __importDefault(require("./tools/observability"));
15
+ const live_1 = __importDefault(require("./tools/live"));
16
+ const accessibility_1 = __importDefault(require("./tools/accessibility"));
17
+ const automate_1 = __importDefault(require("./tools/automate"));
18
+ const testmanagement_1 = __importDefault(require("./tools/testmanagement"));
19
+ function registerTools(server) {
20
+ (0, bstack_sdk_1.default)(server);
21
+ (0, applive_1.default)(server);
22
+ (0, live_1.default)(server);
23
+ (0, observability_1.default)(server);
24
+ (0, accessibility_1.default)(server);
25
+ (0, automate_1.default)(server);
26
+ (0, testmanagement_1.default)(server);
27
+ }
28
+ // Create an MCP server
29
+ const server = new mcp_js_1.McpServer({
30
+ name: "BrowserStack MCP Server",
31
+ version: package_json_1.default.version,
32
+ });
33
+ registerTools(server);
34
+ async function main() {
35
+ logger_1.default.info("Launching BrowserStack MCP server, version %s", package_json_1.default.version);
36
+ // Start receiving messages on stdin and sending messages on stdout
37
+ const transport = new stdio_js_1.StdioServerTransport();
38
+ await server.connect(transport);
39
+ logger_1.default.info("MCP server started successfully");
40
+ }
41
+ main().catch(console.error);
42
+ // Ensure logs are flushed before exit
43
+ process.on("exit", () => {
44
+ logger_1.default.flush();
45
+ });
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.getLatestO11YBuildInfo = getLatestO11YBuildInfo;
7
+ exports.retrieveNetworkFailures = retrieveNetworkFailures;
8
+ const config_1 = __importDefault(require("../config"));
9
+ async function getLatestO11YBuildInfo(buildName, projectName) {
10
+ const buildsUrl = `https://api-observability.browserstack.com/ext/v1/builds/latest?build_name=${encodeURIComponent(buildName)}&project_name=${encodeURIComponent(projectName)}`;
11
+ const buildsResponse = await fetch(buildsUrl, {
12
+ headers: {
13
+ Authorization: `Basic ${Buffer.from(`${config_1.default.browserstackUsername}:${config_1.default.browserstackAccessKey}`).toString("base64")}`,
14
+ },
15
+ });
16
+ if (!buildsResponse.ok) {
17
+ if (buildsResponse.statusText === "Unauthorized") {
18
+ throw new Error(`Failed to fetch builds: ${buildsResponse.statusText}. Please check if the BrowserStack credentials are correctly configured when installing the MCP server.`);
19
+ }
20
+ throw new Error(`Failed to fetch builds: ${buildsResponse.statusText}`);
21
+ }
22
+ return buildsResponse.json();
23
+ }
24
+ // Fetches network logs for a given session ID and returns only failure logs
25
+ async function retrieveNetworkFailures(sessionId) {
26
+ if (!sessionId) {
27
+ throw new Error("Session ID is required");
28
+ }
29
+ const url = `https://api.browserstack.com/automate/sessions/${sessionId}/networklogs`;
30
+ const auth = Buffer.from(`${config_1.default.browserstackUsername}:${config_1.default.browserstackAccessKey}`).toString("base64");
31
+ const response = await fetch(url, {
32
+ method: "GET",
33
+ headers: {
34
+ "Content-Type": "application/json",
35
+ Authorization: `Basic ${auth}`,
36
+ },
37
+ });
38
+ if (!response.ok) {
39
+ if (response.status === 404) {
40
+ throw new Error("Invalid session ID");
41
+ }
42
+ throw new Error(`Failed to fetch network logs: ${response.statusText}`);
43
+ }
44
+ const networklogs = await response.json();
45
+ // Filter for failure logs
46
+ const failureEntries = networklogs.log.entries.filter((entry) => {
47
+ return (entry.response.status === 0 ||
48
+ entry.response.status >= 400 ||
49
+ entry.response._error !== undefined);
50
+ });
51
+ // Return only the failure entries with some context
52
+ return {
53
+ failures: failureEntries.map((entry) => ({
54
+ startedDateTime: entry.startedDateTime,
55
+ request: {
56
+ method: entry.request?.method,
57
+ url: entry.request?.url,
58
+ queryString: entry.request?.queryString,
59
+ },
60
+ response: {
61
+ status: entry.response?.status,
62
+ statusText: entry.response?.statusText,
63
+ _error: entry.response?._error,
64
+ },
65
+ serverIPAddress: entry.serverIPAddress,
66
+ time: entry.time,
67
+ })),
68
+ totalFailures: failureEntries.length,
69
+ };
70
+ }
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatAxiosError = formatAxiosError;
4
+ const axios_1 = require("axios");
5
+ /**
6
+ * Formats an AxiosError into a CallToolResult with an appropriate message.
7
+ * @param err - The error object to format
8
+ * @param defaultText - The fallback error message
9
+ */
10
+ function formatAxiosError(err, defaultText) {
11
+ let text = defaultText;
12
+ if (err instanceof axios_1.AxiosError && err.response?.data) {
13
+ const message = err.response.data.message ||
14
+ err.response.data.error ||
15
+ err.message ||
16
+ defaultText;
17
+ text = message;
18
+ }
19
+ else if (err instanceof Error) {
20
+ text = err.message;
21
+ }
22
+ return {
23
+ content: [{ type: "text", text }],
24
+ isError: true,
25
+ };
26
+ }
@@ -0,0 +1,51 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.customFuzzySearch = customFuzzySearch;
4
+ // 1. Compute Levenshtein distance between two strings
5
+ function levenshtein(a, b) {
6
+ const dp = Array(a.length + 1)
7
+ .fill(0)
8
+ .map(() => Array(b.length + 1).fill(0));
9
+ for (let i = 0; i <= a.length; i++)
10
+ dp[i][0] = i;
11
+ for (let j = 0; j <= b.length; j++)
12
+ dp[0][j] = j;
13
+ for (let i = 1; i <= a.length; i++) {
14
+ for (let j = 1; j <= b.length; j++) {
15
+ dp[i][j] = Math.min(dp[i - 1][j] + 1, // deletion
16
+ dp[i][j - 1] + 1, // insertion
17
+ dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
18
+ }
19
+ }
20
+ return dp[a.length][b.length];
21
+ }
22
+ // 2. Score one item against the query (normalized score 0–1)
23
+ function scoreItem(item, keys, queryTokens) {
24
+ let best = Infinity;
25
+ for (const key of keys) {
26
+ const field = String(item[key] ?? "").toLowerCase();
27
+ const fieldTokens = field.split(/\s+/);
28
+ const tokenScores = queryTokens.map((qt) => {
29
+ const minNormalized = Math.min(...fieldTokens.map((ft) => {
30
+ const rawDist = levenshtein(ft, qt);
31
+ const maxLen = Math.max(ft.length, qt.length);
32
+ return maxLen === 0 ? 0 : rawDist / maxLen; // normalized 0–1
33
+ }));
34
+ return minNormalized;
35
+ });
36
+ const avg = tokenScores.reduce((a, b) => a + b, 0) / tokenScores.length;
37
+ best = Math.min(best, avg);
38
+ }
39
+ return best;
40
+ }
41
+ // 3. The search entrypoint
42
+ function customFuzzySearch(list, keys, query, limit = 5, maxDistance = 0.6) {
43
+ const q = query.toLowerCase().trim();
44
+ const queryTokens = q.split(/\s+/);
45
+ return list
46
+ .map((item) => ({ item, score: scoreItem(item, keys, queryTokens) }))
47
+ .filter((x) => x.score <= maxDistance)
48
+ .sort((a, b) => a.score - b.score)
49
+ .slice(0, limit)
50
+ .map((x) => x.item);
51
+ }
@@ -0,0 +1,109 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.killExistingBrowserStackLocalProcesses = killExistingBrowserStackLocalProcesses;
7
+ exports.ensureLocalBinarySetup = ensureLocalBinarySetup;
8
+ exports.isLocalURL = isLocalURL;
9
+ const logger_1 = __importDefault(require("../logger"));
10
+ const child_process_1 = require("child_process");
11
+ const browserstack_local_1 = require("browserstack-local");
12
+ const config_1 = __importDefault(require("../config"));
13
+ async function isBrowserStackLocalRunning() {
14
+ // Check if BrowserStackLocal binary is already running
15
+ try {
16
+ if (process.platform === "win32") {
17
+ const result = (0, child_process_1.execSync)('tasklist /FI "IMAGENAME eq BrowserStackLocal.exe"', {
18
+ encoding: "utf8",
19
+ });
20
+ if (result.includes("BrowserStackLocal.exe")) {
21
+ logger_1.default.info("BrowserStackLocal binary is already running");
22
+ return true;
23
+ }
24
+ }
25
+ else {
26
+ const result = (0, child_process_1.execSync)("pgrep -f BrowserStackLocal", {
27
+ encoding: "utf8",
28
+ stdio: "pipe",
29
+ }).toString();
30
+ if (result) {
31
+ logger_1.default.info("BrowserStackLocal binary is already running");
32
+ return true;
33
+ }
34
+ }
35
+ logger_1.default.info("BrowserStackLocal binary is not running");
36
+ return false;
37
+ }
38
+ catch (error) {
39
+ logger_1.default.info("Error checking BrowserStackLocal status, assuming not running ... " +
40
+ error);
41
+ return false;
42
+ }
43
+ }
44
+ async function killExistingBrowserStackLocalProcesses() {
45
+ const isRunning = await isBrowserStackLocalRunning();
46
+ if (!isRunning) {
47
+ return;
48
+ }
49
+ // Check and kill any existing BrowserStackLocal processes before starting new one
50
+ try {
51
+ if (process.platform === "win32") {
52
+ // Check if process exists on Windows
53
+ const checkResult = (0, child_process_1.execSync)('tasklist /FI "IMAGENAME eq BrowserStackLocal.exe"', { encoding: "utf8" });
54
+ if (checkResult.includes("BrowserStackLocal.exe")) {
55
+ (0, child_process_1.execSync)("taskkill /F /IM BrowserStackLocal.exe", { stdio: "ignore" });
56
+ logger_1.default.info("Successfully killed existing BrowserStackLocal processes");
57
+ }
58
+ }
59
+ else {
60
+ // Check if process exists on Unix-like systems
61
+ const checkResult = (0, child_process_1.execSync)("pgrep -f BrowserStackLocal", {
62
+ encoding: "utf8",
63
+ stdio: "pipe",
64
+ }).toString();
65
+ if (checkResult) {
66
+ (0, child_process_1.execSync)("pkill -f BrowserStackLocal", { stdio: "ignore" });
67
+ logger_1.default.info("Successfully killed existing BrowserStackLocal processes");
68
+ }
69
+ }
70
+ }
71
+ catch (error) {
72
+ logger_1.default.info(`Error checking/killing BrowserStackLocal processes: ${error}`);
73
+ // Continue execution as there may not be any processes running
74
+ }
75
+ }
76
+ async function ensureLocalBinarySetup() {
77
+ logger_1.default.info("Ensuring local binary setup as it is required for private URLs...");
78
+ const localBinary = new browserstack_local_1.Local();
79
+ await killExistingBrowserStackLocalProcesses();
80
+ return await new Promise((resolve, reject) => {
81
+ localBinary.start({
82
+ key: config_1.default.browserstackAccessKey,
83
+ username: config_1.default.browserstackUsername,
84
+ }, (error) => {
85
+ if (error) {
86
+ logger_1.default.error(`Unable to start BrowserStack Local... please check your credentials and try again. Error: ${error}`);
87
+ reject(new Error(`Unable to configure local tunnel binary, please check your credentials and try again. Error: ${error}`));
88
+ }
89
+ else {
90
+ logger_1.default.info("Successfully started BrowserStack Local");
91
+ resolve();
92
+ }
93
+ });
94
+ });
95
+ }
96
+ function isLocalURL(url) {
97
+ try {
98
+ const urlObj = new URL(url);
99
+ const hostname = urlObj.hostname.toLowerCase();
100
+ return (hostname === "localhost" ||
101
+ hostname === "127.0.0.1" ||
102
+ hostname.endsWith(".local") ||
103
+ hostname.endsWith(".localhost"));
104
+ }
105
+ catch (error) {
106
+ logger_1.default.error(`Error checking if URL is local: ${error}`);
107
+ return false;
108
+ }
109
+ }
@@ -0,0 +1,7 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.sanitizeUrlParam = sanitizeUrlParam;
4
+ function sanitizeUrlParam(param) {
5
+ // Remove any characters that could be used for command injection
6
+ return param.replace(/[;&|`$(){}[\]<>]/g, "");
7
+ }
package/dist/logger.js ADDED
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const pino_1 = __importDefault(require("pino"));
7
+ let logger;
8
+ if (process.env.NODE_ENV === "development") {
9
+ logger = (0, pino_1.default)({
10
+ level: "debug",
11
+ transport: {
12
+ targets: [
13
+ {
14
+ level: "debug",
15
+ target: "pino-pretty",
16
+ options: {
17
+ colorize: true,
18
+ levelFirst: true,
19
+ destination: process.platform === "win32"
20
+ ? "C:\\Windows\\Temp\\browserstack-mcp-server.log"
21
+ : "/tmp/browserstack-mcp-server.log",
22
+ },
23
+ },
24
+ ],
25
+ },
26
+ });
27
+ }
28
+ else {
29
+ // NULL logger
30
+ logger = (0, pino_1.default)({
31
+ level: "info",
32
+ transport: {
33
+ target: "pino/file",
34
+ options: {
35
+ destination: process.platform === "win32" ? "NUL" : "/dev/null",
36
+ },
37
+ },
38
+ });
39
+ }
40
+ exports.default = logger;
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.default = addAccessibilityTools;
4
+ const zod_1 = require("zod");
5
+ const accessibility_1 = require("./accessiblity-utils/accessibility");
6
+ async function runAccessibilityScan(name, pageURL) {
7
+ try {
8
+ const response = await (0, accessibility_1.startAccessibilityScan)(name, [pageURL]);
9
+ const scanId = response.data?.id;
10
+ const scanRunId = response.data?.scanRunId;
11
+ if (!scanId || !scanRunId) {
12
+ throw new Error("Unable to start a accessibility scan, please try again later or open an issue on GitHub if the problem persists");
13
+ }
14
+ return {
15
+ content: [
16
+ {
17
+ type: "text",
18
+ text: `Successfully queued accessibility scan, you will get a report via email within 5 minutes.`,
19
+ },
20
+ ],
21
+ };
22
+ }
23
+ catch (error) {
24
+ return {
25
+ content: [
26
+ {
27
+ type: "text",
28
+ text: `Failed to start accessibility scan: ${error instanceof Error ? error.message : "Unknown error"}. Please open an issue on GitHub if the problem persists`,
29
+ isError: true,
30
+ },
31
+ ],
32
+ isError: true,
33
+ };
34
+ }
35
+ }
36
+ function addAccessibilityTools(server) {
37
+ server.tool("startAccessibilityScan", "Use this tool to start an accessibility scan for a list of URLs on BrowserStack.", {
38
+ name: zod_1.z.string().describe("Name of the accessibility scan"),
39
+ pageURL: zod_1.z.string().describe("The URL to scan for accessibility issues"),
40
+ }, async (args) => {
41
+ return runAccessibilityScan(args.name, args.pageURL);
42
+ });
43
+ }
@@ -0,0 +1,82 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.startAccessibilityScan = startAccessibilityScan;
7
+ exports.pollScanStatus = pollScanStatus;
8
+ exports.waitUntilScanComplete = waitUntilScanComplete;
9
+ const axios_1 = __importDefault(require("axios"));
10
+ const config_js_1 = __importDefault(require("../../config.js"));
11
+ const axios_2 = require("axios");
12
+ async function startAccessibilityScan(name, urlList) {
13
+ try {
14
+ const response = await axios_1.default.post("https://api-accessibility.browserstack.com/api/website-scanner/v1/scans", {
15
+ name,
16
+ urlList,
17
+ recurring: false,
18
+ }, {
19
+ auth: {
20
+ username: config_js_1.default.browserstackUsername,
21
+ password: config_js_1.default.browserstackAccessKey,
22
+ },
23
+ });
24
+ if (!response.data.success) {
25
+ throw new Error(`Unable to create an accessibility scan: ${response.data.errors?.join(", ")}`);
26
+ }
27
+ return response.data;
28
+ }
29
+ catch (error) {
30
+ if (error instanceof axios_2.AxiosError) {
31
+ if (error.response?.data?.error) {
32
+ throw new Error(`Failed to start accessibility scan: ${error.response?.data?.error}`);
33
+ }
34
+ else {
35
+ throw new Error(`Failed to start accessibility scan: ${error.response?.data?.message || error.message}`);
36
+ }
37
+ }
38
+ throw error;
39
+ }
40
+ }
41
+ async function pollScanStatus(scanId, scanRunId) {
42
+ try {
43
+ const response = await axios_1.default.get(`https://api-accessibility.browserstack.com/api/website-scanner/v1/scans/${scanId}/scan_runs/${scanRunId}/status`, {
44
+ auth: {
45
+ username: config_js_1.default.browserstackUsername,
46
+ password: config_js_1.default.browserstackAccessKey,
47
+ },
48
+ });
49
+ if (!response.data.success) {
50
+ throw new Error(`Failed to get scan status: ${response.data.errors?.join(", ")}`);
51
+ }
52
+ return response.data.data?.status || "unknown";
53
+ }
54
+ catch (error) {
55
+ if (error instanceof axios_2.AxiosError) {
56
+ throw new Error(`Failed to get scan status: ${error.response?.data?.message || error.message}`);
57
+ }
58
+ throw error;
59
+ }
60
+ }
61
+ async function waitUntilScanComplete(scanId, scanRunId) {
62
+ return new Promise((resolve, reject) => {
63
+ const interval = setInterval(async () => {
64
+ try {
65
+ const status = await pollScanStatus(scanId, scanRunId);
66
+ if (status === "completed") {
67
+ clearInterval(interval);
68
+ resolve();
69
+ }
70
+ }
71
+ catch (error) {
72
+ clearInterval(interval);
73
+ reject(error);
74
+ }
75
+ }, 5000); // Poll every 5 seconds
76
+ // Set a timeout of 5 minutes
77
+ setTimeout(() => {
78
+ clearInterval(interval);
79
+ reject(new Error("Scan timed out after 5 minutes"));
80
+ }, 5 * 60 * 1000);
81
+ });
82
+ }
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.getAppLiveData = getAppLiveData;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const os_1 = __importDefault(require("os"));
9
+ const path_1 = __importDefault(require("path"));
10
+ const CACHE_DIR = path_1.default.join(os_1.default.homedir(), ".browserstack", "app_live_cache");
11
+ const CACHE_FILE = path_1.default.join(CACHE_DIR, "app_live.json");
12
+ const TTL_MS = 24 * 60 * 60 * 1000; // 1 day
13
+ /**
14
+ * Fetches and caches the App Live devices JSON with a 1-day TTL.
15
+ */
16
+ async function getAppLiveData() {
17
+ if (!fs_1.default.existsSync(CACHE_DIR)) {
18
+ fs_1.default.mkdirSync(CACHE_DIR, { recursive: true });
19
+ }
20
+ if (fs_1.default.existsSync(CACHE_FILE)) {
21
+ const stats = fs_1.default.statSync(CACHE_FILE);
22
+ if (Date.now() - stats.mtimeMs < TTL_MS) {
23
+ return JSON.parse(fs_1.default.readFileSync(CACHE_FILE, "utf8"));
24
+ }
25
+ }
26
+ const response = await fetch("https://www.browserstack.com/list-of-browsers-and-platforms/app_live.json");
27
+ if (!response.ok) {
28
+ throw new Error(`Failed to fetch app live list: ${response.statusText}`);
29
+ }
30
+ const data = await response.json();
31
+ fs_1.default.writeFileSync(CACHE_FILE, JSON.stringify(data), "utf8");
32
+ return data;
33
+ }
@@ -0,0 +1,11 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.fuzzySearchDevices = fuzzySearchDevices;
4
+ const fuzzy_1 = require("../../lib/fuzzy");
5
+ /**
6
+ * Fuzzy searches App Live device entries by name.
7
+ */
8
+ async function fuzzySearchDevices(devices, query, limit = 5) {
9
+ const top_match = (0, fuzzy_1.customFuzzySearch)(devices, ["device", "display_name"], query, limit);
10
+ return top_match;
11
+ }