@browserstack/mcp-server 1.2.18 → 1.2.20-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.
@@ -55,7 +55,6 @@ function getAxiosAgent() {
55
55
  host: proxyHost,
56
56
  port: Number(proxyPort),
57
57
  ca,
58
- rejectUnauthorized: false, // Set to true if you want strict SSL
59
58
  });
60
59
  }
61
60
  else {
@@ -67,7 +66,6 @@ function getAxiosAgent() {
67
66
  // CA only
68
67
  return new https.Agent({
69
68
  ca: fs.readFileSync(caCertPath),
70
- rejectUnauthorized: false, // Set to true for strict SSL
71
69
  });
72
70
  }
73
71
  // Default agent (no proxy, no CA)
@@ -5,7 +5,6 @@ import { uploadApp } from "./upload-app.js";
5
5
  import { getBrowserStackAuth } from "../../lib/get-auth.js";
6
6
  import { findDeviceByName } from "./device-search.js";
7
7
  import { pickVersion } from "./version-utils.js";
8
- import childProcess from "child_process";
9
8
  import envConfig from "../../config.js";
10
9
  /**
11
10
  * Start an App Live session: filter, select, upload, and open.
@@ -69,33 +68,44 @@ export async function startSession(args, options) {
69
68
  });
70
69
  const launchUrl = `https://app-live.browserstack.com/dashboard#${params.toString()}&device=${deviceParam}`;
71
70
  if (!envConfig.REMOTE_MCP) {
72
- openBrowser(launchUrl);
71
+ const openCommand = getOpenBrowserCommand(launchUrl);
72
+ if (openCommand) {
73
+ return [
74
+ `App Live session URL: ${launchUrl}${note}`,
75
+ ``,
76
+ `To open the session in the default browser, run:`,
77
+ ` ${openCommand}`,
78
+ ].join("\n");
79
+ }
73
80
  }
74
81
  return launchUrl + note;
75
82
  }
76
83
  /**
77
- * Opens the launch URL in the default browser.
78
- * @param launchUrl - The URL to open.
79
- * @throws Will throw an error if the browser fails to open.
84
+ * Returns the platform-appropriate shell command to open `launchUrl` in the
85
+ * default browser, or null if the URL is not a trusted BrowserStack URL.
86
+ *
87
+ * The command is returned to the MCP client so the host agent can prompt the
88
+ * user before executing it. The server itself never spawns a process, which
89
+ * eliminates the command-injection surface entirely.
80
90
  */
81
- function openBrowser(launchUrl) {
91
+ function getOpenBrowserCommand(launchUrl) {
92
+ let parsed;
82
93
  try {
83
- const command = process.platform === "darwin"
84
- ? ["open", launchUrl]
85
- : process.platform === "win32"
86
- ? ["cmd", "/c", "start", launchUrl]
87
- : ["xdg-open", launchUrl];
88
- // nosemgrep:javascript.lang.security.detect-child-process.detect-child-process
89
- const child = childProcess.spawn(command[0], command.slice(1), {
90
- stdio: "ignore",
91
- detached: true,
92
- });
93
- child.on("error", (error) => {
94
- logger.error(`Failed to open browser automatically: ${error}. Please open this URL manually: ${launchUrl}`);
95
- });
96
- child.unref();
94
+ parsed = new URL(launchUrl);
95
+ }
96
+ catch {
97
+ logger.error(`Refusing to surface malformed URL: ${launchUrl}`);
98
+ return null;
97
99
  }
98
- catch (error) {
99
- logger.error(`Failed to open browser automatically: ${error}. Please open this URL manually: ${launchUrl}`);
100
+ if (parsed.protocol !== "https:" ||
101
+ !/(^|\.)browserstack\.com$/i.test(parsed.hostname)) {
102
+ logger.error(`Refusing to surface untrusted URL: ${launchUrl}`);
103
+ return null;
100
104
  }
105
+ const quoted = `"${parsed.toString()}"`;
106
+ if (process.platform === "darwin")
107
+ return `open ${quoted}`;
108
+ if (process.platform === "win32")
109
+ return `cmd /c start "" ${quoted}`;
110
+ return `xdg-open ${quoted}`;
101
111
  }
@@ -1,5 +1,4 @@
1
1
  import logger from "../../logger.js";
2
- import childProcess from "child_process";
3
2
  import { filterDesktop } from "./desktop-filter.js";
4
3
  import { filterMobile } from "./mobile-filter.js";
5
4
  import { PlatformType, } from "./types.js";
@@ -39,10 +38,19 @@ export async function startBrowserSession(args, config) {
39
38
  const url = args.platformType === PlatformType.DESKTOP
40
39
  ? buildDesktopUrl(args, entry, isLocal)
41
40
  : buildMobileUrl(args, entry, isLocal);
41
+ const note = entry.notes ? `, ${entry.notes}` : "";
42
42
  if (!envConfig.REMOTE_MCP) {
43
- openBrowser(url);
43
+ const openCommand = getOpenBrowserCommand(url);
44
+ if (openCommand) {
45
+ return [
46
+ `Live session URL: ${url}${note}`,
47
+ ``,
48
+ `To open the session in the default browser, run:`,
49
+ ` ${openCommand}`,
50
+ ].join("\n");
51
+ }
44
52
  }
45
- return entry.notes ? `${url}, ${entry.notes}` : url;
53
+ return `${url}${note}`;
46
54
  }
47
55
  function buildDesktopUrl(args, e, isLocal) {
48
56
  const params = new URLSearchParams({
@@ -79,24 +87,33 @@ function buildMobileUrl(args, d, isLocal) {
79
87
  });
80
88
  return `https://live.browserstack.com/dashboard#${params.toString()}`;
81
89
  }
82
- // ——— Open a browser window ———
83
- function openBrowser(launchUrl) {
90
+ // ——— Build a browser-open command for the host agent ———
91
+ /**
92
+ * Returns the platform-appropriate shell command to open `launchUrl` in the
93
+ * default browser, or null if the URL is not a trusted BrowserStack URL.
94
+ *
95
+ * The command is returned to the MCP client so the host agent can prompt the
96
+ * user before executing it. The server itself never spawns a process, which
97
+ * eliminates the command-injection surface entirely.
98
+ */
99
+ function getOpenBrowserCommand(launchUrl) {
100
+ let parsed;
84
101
  try {
85
- const command = process.platform === "darwin"
86
- ? ["open", launchUrl]
87
- : process.platform === "win32"
88
- ? ["cmd", "/c", "start", `""`, `"${launchUrl}"`]
89
- : ["xdg-open", launchUrl];
90
- // nosemgrep:javascript.lang.security.detect-child-process.detect-child-process
91
- const child = childProcess.spawn(command[0], command.slice(1), {
92
- stdio: "ignore",
93
- detached: true,
94
- ...(process.platform === "win32" ? { shell: true } : {}),
95
- });
96
- child.on("error", (err) => logger.error(`Failed to open browser: ${err}. URL: ${launchUrl}`));
97
- child.unref();
102
+ parsed = new URL(launchUrl);
103
+ }
104
+ catch {
105
+ logger.error(`Refusing to surface malformed URL: ${launchUrl}`);
106
+ return null;
98
107
  }
99
- catch (err) {
100
- logger.error(`Failed to launch browser: ${err}. URL: ${launchUrl}`);
108
+ if (parsed.protocol !== "https:" ||
109
+ !/(^|\.)browserstack\.com$/i.test(parsed.hostname)) {
110
+ logger.error(`Refusing to surface untrusted URL: ${launchUrl}`);
111
+ return null;
101
112
  }
113
+ const quoted = `"${parsed.toString()}"`;
114
+ if (process.platform === "darwin")
115
+ return `open ${quoted}`;
116
+ if (process.platform === "win32")
117
+ return `cmd /c start "" ${quoted}`;
118
+ return `xdg-open ${quoted}`;
102
119
  }
@@ -7,6 +7,15 @@ export declare function fetchFormFields(projectId: string, config: BrowserStackC
7
7
  default_fields: any;
8
8
  custom_fields: any;
9
9
  }>;
10
+ /**
11
+ * Resolve a default-field input (priority/case_type) to the form's display or
12
+ * internal name, matching case-insensitively. Returns undefined if no match.
13
+ */
14
+ export declare function normalizeDefaultFieldValue(fieldValues: Array<{
15
+ internal_name?: string | null;
16
+ name?: string;
17
+ value: any;
18
+ }>, input: string, emit: "name" | "internal_name"): string | undefined;
10
19
  /**
11
20
  * Trigger AI-based test case generation for a document.
12
21
  */
@@ -16,6 +16,20 @@ export async function fetchFormFields(projectId, config) {
16
16
  });
17
17
  return res.data;
18
18
  }
19
+ /**
20
+ * Resolve a default-field input (priority/case_type) to the form's display or
21
+ * internal name, matching case-insensitively. Returns undefined if no match.
22
+ */
23
+ export function normalizeDefaultFieldValue(fieldValues, input, emit) {
24
+ const normalized = input.toLowerCase().trim();
25
+ const match = fieldValues.find((v) => (v.internal_name ?? "").toLowerCase() === normalized ||
26
+ (v.name ?? "").toLowerCase() === normalized);
27
+ if (!match)
28
+ return undefined;
29
+ if (emit === "name")
30
+ return match.name;
31
+ return match.internal_name ?? match.name;
32
+ }
19
33
  /**
20
34
  * Trigger AI-based test case generation for a document.
21
35
  */
@@ -22,6 +22,7 @@ export interface TestCaseCreateRequest {
22
22
  tags?: string[];
23
23
  custom_fields?: Record<string, string>;
24
24
  automation_status?: string;
25
+ priority?: string;
25
26
  }
26
27
  export interface TestCaseResponse {
27
28
  data: {
@@ -70,6 +71,7 @@ export declare const CreateTestCaseSchema: z.ZodObject<{
70
71
  tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
71
72
  custom_fields: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
72
73
  automation_status: z.ZodOptional<z.ZodString>;
74
+ priority: z.ZodOptional<z.ZodString>;
73
75
  }, z.core.$strip>;
74
76
  export declare function sanitizeArgs(args: any): any;
75
77
  export declare function createTestCase(params: TestCaseCreateRequest, config: BrowserStackConfig): Promise<CallToolResult>;
@@ -1,8 +1,9 @@
1
1
  import { apiClient } from "../../lib/apiClient.js";
2
2
  import { z } from "zod";
3
3
  import { formatAxiosError } from "../../lib/error.js";
4
- import { projectIdentifierToId } from "./TCG-utils/api.js";
4
+ import { fetchFormFields, normalizeDefaultFieldValue, projectIdentifierToId, } from "./TCG-utils/api.js";
5
5
  import { getTMBaseURL } from "../../lib/tm-base-url.js";
6
+ import logger from "../../logger.js";
6
7
  export const CreateTestCaseSchema = z.object({
7
8
  project_identifier: z
8
9
  .string()
@@ -54,6 +55,10 @@ export const CreateTestCaseSchema = z.object({
54
55
  .string()
55
56
  .optional()
56
57
  .describe("Automation status of the test case. Common values include 'not_automated', 'automated', 'automation_not_required'."),
58
+ priority: z
59
+ .string()
60
+ .optional()
61
+ .describe("Priority of the test case. Accepts either display name (e.g. 'Critical', 'High', 'Medium', 'Low') or internal name (e.g. 'medium'). If omitted, the project default (usually 'Medium') is applied. Valid values are per-project and discoverable via the form-fields endpoint."),
57
62
  });
58
63
  export function sanitizeArgs(args) {
59
64
  const cleaned = { ...args };
@@ -74,8 +79,27 @@ export function sanitizeArgs(args) {
74
79
  return cleaned;
75
80
  }
76
81
  import { getBrowserStackAuth } from "../../lib/get-auth.js";
82
+ /**
83
+ * Normalize priority to the display name the create endpoint accepts (it
84
+ * rejects lowercase). On lookup failure, pass the raw value through.
85
+ */
86
+ async function normalizePriority(projectIdentifier, priority, config) {
87
+ try {
88
+ const numericProjectId = await projectIdentifierToId(projectIdentifier, config);
89
+ const { default_fields } = await fetchFormFields(numericProjectId, config);
90
+ return (normalizeDefaultFieldValue(default_fields?.priority?.values ?? [], priority, "name") ?? priority);
91
+ }
92
+ catch (err) {
93
+ logger.warn("Failed to normalize priority value; passing through as given: %s", err instanceof Error ? err.message : String(err));
94
+ return priority;
95
+ }
96
+ }
77
97
  export async function createTestCase(params, config) {
78
- const body = { test_case: params };
98
+ const testCaseParams = { ...params };
99
+ if (testCaseParams.priority !== undefined) {
100
+ testCaseParams.priority = await normalizePriority(params.project_identifier, testCaseParams.priority, config);
101
+ }
102
+ const body = { test_case: testCaseParams };
79
103
  const authString = getBrowserStackAuth(config);
80
104
  const [username, password] = authString.split(":");
81
105
  try {
@@ -1,7 +1,7 @@
1
1
  import { apiClient } from "../../lib/apiClient.js";
2
2
  import { z } from "zod";
3
3
  import { formatAxiosError } from "../../lib/error.js";
4
- import { fetchFormFields, projectIdentifierToId } from "./TCG-utils/api.js";
4
+ import { fetchFormFields, normalizeDefaultFieldValue, projectIdentifierToId, } from "./TCG-utils/api.js";
5
5
  import { getTMBaseURL } from "../../lib/tm-base-url.js";
6
6
  import { getBrowserStackAuth } from "../../lib/get-auth.js";
7
7
  import logger from "../../logger.js";
@@ -62,26 +62,6 @@ export const UpdateTestCaseSchema = z.object({
62
62
  .optional()
63
63
  .describe("Map of custom field name/id to value. Valid field names and value types are per-project; discover them via the project's form fields."),
64
64
  });
65
- /**
66
- * Build a normalizer for a default field's accepted value.
67
- * The TM PATCH endpoint accepts different casings for different default
68
- * fields (Title-Case display name for priority/case_type, snake_case
69
- * internal_name for automation_status). We accept either from the caller
70
- * and emit the form the API actually wants.
71
- *
72
- * Returns undefined when no matching option is found — callers should
73
- * pass the raw value through so the backend can surface its own error.
74
- */
75
- function normalizeDefaultFieldValue(fieldValues, input, emit) {
76
- const normalized = input.toLowerCase().trim();
77
- const match = fieldValues.find((v) => (v.internal_name ?? "").toLowerCase() === normalized ||
78
- (v.name ?? "").toLowerCase() === normalized);
79
- if (!match)
80
- return undefined;
81
- if (emit === "name")
82
- return match.name;
83
- return match.internal_name ?? match.name;
84
- }
85
65
  /**
86
66
  * Normalise default-field inputs (priority/case_type/automation_status) to
87
67
  * what the TM PATCH endpoint accepts. Fetches the project's form-fields
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@browserstack/mcp-server",
3
- "version": "1.2.18",
3
+ "version": "1.2.20-beta.1",
4
4
  "description": "BrowserStack's Official MCP Server",
5
5
  "mcpName": "io.github.browserstack/mcp-server",
6
6
  "main": "dist/index.js",