@browserstack/mcp-server 2.2.2 → 2.2.3
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/lib/error.d.ts +10 -0
- package/dist/lib/error.js +64 -12
- package/dist/lib/utils.js +13 -2
- package/dist/tools/automate-utils/fetch-screenshots.d.ts +6 -2
- package/dist/tools/automate-utils/fetch-screenshots.js +88 -19
- package/dist/tools/automate.js +2 -2
- package/dist/tools/testmanagement-utils/update-testcase.js +5 -3
- package/package.json +1 -1
package/dist/lib/error.d.ts
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Returns the most specific human-readable text for an error raised by an
|
|
4
|
+
* API call, capped at 500 characters. For an AxiosError with a response body
|
|
5
|
+
* it prefers the body's `message`, `error` or `errors` field, serialising
|
|
6
|
+
* objects, and uses the whole body when none of those fields exist. An empty,
|
|
7
|
+
* whitespace-only or HTML-document body falls back to the error's own message.
|
|
8
|
+
* @param err - The error object to describe
|
|
9
|
+
* @param defaultText - The fallback error message
|
|
10
|
+
*/
|
|
11
|
+
export declare function errorText(err: unknown, defaultText: string): string;
|
|
2
12
|
/**
|
|
3
13
|
* Formats an AxiosError into a CallToolResult with an appropriate message.
|
|
4
14
|
* @param err - The error object to format
|
package/dist/lib/error.js
CHANGED
|
@@ -1,23 +1,75 @@
|
|
|
1
1
|
import { AxiosError } from "axios";
|
|
2
|
+
const MAX_ERROR_TEXT = 500;
|
|
3
|
+
const BODY_FIELDS = ["message", "error", "errors"];
|
|
4
|
+
function clip(text) {
|
|
5
|
+
return text.length > MAX_ERROR_TEXT ? text.slice(0, MAX_ERROR_TEXT) : text;
|
|
6
|
+
}
|
|
7
|
+
const HTML_DOCUMENT = /^\s*<(!doctype|html)\b/i;
|
|
8
|
+
function stringify(value) {
|
|
9
|
+
if (typeof value === "string") {
|
|
10
|
+
return HTML_DOCUMENT.test(value) ? "" : value.trim();
|
|
11
|
+
}
|
|
12
|
+
if (value === undefined || value === null)
|
|
13
|
+
return "";
|
|
14
|
+
try {
|
|
15
|
+
const json = JSON.stringify(value);
|
|
16
|
+
return json === "{}" || json === "[]" ? "" : (json ?? "");
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return "";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Text taken from an API error body: the first non-empty `message`, `error`
|
|
24
|
+
* or `errors` field when any of them is present, otherwise the whole body.
|
|
25
|
+
* Returns undefined when the body carries no usable text.
|
|
26
|
+
*/
|
|
27
|
+
function bodyText(data) {
|
|
28
|
+
if (typeof data !== "object" || data === null) {
|
|
29
|
+
const text = stringify(data);
|
|
30
|
+
return text ? clip(text) : undefined;
|
|
31
|
+
}
|
|
32
|
+
const record = data;
|
|
33
|
+
const named = BODY_FIELDS.filter((key) => key in record);
|
|
34
|
+
if (named.length > 0) {
|
|
35
|
+
for (const key of named) {
|
|
36
|
+
const text = stringify(record[key]);
|
|
37
|
+
if (text)
|
|
38
|
+
return clip(text);
|
|
39
|
+
}
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
const text = stringify(record);
|
|
43
|
+
return text ? clip(text) : undefined;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Returns the most specific human-readable text for an error raised by an
|
|
47
|
+
* API call, capped at 500 characters. For an AxiosError with a response body
|
|
48
|
+
* it prefers the body's `message`, `error` or `errors` field, serialising
|
|
49
|
+
* objects, and uses the whole body when none of those fields exist. An empty,
|
|
50
|
+
* whitespace-only or HTML-document body falls back to the error's own message.
|
|
51
|
+
* @param err - The error object to describe
|
|
52
|
+
* @param defaultText - The fallback error message
|
|
53
|
+
*/
|
|
54
|
+
export function errorText(err, defaultText) {
|
|
55
|
+
if (err instanceof AxiosError) {
|
|
56
|
+
const text = bodyText(err.response?.data);
|
|
57
|
+
if (text)
|
|
58
|
+
return text;
|
|
59
|
+
return clip(err.message || defaultText);
|
|
60
|
+
}
|
|
61
|
+
if (err instanceof Error)
|
|
62
|
+
return clip(err.message || defaultText);
|
|
63
|
+
return defaultText;
|
|
64
|
+
}
|
|
2
65
|
/**
|
|
3
66
|
* Formats an AxiosError into a CallToolResult with an appropriate message.
|
|
4
67
|
* @param err - The error object to format
|
|
5
68
|
* @param defaultText - The fallback error message
|
|
6
69
|
*/
|
|
7
70
|
export function formatAxiosError(err, defaultText) {
|
|
8
|
-
let text = defaultText;
|
|
9
|
-
if (err instanceof AxiosError && err.response?.data) {
|
|
10
|
-
const message = err.response.data.message ||
|
|
11
|
-
err.response.data.error ||
|
|
12
|
-
err.message ||
|
|
13
|
-
defaultText;
|
|
14
|
-
text = message;
|
|
15
|
-
}
|
|
16
|
-
else if (err instanceof Error) {
|
|
17
|
-
text = err.message;
|
|
18
|
-
}
|
|
19
71
|
return {
|
|
20
|
-
content: [{ type: "text", text }],
|
|
72
|
+
content: [{ type: "text", text: errorText(err, defaultText) }],
|
|
21
73
|
isError: true,
|
|
22
74
|
};
|
|
23
75
|
}
|
package/dist/lib/utils.js
CHANGED
|
@@ -7,6 +7,9 @@ export function sanitizeUrlParam(param) {
|
|
|
7
7
|
return param.replace(/[;&|`$(){}[\]<>]/g, "");
|
|
8
8
|
}
|
|
9
9
|
const ONE_MB = 1048576;
|
|
10
|
+
// sharp's default (~268M px) lets one image decode to ~1 GB of RGBA; 25M px caps it near 100 MB.
|
|
11
|
+
const MAX_INPUT_PIXELS = 25_000_000;
|
|
12
|
+
const MAX_IMAGE_DIMENSION = 1920;
|
|
10
13
|
let sharpConfigured = false;
|
|
11
14
|
//Compresses a base64 image intelligently to keep it under 1 MB if needed.
|
|
12
15
|
export async function maybeCompressBase64(base64) {
|
|
@@ -25,11 +28,19 @@ export async function maybeCompressBase64(base64) {
|
|
|
25
28
|
sharp.concurrency(1);
|
|
26
29
|
sharpConfigured = true;
|
|
27
30
|
}
|
|
28
|
-
const
|
|
31
|
+
const image = globalConfig.REMOTE_MCP
|
|
32
|
+
? sharp(buffer, { limitInputPixels: MAX_INPUT_PIXELS }).resize({
|
|
33
|
+
width: MAX_IMAGE_DIMENSION,
|
|
34
|
+
height: MAX_IMAGE_DIMENSION,
|
|
35
|
+
fit: "inside",
|
|
36
|
+
withoutEnlargement: true,
|
|
37
|
+
})
|
|
38
|
+
: sharp(buffer);
|
|
39
|
+
const compressedBuffer = await image.png({ quality }).toBuffer();
|
|
29
40
|
return compressedBuffer.toString("base64");
|
|
30
41
|
}
|
|
31
42
|
catch (err) {
|
|
32
|
-
logger.warn("Image compression
|
|
43
|
+
logger.warn("Image compression failed; returning the uncompressed image. %s", err instanceof Error ? err.message : String(err));
|
|
33
44
|
return base64;
|
|
34
45
|
}
|
|
35
46
|
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { SessionType } from "../../lib/constants.js";
|
|
2
2
|
import { BrowserStackConfig } from "../../lib/types.js";
|
|
3
|
-
export declare
|
|
3
|
+
export declare const MAX_SCREENSHOT_BYTES: number | undefined;
|
|
4
|
+
export declare function parseScreenshotUrls(text: string): string[];
|
|
5
|
+
export type Screenshot = {
|
|
4
6
|
url: string;
|
|
5
7
|
base64: string;
|
|
6
|
-
|
|
8
|
+
mimeType: string;
|
|
9
|
+
};
|
|
10
|
+
export declare function fetchAutomationScreenshots(sessionId: string, sessionType: SessionType | undefined, config: BrowserStackConfig): Promise<Screenshot[]>;
|
|
@@ -3,6 +3,10 @@ import { SessionType } from "../../lib/constants.js";
|
|
|
3
3
|
import { getBrowserStackAuth } from "../../lib/get-auth.js";
|
|
4
4
|
import { apiClient } from "../../lib/apiClient.js";
|
|
5
5
|
import globalConfig from "../../config.js";
|
|
6
|
+
import { MAX_LOG_BYTES } from "../failurelogs-utils/utils.js";
|
|
7
|
+
export const MAX_SCREENSHOT_BYTES = globalConfig.REMOTE_MCP
|
|
8
|
+
? 10 * 1024 * 1024
|
|
9
|
+
: undefined;
|
|
6
10
|
async function extractScreenshotUrls(sessionId, sessionType, config) {
|
|
7
11
|
const authString = getBrowserStackAuth(config);
|
|
8
12
|
const auth = Buffer.from(authString).toString("base64");
|
|
@@ -14,47 +18,112 @@ async function extractScreenshotUrls(sessionId, sessionType, config) {
|
|
|
14
18
|
"Content-Type": "application/json",
|
|
15
19
|
Authorization: `Basic ${auth}`,
|
|
16
20
|
},
|
|
21
|
+
maxContentLength: MAX_LOG_BYTES,
|
|
17
22
|
raise_error: false,
|
|
18
23
|
});
|
|
24
|
+
if (response.status === 413) {
|
|
25
|
+
throw new Error("The logs for this session are too large (over 5 MB) to find screenshots here. Open the session in the BrowserStack dashboard to view them.");
|
|
26
|
+
}
|
|
19
27
|
await assertOkResponse(response, "Session");
|
|
20
28
|
const text = typeof response.data === "string"
|
|
21
29
|
? response.data
|
|
22
30
|
: JSON.stringify(response.data);
|
|
31
|
+
return parseScreenshotUrls(text);
|
|
32
|
+
}
|
|
33
|
+
const LOG_PREFIX = String.raw `^\d{4}-\d{2}-\d{2} \d{1,2}:\d{1,2}:\d{1,2}:\d{1,3} `;
|
|
34
|
+
const LINE_TYPE_PATTERN = new RegExp(`${LOG_PREFIX}([A-Z_]+) `);
|
|
35
|
+
const SCREENSHOT_REQUEST_PATTERN = new RegExp(String.raw `${LOG_PREFIX}REQUEST \[[^\]]+\] GET /session/[^/\s]+/screenshot(\s|$)`);
|
|
36
|
+
const RESPONSE_VALUE_PATTERN = new RegExp(String.raw `${LOG_PREFIX}RESPONSE \{"value"\s*:\s*"(https://[^"]+)"`);
|
|
37
|
+
const DEBUG_SCREENSHOT_PATTERN = new RegExp(String.raw `${LOG_PREFIX}DEBUG (https://\S+)$`);
|
|
38
|
+
const TRUSTED_HOST_PATTERN = /(^|\.)browserstack\.com$|\.s3([.-][a-z0-9-]+)*\.amazonaws\.com$|\.backblazeb2\.com$/;
|
|
39
|
+
function isTrustedScreenshotUrl(value) {
|
|
40
|
+
try {
|
|
41
|
+
const url = new URL(value);
|
|
42
|
+
return url.protocol === "https:" && TRUSTED_HOST_PATTERN.test(url.hostname);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export function parseScreenshotUrls(text) {
|
|
23
49
|
const urls = [];
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
50
|
+
const lines = text.split("\n").map((line) => line.trimEnd());
|
|
51
|
+
for (let i = 0; i < lines.length; i++) {
|
|
52
|
+
const line = lines[i];
|
|
53
|
+
const debugMatch = line.match(DEBUG_SCREENSHOT_PATTERN);
|
|
54
|
+
if (debugMatch) {
|
|
55
|
+
urls.push(debugMatch[1]);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (!SCREENSHOT_REQUEST_PATTERN.test(line)) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
62
|
+
const lineType = lines[j].match(LINE_TYPE_PATTERN)?.[1];
|
|
63
|
+
if (lineType === "REQUEST") {
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
if (lineType === "RESPONSE") {
|
|
67
|
+
const valueMatch = lines[j].match(RESPONSE_VALUE_PATTERN);
|
|
68
|
+
if (valueMatch) {
|
|
69
|
+
urls.push(valueMatch[1]);
|
|
70
|
+
}
|
|
71
|
+
break;
|
|
35
72
|
}
|
|
36
73
|
}
|
|
37
74
|
}
|
|
38
|
-
return urls;
|
|
75
|
+
return [...new Set(urls)].filter(isTrustedScreenshotUrl);
|
|
76
|
+
}
|
|
77
|
+
function detectImageMimeType(buffer) {
|
|
78
|
+
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
|
79
|
+
return "image/jpeg";
|
|
80
|
+
}
|
|
81
|
+
return "image/png";
|
|
39
82
|
}
|
|
83
|
+
// Returns null for a screenshot over MAX_SCREENSHOT_BYTES so the rest still load.
|
|
40
84
|
async function fetchAndCompress(url) {
|
|
41
|
-
const response = await apiClient.get({
|
|
85
|
+
const response = await apiClient.get({
|
|
86
|
+
url,
|
|
87
|
+
responseType: "arraybuffer",
|
|
88
|
+
maxContentLength: MAX_SCREENSHOT_BYTES,
|
|
89
|
+
raise_error: false,
|
|
90
|
+
});
|
|
91
|
+
if (response.status === 413) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
throw new Error(`Failed to download screenshot: ${response.status} ${response.statusText}`);
|
|
96
|
+
}
|
|
42
97
|
// Axios returns response.data as a Buffer for binary data
|
|
43
|
-
const
|
|
44
|
-
|
|
98
|
+
const buffer = Buffer.from(response.data);
|
|
99
|
+
const base64 = await maybeCompressBase64(buffer.toString("base64"));
|
|
100
|
+
// Detect AFTER compression: sharp re-encodes anything over 1 MB to PNG, so
|
|
101
|
+
// reading the original bytes would label a large JPEG with the wrong type.
|
|
102
|
+
return {
|
|
103
|
+
url,
|
|
104
|
+
base64,
|
|
105
|
+
mimeType: detectImageMimeType(Buffer.from(base64, "base64")),
|
|
106
|
+
};
|
|
45
107
|
}
|
|
46
108
|
//Converts screenshot URLs to base64 encoded images
|
|
47
109
|
async function convertUrlsToBase64(urls) {
|
|
110
|
+
let results;
|
|
48
111
|
// Hosted pods are memory-constrained: process one image at a time. Local runs
|
|
49
112
|
// keep the concurrent path.
|
|
50
113
|
if (globalConfig.REMOTE_MCP) {
|
|
51
|
-
|
|
114
|
+
results = [];
|
|
52
115
|
for (const url of urls) {
|
|
53
|
-
|
|
116
|
+
results.push(await fetchAndCompress(url));
|
|
54
117
|
}
|
|
55
|
-
return screenshots;
|
|
56
118
|
}
|
|
57
|
-
|
|
119
|
+
else {
|
|
120
|
+
results = await Promise.all(urls.map(fetchAndCompress));
|
|
121
|
+
}
|
|
122
|
+
const screenshots = results.filter((s) => s !== null);
|
|
123
|
+
if (urls.length > 0 && screenshots.length === 0) {
|
|
124
|
+
throw new Error("The screenshots in this session are too large (over 10 MB each) to fetch here. Open the session in the BrowserStack dashboard to view them.");
|
|
125
|
+
}
|
|
126
|
+
return screenshots;
|
|
58
127
|
}
|
|
59
128
|
//Fetches and converts screenshot URLs to base64 encoded images
|
|
60
129
|
export async function fetchAutomationScreenshots(sessionId, sessionType = SessionType.Automate, config) {
|
package/dist/tools/automate.js
CHANGED
|
@@ -14,7 +14,7 @@ export async function fetchAutomationScreenshotsTool(args, config) {
|
|
|
14
14
|
content: [
|
|
15
15
|
{
|
|
16
16
|
type: "text",
|
|
17
|
-
text: "No screenshots found in the session
|
|
17
|
+
text: "No screenshots found in the session logs. Run with browserstack.debug: true to capture visual-log screenshots.",
|
|
18
18
|
},
|
|
19
19
|
],
|
|
20
20
|
isError: true,
|
|
@@ -23,7 +23,7 @@ export async function fetchAutomationScreenshotsTool(args, config) {
|
|
|
23
23
|
const results = screenshots.map((screenshot, index) => ({
|
|
24
24
|
type: "image",
|
|
25
25
|
data: screenshot.base64,
|
|
26
|
-
mimeType:
|
|
26
|
+
mimeType: screenshot.mimeType,
|
|
27
27
|
_meta: { url: screenshot.url, index: index + 1 },
|
|
28
28
|
}));
|
|
29
29
|
return {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { apiClient } from "../../lib/apiClient.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import {
|
|
3
|
+
import { errorText } from "../../lib/error.js";
|
|
4
4
|
import { fetchFormFields, normalizeDefaultFields as normalizeDefaultFieldsFromForm, 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";
|
|
@@ -218,12 +218,14 @@ Error details: ${JSON.stringify(err.response?.data || err.message)}`,
|
|
|
218
218
|
isError: true,
|
|
219
219
|
};
|
|
220
220
|
}
|
|
221
|
-
const
|
|
221
|
+
const status = err.response?.status;
|
|
222
|
+
const detail = errorText(err, "unknown error");
|
|
223
|
+
const hint = status === 401 ? " Check the BrowserStack credentials in use." : "";
|
|
222
224
|
return {
|
|
223
225
|
content: [
|
|
224
226
|
{
|
|
225
227
|
type: "text",
|
|
226
|
-
text: `Failed to update test case
|
|
228
|
+
text: `Failed to update test case${status ? ` (HTTP ${status})` : ""}: ${detail}.${hint}`,
|
|
227
229
|
},
|
|
228
230
|
],
|
|
229
231
|
isError: true,
|