@browserstack/mcp-server 1.4.0 → 1.4.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.
- package/README.md +2 -0
- package/dist/lib/upload-validator.d.ts +3 -2
- package/dist/lib/upload-validator.js +18 -15
- package/dist/tools/appautomate-utils/native-execution/constants.js +4 -2
- package/dist/tools/appautomate.js +3 -1
- package/dist/tools/applive.js +1 -1
- package/dist/tools/bstack-sdk.js +2 -2
- package/dist/tools/build-insights.js +1 -1
- package/dist/tools/percy-sdk.js +9 -9
- package/dist/tools/testmanagement-utils/TCG-utils/api.js +26 -7
- package/dist/tools/testmanagement-utils/TCG-utils/config.d.ts +2 -0
- package/dist/tools/testmanagement-utils/TCG-utils/config.js +5 -0
- package/dist/tools/testmanagement.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -292,6 +292,8 @@ Select the “Installed” tab. Click the “Configure MCP Servers” button at
|
|
|
292
292
|
}
|
|
293
293
|
```
|
|
294
294
|
|
|
295
|
+
> **File & app uploads:** tools that upload a local file/app (`uploadProductRequirementFile`, `takeAppScreenshot`, `runAppTestsOnBrowserStack`, `runAppLiveSession`) require the `MCP_UPLOAD_BASE_DIR` env var set to a directory containing those files; uploads are restricted to it.
|
|
296
|
+
|
|
295
297
|
### 💡 List of BrowserStack MCP Tools
|
|
296
298
|
|
|
297
299
|
As of now we support 46 tools.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export interface UploadValidationOptions {
|
|
2
2
|
allowedExtensions: readonly string[];
|
|
3
3
|
maxSizeBytes: number;
|
|
4
|
-
allowedBaseDir
|
|
4
|
+
allowedBaseDir: string | undefined;
|
|
5
5
|
}
|
|
6
6
|
/**
|
|
7
7
|
* Canonicalizes and validates a user-supplied upload path. Returns the resolved
|
|
@@ -13,7 +13,8 @@ export interface UploadValidationOptions {
|
|
|
13
13
|
* - File extension is in `allowedExtensions` (case-insensitive)
|
|
14
14
|
* - No path segment is a hidden dir/file (starts with `.`); blocks ~/.ssh,
|
|
15
15
|
* ~/.aws, .env, etc. even after symlink resolution
|
|
16
|
-
* -
|
|
16
|
+
* - `allowedBaseDir` is mandatory: uploads are refused unless it is configured
|
|
17
|
+
* (via MCP_UPLOAD_BASE_DIR), and the canonical path must live inside it
|
|
17
18
|
*/
|
|
18
19
|
export declare function validateUploadPath(filePath: string, options: UploadValidationOptions): string;
|
|
19
20
|
export declare const APP_BINARY_EXTENSIONS: readonly [".apk", ".aab", ".ipa", ".app", ".zip"];
|
|
@@ -10,7 +10,8 @@ import path from "path";
|
|
|
10
10
|
* - File extension is in `allowedExtensions` (case-insensitive)
|
|
11
11
|
* - No path segment is a hidden dir/file (starts with `.`); blocks ~/.ssh,
|
|
12
12
|
* ~/.aws, .env, etc. even after symlink resolution
|
|
13
|
-
* -
|
|
13
|
+
* - `allowedBaseDir` is mandatory: uploads are refused unless it is configured
|
|
14
|
+
* (via MCP_UPLOAD_BASE_DIR), and the canonical path must live inside it
|
|
14
15
|
*/
|
|
15
16
|
export function validateUploadPath(filePath, options) {
|
|
16
17
|
if (typeof filePath !== "string" || filePath.trim().length === 0) {
|
|
@@ -48,20 +49,22 @@ export function validateUploadPath(filePath, options) {
|
|
|
48
49
|
if (!allowed.includes(ext)) {
|
|
49
50
|
throw new Error(`Upload rejected: file extension "${ext || "(none)"}" is not in the allowed list (${allowed.join(", ")}).`);
|
|
50
51
|
}
|
|
51
|
-
if (options.allowedBaseDir) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
52
|
+
if (!options.allowedBaseDir) {
|
|
53
|
+
throw new Error("Upload rejected: MCP_UPLOAD_BASE_DIR is not set. Set it to the directory " +
|
|
54
|
+
"containing the files to upload, then restart the MCP server.");
|
|
55
|
+
}
|
|
56
|
+
let baseCanonical;
|
|
57
|
+
try {
|
|
58
|
+
baseCanonical = fs.realpathSync(path.resolve(options.allowedBaseDir));
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
throw new Error(`Upload rejected: configured MCP_UPLOAD_BASE_DIR does not exist (${options.allowedBaseDir}).`);
|
|
62
|
+
}
|
|
63
|
+
const baseWithSep = baseCanonical.endsWith(path.sep)
|
|
64
|
+
? baseCanonical
|
|
65
|
+
: baseCanonical + path.sep;
|
|
66
|
+
if (canonical !== baseCanonical && !canonical.startsWith(baseWithSep)) {
|
|
67
|
+
throw new Error(`Upload rejected: file must be located inside ${baseCanonical}.`);
|
|
65
68
|
}
|
|
66
69
|
return canonical;
|
|
67
70
|
}
|
|
@@ -20,7 +20,8 @@ export const RUN_APP_AUTOMATE_SCHEMA = {
|
|
|
20
20
|
" xcodebuild clean -scheme YOUR_SCHEME && \\\n" +
|
|
21
21
|
" xcodebuild archive -scheme YOUR_SCHEME -configuration Release -archivePath build/app.xcarchive && \\\n" +
|
|
22
22
|
" xcodebuild -exportArchive -archivePath build/app.xcarchive -exportPath build/ipa -exportOptionsPlist exportOptions.plist\n\n" +
|
|
23
|
-
"If in other directory, provide existing app path"
|
|
23
|
+
"If in other directory, provide existing app path.\n" +
|
|
24
|
+
"The resolved file must be located inside the directory set in MCP_UPLOAD_BASE_DIR."),
|
|
24
25
|
testSuitePath: z
|
|
25
26
|
.string()
|
|
26
27
|
.describe("Path to your test suite file:\n" +
|
|
@@ -30,7 +31,8 @@ export const RUN_APP_AUTOMATE_SCHEMA = {
|
|
|
30
31
|
" xcodebuild test-without-building -scheme YOUR_SCHEME -destination 'generic/platform=iOS' && \\\n" +
|
|
31
32
|
" cd ~/Library/Developer/Xcode/DerivedData/*/Build/Products/Debug-iphonesimulator/ && \\\n" +
|
|
32
33
|
" zip -r Tests.zip *.xctestrun *-Runner.app\n\n" +
|
|
33
|
-
"If in other directory, provide existing test file path"
|
|
34
|
+
"If in other directory, provide existing test file path.\n" +
|
|
35
|
+
"The resolved file must be located inside the directory set in MCP_UPLOAD_BASE_DIR."),
|
|
34
36
|
devices: z
|
|
35
37
|
.array(MobileDeviceSchema)
|
|
36
38
|
.max(3)
|
|
@@ -206,7 +206,7 @@ export default function addAppAutomationTools(server, config) {
|
|
|
206
206
|
.describe("Platform to run the app on. Either 'android' or 'ios'."),
|
|
207
207
|
appPath: z
|
|
208
208
|
.string()
|
|
209
|
-
.describe("The path to the .apk or .ipa file. Required for app installation."),
|
|
209
|
+
.describe("The path to the .apk or .ipa file. Required for app installation. Must be located inside the directory set in MCP_UPLOAD_BASE_DIR."),
|
|
210
210
|
}, {
|
|
211
211
|
title: "Take App Screenshot",
|
|
212
212
|
readOnlyHint: false,
|
|
@@ -266,9 +266,11 @@ export default function addAppAutomationTools(server, config) {
|
|
|
266
266
|
idempotentHint: true,
|
|
267
267
|
}, async (args) => {
|
|
268
268
|
try {
|
|
269
|
+
trackMCP("setupBrowserStackAppAutomateTests", server.server.getClientVersion(), undefined, config);
|
|
269
270
|
return await setupAppAutomateHandler(args, config);
|
|
270
271
|
}
|
|
271
272
|
catch (error) {
|
|
273
|
+
trackMCP("setupBrowserStackAppAutomateTests", server.server.getClientVersion(), error, config);
|
|
272
274
|
const error_message = error instanceof Error ? error.message : "Unknown error";
|
|
273
275
|
return {
|
|
274
276
|
content: [
|
package/dist/tools/applive.js
CHANGED
|
@@ -66,7 +66,7 @@ export default function addAppLiveTools(server, config) {
|
|
|
66
66
|
.describe("Which platform to run on, examples: 'android', 'ios'. Set this based on the app path provided."),
|
|
67
67
|
appPath: z
|
|
68
68
|
.string()
|
|
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."),
|
|
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. Must be located inside the directory set in MCP_UPLOAD_BASE_DIR."),
|
|
70
70
|
}, {
|
|
71
71
|
title: "Run App Live Session",
|
|
72
72
|
readOnlyHint: false,
|
package/dist/tools/bstack-sdk.js
CHANGED
|
@@ -13,11 +13,11 @@ export function registerRunBrowserStackTestsTool(server, config) {
|
|
|
13
13
|
idempotentHint: true,
|
|
14
14
|
}, async (args) => {
|
|
15
15
|
try {
|
|
16
|
-
trackMCP("
|
|
16
|
+
trackMCP("setupBrowserStackAutomateTests", server.server.getClientVersion(), undefined, config);
|
|
17
17
|
return await runTestsOnBrowserStackHandler(args, config);
|
|
18
18
|
}
|
|
19
19
|
catch (error) {
|
|
20
|
-
return handleMCPError("
|
|
20
|
+
return handleMCPError("setupBrowserStackAutomateTests", server, config, error);
|
|
21
21
|
}
|
|
22
22
|
});
|
|
23
23
|
return tools;
|
|
@@ -108,7 +108,7 @@ export default function addBuildInsightsTools(server, config) {
|
|
|
108
108
|
idempotentHint: true,
|
|
109
109
|
}, async (args) => {
|
|
110
110
|
try {
|
|
111
|
-
trackMCP("fetchBuildInsights", server.server.getClientVersion(), config);
|
|
111
|
+
trackMCP("fetchBuildInsights", server.server.getClientVersion(), undefined, config);
|
|
112
112
|
return await fetchBuildInsightsTool(args, config);
|
|
113
113
|
}
|
|
114
114
|
catch (error) {
|
package/dist/tools/percy-sdk.js
CHANGED
|
@@ -38,11 +38,11 @@ export function registerPercyTools(server, config) {
|
|
|
38
38
|
idempotentHint: true,
|
|
39
39
|
}, async (args) => {
|
|
40
40
|
try {
|
|
41
|
-
trackMCP("
|
|
41
|
+
trackMCP("percyVisualTestIntegrationAgent", server.server.getClientVersion(), undefined, config);
|
|
42
42
|
return simulatePercyChangeHandler(args, config);
|
|
43
43
|
}
|
|
44
44
|
catch (error) {
|
|
45
|
-
return handleMCPError("
|
|
45
|
+
return handleMCPError("percyVisualTestIntegrationAgent", server, config, error);
|
|
46
46
|
}
|
|
47
47
|
});
|
|
48
48
|
tools.setupPercyVisualTesting = server.tool("expandPercyVisualTesting", SETUP_PERCY_DESCRIPTION, SetUpPercyParamsShape, {
|
|
@@ -53,11 +53,11 @@ export function registerPercyTools(server, config) {
|
|
|
53
53
|
idempotentHint: true,
|
|
54
54
|
}, async (args) => {
|
|
55
55
|
try {
|
|
56
|
-
trackMCP("
|
|
56
|
+
trackMCP("expandPercyVisualTesting", server.server.getClientVersion(), undefined, config);
|
|
57
57
|
return setUpPercyHandler(args, config);
|
|
58
58
|
}
|
|
59
59
|
catch (error) {
|
|
60
|
-
return handleMCPError("
|
|
60
|
+
return handleMCPError("expandPercyVisualTesting", server, config, error);
|
|
61
61
|
}
|
|
62
62
|
});
|
|
63
63
|
tools.addPercySnapshotCommands = server.tool("addPercySnapshotCommands", PERCY_SNAPSHOT_COMMANDS_DESCRIPTION, UpdateTestFileWithInstructionsParams, {
|
|
@@ -68,7 +68,7 @@ export function registerPercyTools(server, config) {
|
|
|
68
68
|
idempotentHint: true,
|
|
69
69
|
}, async (args) => {
|
|
70
70
|
try {
|
|
71
|
-
trackMCP("addPercySnapshotCommands", server.server.getClientVersion(), config);
|
|
71
|
+
trackMCP("addPercySnapshotCommands", server.server.getClientVersion(), undefined, config);
|
|
72
72
|
return await updateTestsWithPercyCommands(args);
|
|
73
73
|
}
|
|
74
74
|
catch (error) {
|
|
@@ -83,7 +83,7 @@ export function registerPercyTools(server, config) {
|
|
|
83
83
|
idempotentHint: true,
|
|
84
84
|
}, async () => {
|
|
85
85
|
try {
|
|
86
|
-
trackMCP("listTestFiles", server.server.getClientVersion(), config);
|
|
86
|
+
trackMCP("listTestFiles", server.server.getClientVersion(), undefined, config);
|
|
87
87
|
return addListTestFiles();
|
|
88
88
|
}
|
|
89
89
|
catch (error) {
|
|
@@ -98,7 +98,7 @@ export function registerPercyTools(server, config) {
|
|
|
98
98
|
idempotentHint: true,
|
|
99
99
|
}, async (args) => {
|
|
100
100
|
try {
|
|
101
|
-
trackMCP("runPercyScan", server.server.getClientVersion(), config);
|
|
101
|
+
trackMCP("runPercyScan", server.server.getClientVersion(), undefined, config);
|
|
102
102
|
return runPercyScan(args);
|
|
103
103
|
}
|
|
104
104
|
catch (error) {
|
|
@@ -113,7 +113,7 @@ export function registerPercyTools(server, config) {
|
|
|
113
113
|
idempotentHint: true,
|
|
114
114
|
}, async (args) => {
|
|
115
115
|
try {
|
|
116
|
-
trackMCP("fetchPercyChanges", server.server.getClientVersion(), config);
|
|
116
|
+
trackMCP("fetchPercyChanges", server.server.getClientVersion(), undefined, config);
|
|
117
117
|
return await fetchPercyChanges(args, config);
|
|
118
118
|
}
|
|
119
119
|
catch (error) {
|
|
@@ -128,7 +128,7 @@ export function registerPercyTools(server, config) {
|
|
|
128
128
|
idempotentHint: true,
|
|
129
129
|
}, async (args) => {
|
|
130
130
|
try {
|
|
131
|
-
trackMCP("managePercyBuildApproval", server.server.getClientVersion(), config);
|
|
131
|
+
trackMCP("managePercyBuildApproval", server.server.getClientVersion(), undefined, config);
|
|
132
132
|
return await approveOrDeclinePercyBuild(args, config);
|
|
133
133
|
}
|
|
134
134
|
catch (error) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { apiClient } from "../../../lib/apiClient.js";
|
|
2
|
-
import { TCG_TRIGGER_URL, TCG_POLL_URL, FETCH_DETAILS_URL, FORM_FIELDS_URL, BULK_CREATE_URL, TC_DETAILS_MAX_BATCH, } from "./config.js";
|
|
2
|
+
import { TCG_TRIGGER_URL, TCG_POLL_URL, FETCH_DETAILS_URL, FORM_FIELDS_URL, BULK_CREATE_URL, TC_DETAILS_MAX_BATCH, TCG_POLL_INTERVAL_MS, TCG_POLL_MAX_WAIT_MS, } from "./config.js";
|
|
3
3
|
import { createTestCasePayload } from "./helpers.js";
|
|
4
4
|
import { getBrowserStackAuth } from "../../../lib/get-auth.js";
|
|
5
5
|
import { getTMBaseURL } from "../../../lib/tm-base-url.js";
|
|
@@ -118,9 +118,15 @@ export async function pollTestCaseDetails(traceRequestId, config) {
|
|
|
118
118
|
let done = false;
|
|
119
119
|
const tmBaseUrl = await getTMBaseURL(config);
|
|
120
120
|
const TCG_POLL_URL_VALUE = TCG_POLL_URL(tmBaseUrl);
|
|
121
|
+
const deadline = Date.now() + TCG_POLL_MAX_WAIT_MS;
|
|
121
122
|
while (!done) {
|
|
123
|
+
// Bail out if the backend never sends a "termination" message, so a stuck
|
|
124
|
+
// job cannot keep this loop (and its callers) alive forever.
|
|
125
|
+
if (Date.now() > deadline) {
|
|
126
|
+
throw new Error(`TCG test-case detail polling timed out after ${TCG_POLL_MAX_WAIT_MS}ms (trace ${traceRequestId})`);
|
|
127
|
+
}
|
|
122
128
|
// add a bit of jitter to avoid synchronized polling storms
|
|
123
|
-
await new Promise((r) => setTimeout(r,
|
|
129
|
+
await new Promise((r) => setTimeout(r, TCG_POLL_INTERVAL_MS + Math.random() * 5000));
|
|
124
130
|
const poll = await apiClient.post({
|
|
125
131
|
url: `${TCG_POLL_URL_VALUE}?x-bstack-traceRequestId=${encodeURIComponent(traceRequestId)}`,
|
|
126
132
|
headers: {
|
|
@@ -159,7 +165,20 @@ export async function pollScenariosTestDetails(args, traceId, context, documentI
|
|
|
159
165
|
const TCG_POLL_URL_VALUE = TCG_POLL_URL(tmBaseUrl);
|
|
160
166
|
// Promisify interval-style polling using a wrapper
|
|
161
167
|
await new Promise((resolve, reject) => {
|
|
162
|
-
const
|
|
168
|
+
const timers = {};
|
|
169
|
+
const stop = () => {
|
|
170
|
+
if (timers.interval)
|
|
171
|
+
clearInterval(timers.interval);
|
|
172
|
+
if (timers.timeout)
|
|
173
|
+
clearTimeout(timers.timeout);
|
|
174
|
+
};
|
|
175
|
+
// Hard wall-clock deadline: if the backend never sends a "termination"
|
|
176
|
+
// message, reject instead of letting the interval fire forever.
|
|
177
|
+
timers.timeout = setTimeout(() => {
|
|
178
|
+
stop();
|
|
179
|
+
reject(new Error(`TCG scenario polling timed out after ${TCG_POLL_MAX_WAIT_MS}ms (trace ${traceId})`));
|
|
180
|
+
}, TCG_POLL_MAX_WAIT_MS);
|
|
181
|
+
timers.interval = setInterval(async () => {
|
|
163
182
|
try {
|
|
164
183
|
const poll = await apiClient.post({
|
|
165
184
|
url: `${TCG_POLL_URL_VALUE}?x-bstack-traceRequestId=${encodeURIComponent(traceId)}`,
|
|
@@ -169,7 +188,7 @@ export async function pollScenariosTestDetails(args, traceId, context, documentI
|
|
|
169
188
|
body: {},
|
|
170
189
|
});
|
|
171
190
|
if (poll.status !== 200) {
|
|
172
|
-
|
|
191
|
+
stop();
|
|
173
192
|
reject(new Error(`Polling error: ${poll.statusText || poll.status}`));
|
|
174
193
|
return;
|
|
175
194
|
}
|
|
@@ -221,16 +240,16 @@ export async function pollScenariosTestDetails(args, traceId, context, documentI
|
|
|
221
240
|
}
|
|
222
241
|
}
|
|
223
242
|
if (msg.type === "termination") {
|
|
224
|
-
|
|
243
|
+
stop();
|
|
225
244
|
resolve();
|
|
226
245
|
}
|
|
227
246
|
}
|
|
228
247
|
}
|
|
229
248
|
catch (err) {
|
|
230
|
-
|
|
249
|
+
stop();
|
|
231
250
|
reject(err);
|
|
232
251
|
}
|
|
233
|
-
},
|
|
252
|
+
}, TCG_POLL_INTERVAL_MS);
|
|
234
253
|
});
|
|
235
254
|
// once all detail fetches are triggered, wait for them to complete
|
|
236
255
|
const detailsList = await Promise.all(detailPromises);
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export declare const TC_DETAILS_MAX_BATCH = 10;
|
|
2
|
+
export declare const TCG_POLL_INTERVAL_MS = 10000;
|
|
3
|
+
export declare const TCG_POLL_MAX_WAIT_MS: number;
|
|
2
4
|
export declare const TCG_TRIGGER_URL: (baseUrl: string) => string;
|
|
3
5
|
export declare const TCG_POLL_URL: (baseUrl: string) => string;
|
|
4
6
|
export declare const FETCH_DETAILS_URL: (baseUrl: string) => string;
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
export const TC_DETAILS_MAX_BATCH = 10;
|
|
2
|
+
// Wall-clock bounds for the TCG generation polling loops. Without a hard
|
|
3
|
+
// deadline, a job that never emits a "termination" message keeps the loop
|
|
4
|
+
// (and every caller awaiting it) alive forever.
|
|
5
|
+
export const TCG_POLL_INTERVAL_MS = 10_000;
|
|
6
|
+
export const TCG_POLL_MAX_WAIT_MS = 10 * 60 * 1000; // 10 minutes
|
|
2
7
|
export const TCG_TRIGGER_URL = (baseUrl) => `${baseUrl}/api/v1/integration/tcg/test-generation/suggest-test-cases`;
|
|
3
8
|
export const TCG_POLL_URL = (baseUrl) => `${baseUrl}/api/v1/integration/tcg/test-generation/test-cases-polling`;
|
|
4
9
|
export const FETCH_DETAILS_URL = (baseUrl) => `${baseUrl}/api/v1/integration/tcg/test-generation/fetch-test-case-details`;
|
|
@@ -264,11 +264,11 @@ export async function uploadProductRequirementFileTool(args, config, server) {
|
|
|
264
264
|
*/
|
|
265
265
|
export async function createTestCasesFromFileTool(args, context, config, server) {
|
|
266
266
|
try {
|
|
267
|
-
trackMCP("createTestCasesFromFile", server.server.getClientVersion(), undefined);
|
|
267
|
+
trackMCP("createTestCasesFromFile", server.server.getClientVersion(), undefined, config);
|
|
268
268
|
return await createTestCasesFromFile(args, context, config);
|
|
269
269
|
}
|
|
270
270
|
catch (err) {
|
|
271
|
-
trackMCP("createTestCasesFromFile", server.server.getClientVersion(), err);
|
|
271
|
+
trackMCP("createTestCasesFromFile", server.server.getClientVersion(), err, config);
|
|
272
272
|
return {
|
|
273
273
|
content: [
|
|
274
274
|
{
|