@browserstack/mcp-server 2.2.0-beta.1 → 2.2.0
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 +42 -432
- package/capability/loadtesting.capability-index.json +25 -2
- package/capability/tm.capability-index.json +1 -2
- package/dist/config.d.ts +27 -2
- package/dist/config.js +19 -6
- package/dist/lib/instrumentation.js +29 -3
- package/dist/lib/utils.js +8 -0
- package/dist/tools/accessiblity-utils/scanner.js +1 -1
- package/dist/tools/ask-browserstack/central-oauth.d.ts +46 -25
- package/dist/tools/ask-browserstack/central-oauth.js +177 -55
- package/dist/tools/ask-browserstack/config.d.ts +0 -14
- package/dist/tools/ask-browserstack/config.js +0 -16
- package/dist/tools/ask-browserstack/deferred.d.ts +74 -0
- package/dist/tools/ask-browserstack/deferred.js +242 -0
- package/dist/tools/ask-browserstack/register.d.ts +3 -2
- package/dist/tools/ask-browserstack/register.js +219 -72
- package/dist/tools/ask-browserstack/relay.js +11 -17
- package/dist/tools/ask-browserstack/stream.d.ts +27 -5
- package/dist/tools/ask-browserstack/stream.js +25 -14
- package/dist/tools/ask-browserstack/types.d.ts +54 -3
- package/dist/tools/ask-browserstack/types.js +11 -1
- package/dist/tools/automate-utils/fetch-screenshots.js +17 -15
- package/dist/tools/capability-registry/bind.js +7 -43
- package/dist/tools/capability-registry/discovery.js +1 -3
- package/dist/tools/capability-registry/egress.d.ts +5 -12
- package/dist/tools/capability-registry/egress.js +1 -24
- package/dist/tools/capability-registry/redact.js +7 -5
- package/dist/tools/capability-registry/register.js +41 -289
- package/dist/tools/capability-registry/resolve.d.ts +1 -1
- package/dist/tools/capability-registry/resolve.js +3 -24
- package/dist/tools/capability-registry/search.d.ts +1 -28
- package/dist/tools/capability-registry/search.js +4 -202
- package/dist/tools/capability-registry/types.d.ts +3 -42
- package/dist/tools/testmanagement.js +5 -5
- package/dist/tools/tool-handoff.d.ts +12 -15
- package/dist/tools/tool-handoff.js +18 -21
- package/package.json +3 -4
- package/capability/tra.capability-index.json +0 -52074
- package/dist/lib/central-oauth.d.ts +0 -133
- package/dist/lib/central-oauth.js +0 -246
- package/dist/tools/capability-registry/oauth.d.ts +0 -63
- package/dist/tools/capability-registry/oauth.js +0 -176
- package/dist/tools/capability-registry/routing-token.d.ts +0 -25
- package/dist/tools/capability-registry/routing-token.js +0 -87
|
@@ -11,4 +11,14 @@
|
|
|
11
11
|
// describe text in register.ts, and the two a11y handoffs in tool-handoff.ts — which stop
|
|
12
12
|
// pointing here precisely because the call would now be rejected.
|
|
13
13
|
export const PRODUCTS = ["tm", "tra"];
|
|
14
|
-
export const ASK_STATUSES = [
|
|
14
|
+
export const ASK_STATUSES = [
|
|
15
|
+
"ok",
|
|
16
|
+
"blocked",
|
|
17
|
+
"error",
|
|
18
|
+
"rate_limited",
|
|
19
|
+
// Deferred only: the run reached an ask and is PARKED, not finished. Distinct from
|
|
20
|
+
// `blocked` (a human said no) and from `error` (nothing is waiting) — this one is the
|
|
21
|
+
// only status a caller is invited to come back from, and the only one carrying a
|
|
22
|
+
// `run_id`.
|
|
23
|
+
"needs_approval",
|
|
24
|
+
];
|
|
@@ -2,6 +2,7 @@ import { assertOkResponse, maybeCompressBase64 } from "../../lib/utils.js";
|
|
|
2
2
|
import { SessionType } from "../../lib/constants.js";
|
|
3
3
|
import { getBrowserStackAuth } from "../../lib/get-auth.js";
|
|
4
4
|
import { apiClient } from "../../lib/apiClient.js";
|
|
5
|
+
import globalConfig from "../../config.js";
|
|
5
6
|
async function extractScreenshotUrls(sessionId, sessionType, config) {
|
|
6
7
|
const authString = getBrowserStackAuth(config);
|
|
7
8
|
const auth = Buffer.from(authString).toString("base64");
|
|
@@ -36,23 +37,24 @@ async function extractScreenshotUrls(sessionId, sessionType, config) {
|
|
|
36
37
|
}
|
|
37
38
|
return urls;
|
|
38
39
|
}
|
|
40
|
+
async function fetchAndCompress(url) {
|
|
41
|
+
const response = await apiClient.get({ url, responseType: "arraybuffer" });
|
|
42
|
+
// Axios returns response.data as a Buffer for binary data
|
|
43
|
+
const base64 = Buffer.from(response.data).toString("base64");
|
|
44
|
+
return { url, base64: await maybeCompressBase64(base64) };
|
|
45
|
+
}
|
|
39
46
|
//Converts screenshot URLs to base64 encoded images
|
|
40
47
|
async function convertUrlsToBase64(urls) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
url,
|
|
52
|
-
base64: compressedBase64,
|
|
53
|
-
};
|
|
54
|
-
}));
|
|
55
|
-
return screenshots;
|
|
48
|
+
// Hosted pods are memory-constrained: process one image at a time. Local runs
|
|
49
|
+
// keep the concurrent path.
|
|
50
|
+
if (globalConfig.REMOTE_MCP) {
|
|
51
|
+
const screenshots = [];
|
|
52
|
+
for (const url of urls) {
|
|
53
|
+
screenshots.push(await fetchAndCompress(url));
|
|
54
|
+
}
|
|
55
|
+
return screenshots;
|
|
56
|
+
}
|
|
57
|
+
return Promise.all(urls.map(fetchAndCompress));
|
|
56
58
|
}
|
|
57
59
|
//Fetches and converts screenshot URLs to base64 encoded images
|
|
58
60
|
export async function fetchAutomationScreenshots(sessionId, sessionType = SessionType.Automate, config) {
|
|
@@ -230,15 +230,6 @@ function coerceType(value, param, label = param.name) {
|
|
|
230
230
|
if (expected === "integer" && !Number.isInteger(parsed)) {
|
|
231
231
|
throw new InvocationError(`'${label}' must be a whole number, not ${JSON.stringify(raw)}`);
|
|
232
232
|
}
|
|
233
|
-
// Past 2^53 `Number` rounds: TRA's 17-digit unique-error cluster ids came back as a
|
|
234
|
-
// neighbouring id, so the call silently targeted the wrong cluster. Keep the caller's
|
|
235
|
-
// exact digits instead — path and query serialise a string identically, and a JSON body
|
|
236
|
-
// carrying a digit string still binds to a Long on the server.
|
|
237
|
-
if (expected === "integer" &&
|
|
238
|
-
!Number.isSafeInteger(parsed) &&
|
|
239
|
-
/^-?\d+$/.test(raw)) {
|
|
240
|
-
return raw;
|
|
241
|
-
}
|
|
242
233
|
return parsed;
|
|
243
234
|
}
|
|
244
235
|
if (expected === "boolean") {
|
|
@@ -289,12 +280,6 @@ function place(root, pointer, value) {
|
|
|
289
280
|
}
|
|
290
281
|
cursor[segments[segments.length - 1]] = value;
|
|
291
282
|
}
|
|
292
|
-
const MAP_KEY = /^[A-Za-z0-9][\w.-]*$/;
|
|
293
|
-
function keyedParam(group, params) {
|
|
294
|
-
if (group !== "body")
|
|
295
|
-
return undefined;
|
|
296
|
-
return params.find((param) => /^\{\w+\}$/.test(param.name));
|
|
297
|
-
}
|
|
298
283
|
const GROUPS = [
|
|
299
284
|
{ group: "path_params", declared: "path_params" },
|
|
300
285
|
{ group: "query", declared: "query" },
|
|
@@ -313,10 +298,9 @@ export function bind(capability, args) {
|
|
|
313
298
|
}
|
|
314
299
|
const params = capability[declared] || [];
|
|
315
300
|
const byName = new Map(params.map((param) => [param.name, param]));
|
|
316
|
-
const keyed = keyedParam(group, params);
|
|
317
301
|
// Unknown arguments are an error rather than being dropped: silently ignoring a
|
|
318
302
|
// misspelled filter would return a larger result set that looks like a correct answer.
|
|
319
|
-
const unknown = Object.keys(supplied).filter((name) => !byName.has(name)
|
|
303
|
+
const unknown = Object.keys(supplied).filter((name) => !byName.has(name));
|
|
320
304
|
if (unknown.length > 0) {
|
|
321
305
|
// A CALLER WHO SENT THE WRAPPER WE BUILD DESERVES TO BE TOLD SO.
|
|
322
306
|
//
|
|
@@ -347,10 +331,7 @@ export function bind(capability, args) {
|
|
|
347
331
|
: ""));
|
|
348
332
|
}
|
|
349
333
|
for (const [name, raw] of Object.entries(supplied)) {
|
|
350
|
-
const param = byName.get(name)
|
|
351
|
-
if (param === keyed && !MAP_KEY.test(name)) {
|
|
352
|
-
throw new InvocationError(`'${name}' is not a usable ${keyed.name} key: use the id exactly as a read returned it`);
|
|
353
|
-
}
|
|
334
|
+
const param = byName.get(name);
|
|
354
335
|
const value = coerce(raw, param);
|
|
355
336
|
if (group === "path_params") {
|
|
356
337
|
// Encode with nothing exempt: a `/` inside a path value would otherwise rewrite the
|
|
@@ -358,10 +339,7 @@ export function bind(capability, args) {
|
|
|
358
339
|
path = path.replaceAll(`{${name}}`, encodeURIComponent(pathSegment(value, name)));
|
|
359
340
|
}
|
|
360
341
|
else if (group === "body") {
|
|
361
|
-
|
|
362
|
-
place(body, param === keyed && !byName.has(name)
|
|
363
|
-
? pointer.replace(keyed.name, name)
|
|
364
|
-
: pointer, value);
|
|
342
|
+
place(body, param.json_path || `/${name}`, value);
|
|
365
343
|
}
|
|
366
344
|
else {
|
|
367
345
|
query[name] = value;
|
|
@@ -376,14 +354,9 @@ export function bind(capability, args) {
|
|
|
376
354
|
if (group === "query")
|
|
377
355
|
continue;
|
|
378
356
|
const supplied = args[group] || {};
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
for (const param of params) {
|
|
383
|
-
const present = param.name in supplied ||
|
|
384
|
-
(param === keyed &&
|
|
385
|
-
Object.keys(supplied).some((name) => !declaredNames.has(name)));
|
|
386
|
-
if (param.required && !present)
|
|
357
|
+
for (const param of capability[declared] ||
|
|
358
|
+
[]) {
|
|
359
|
+
if (param.required && !(param.name in supplied))
|
|
387
360
|
missing.push(param.name);
|
|
388
361
|
}
|
|
389
362
|
}
|
|
@@ -394,14 +367,5 @@ export function bind(capability, args) {
|
|
|
394
367
|
if (leftover) {
|
|
395
368
|
throw new InvocationError(`path placeholder(s) not supplied: ${leftover.join(", ")}`);
|
|
396
369
|
}
|
|
397
|
-
|
|
398
|
-
// all, and a route with a required @RequestBody (TRA close_build, whose guidance says to
|
|
399
|
-
// send `{}`) answered 400 "Invalid request payload". GET/HEAD never carry one.
|
|
400
|
-
const sentEmptyBody = args.body !== undefined &&
|
|
401
|
-
!["GET", "HEAD"].includes(capability.method.toUpperCase());
|
|
402
|
-
return {
|
|
403
|
-
path,
|
|
404
|
-
query,
|
|
405
|
-
body: Object.keys(body).length > 0 || sentEmptyBody ? body : undefined,
|
|
406
|
-
};
|
|
370
|
+
return { path, query, body: Object.keys(body).length > 0 ? body : undefined };
|
|
407
371
|
}
|
|
@@ -79,9 +79,7 @@ export async function discoverBaseUrl(product, source, credentials, transport) {
|
|
|
79
79
|
`set probe_path in its index`);
|
|
80
80
|
}
|
|
81
81
|
// The same scheme the invocation will use, so a host that answers here answers there.
|
|
82
|
-
|
|
83
|
-
// and does not vary by which region answers.
|
|
84
|
-
const headers = await authHeaders(credentials, source.auth, product);
|
|
82
|
+
const headers = authHeaders(credentials, source.auth);
|
|
85
83
|
const failures = [];
|
|
86
84
|
for (const candidate of candidates) {
|
|
87
85
|
const response = await transport("GET", `${candidate}${path}`, headers, {});
|
|
@@ -16,10 +16,11 @@
|
|
|
16
16
|
* Api-Token regardless, and the mismatch surfaced as a 401 that reads like the user's
|
|
17
17
|
* credentials are wrong.
|
|
18
18
|
*/
|
|
19
|
-
import { TokenTransport } from "../../lib/central-oauth.js";
|
|
20
19
|
import { AuthScheme } from "./types.js";
|
|
21
|
-
export
|
|
22
|
-
|
|
20
|
+
export interface Credentials {
|
|
21
|
+
username: string;
|
|
22
|
+
accessKey: string;
|
|
23
|
+
}
|
|
23
24
|
export interface HttpResponse {
|
|
24
25
|
status: number;
|
|
25
26
|
body: unknown;
|
|
@@ -38,14 +39,6 @@ export declare const DEFAULT_AUTH: AuthScheme;
|
|
|
38
39
|
* the common case, not a hypothetical.
|
|
39
40
|
*/
|
|
40
41
|
export declare function renderTemplate(template: string, credentials: Credentials): string;
|
|
41
|
-
|
|
42
|
-
* The headers for one outbound call.
|
|
43
|
-
*
|
|
44
|
-
* ASYNC BECAUSE ONE SCHEME NEEDS A ROUND TRIP. `apiKey` and `http` are pure string work, but
|
|
45
|
-
* `oauth2` exchanges the caller's credentials for a JWT before anything can be sent, and
|
|
46
|
-
* there was nowhere to put that while this was synchronous. Both call sites were already
|
|
47
|
-
* inside async functions, so the cost is two `await`s.
|
|
48
|
-
*/
|
|
49
|
-
export declare function authHeaders(credentials: Credentials, auth?: AuthScheme, product?: string, tokenTransport?: TokenTransport): Promise<Record<string, string>>;
|
|
42
|
+
export declare function authHeaders(credentials: Credentials, auth?: AuthScheme): Record<string, string>;
|
|
50
43
|
/** A fetch-based transport. Redirects are NOT followed. */
|
|
51
44
|
export declare function fetchTransport(timeoutMs?: number): Transport;
|
|
@@ -16,9 +16,7 @@
|
|
|
16
16
|
* Api-Token regardless, and the mismatch surfaced as a 401 that reads like the user's
|
|
17
17
|
* credentials are wrong.
|
|
18
18
|
*/
|
|
19
|
-
import { fetchTokenTransport, } from "../../lib/central-oauth.js";
|
|
20
19
|
import { InvocationError } from "./index-loader.js";
|
|
21
|
-
import { bearerToken } from "./oauth.js";
|
|
22
20
|
/** What this server can put into a credential template. Nothing else is fillable. */
|
|
23
21
|
const PLACEHOLDERS = ["username", "access_key"];
|
|
24
22
|
/** The historical default, and what an index without an `auth` block still gets. */
|
|
@@ -50,15 +48,7 @@ export function renderTemplate(template, credentials) {
|
|
|
50
48
|
.replaceAll("{username}", credentials.username)
|
|
51
49
|
.replaceAll("{access_key}", credentials.accessKey);
|
|
52
50
|
}
|
|
53
|
-
|
|
54
|
-
* The headers for one outbound call.
|
|
55
|
-
*
|
|
56
|
-
* ASYNC BECAUSE ONE SCHEME NEEDS A ROUND TRIP. `apiKey` and `http` are pure string work, but
|
|
57
|
-
* `oauth2` exchanges the caller's credentials for a JWT before anything can be sent, and
|
|
58
|
-
* there was nowhere to put that while this was synchronous. Both call sites were already
|
|
59
|
-
* inside async functions, so the cost is two `await`s.
|
|
60
|
-
*/
|
|
61
|
-
export async function authHeaders(credentials, auth = DEFAULT_AUTH, product = "this product", tokenTransport = fetchTokenTransport()) {
|
|
51
|
+
export function authHeaders(credentials, auth = DEFAULT_AUTH) {
|
|
62
52
|
if (!credentials?.username || !credentials?.accessKey) {
|
|
63
53
|
// Refusing here beats sending unauthenticated and surfacing the product's 401, which
|
|
64
54
|
// reads like the user's problem when it is our missing configuration.
|
|
@@ -69,19 +59,6 @@ export async function authHeaders(credentials, auth = DEFAULT_AUTH, product = "t
|
|
|
69
59
|
"request-source": "ai-chatbot",
|
|
70
60
|
"Content-Type": "application/json",
|
|
71
61
|
};
|
|
72
|
-
if (auth.type === "oauth2") {
|
|
73
|
-
// Refused rather than ignored, which is the same call `in: cookie` and `in: query` get
|
|
74
|
-
// below: a field that looks like it selects behaviour and does not is how someone
|
|
75
|
-
// spends an afternoon on a 401 they were never going to be able to explain.
|
|
76
|
-
const stray = ["in", "name", "template"].filter((field) => auth[field] !== undefined);
|
|
77
|
-
if (stray.length > 0) {
|
|
78
|
-
throw new InvocationError(`oauth2 auth does not take ${stray.join(", ")}: a bearer token's presentation is ` +
|
|
79
|
-
`fixed at 'Authorization: Bearer' (RFC 6750). Remove ${stray.length > 1 ? "them" : "it"}, ` +
|
|
80
|
-
`or use apiKey if this product really does read a different header.`);
|
|
81
|
-
}
|
|
82
|
-
const token = await bearerToken(product, auth, credentials, tokenTransport);
|
|
83
|
-
return { Authorization: `Bearer ${token}`, ...common };
|
|
84
|
-
}
|
|
85
62
|
const value = renderTemplate(auth.template || DEFAULT_AUTH.template, credentials);
|
|
86
63
|
if (auth.type === "apiKey") {
|
|
87
64
|
// Header only. `cookie` needs a session this server does not have, and `query` would
|
|
@@ -25,13 +25,13 @@ const RULES = [
|
|
|
25
25
|
},
|
|
26
26
|
{
|
|
27
27
|
name: "email",
|
|
28
|
-
pattern: /\b[\w.%+-]
|
|
28
|
+
pattern: /\b[\w.%+-]{1,128}@[\w.-]{1,128}\.[a-z]{2,24}\b/gi,
|
|
29
29
|
replacement: "[email]",
|
|
30
30
|
},
|
|
31
31
|
// JWT: three base64url segments.
|
|
32
32
|
{
|
|
33
33
|
name: "jwt",
|
|
34
|
-
pattern: /\beyJ[\w-]{8,}\.[\w-]{8,}\.[\w-]{8,}\b/g,
|
|
34
|
+
pattern: /\beyJ[\w-]{8,1024}\.[\w-]{8,1024}\.[\w-]{8,1024}\b/g,
|
|
35
35
|
replacement: "[token]",
|
|
36
36
|
},
|
|
37
37
|
// A long opaque run — access keys, API keys, session ids. Requires BOTH a digit and a
|
|
@@ -41,7 +41,7 @@ const RULES = [
|
|
|
41
41
|
// wording a product uses is exactly the assumption that fails.
|
|
42
42
|
{
|
|
43
43
|
name: "opaque_token",
|
|
44
|
-
pattern: /\b(?=[A-Za-z0-9_-]
|
|
44
|
+
pattern: /\b(?=[A-Za-z0-9_-]{0,256}\d)(?=[A-Za-z0-9_-]{0,256}[A-Za-z])[A-Za-z0-9_-]{20,256}\b/g,
|
|
45
45
|
replacement: "[token]",
|
|
46
46
|
},
|
|
47
47
|
{
|
|
@@ -53,7 +53,7 @@ const RULES = [
|
|
|
53
53
|
// separator or a `+`, so a bare digit run falls to long_number instead.
|
|
54
54
|
{
|
|
55
55
|
name: "phone",
|
|
56
|
-
pattern: /(?:\+\d{1,3}[\s.-]?)?(?:\(\d{2,4}\)[\s.-]?)?\d{3,5}[\s.-]\d{3,5}(?:[\s.-]\d{2,5})?\b/g,
|
|
56
|
+
pattern: /(?:\+\d{1,3}[\s.-]?)?(?:\(\d{2,4}\)[\s.-]?)?\b\d{3,5}[\s.-]\d{3,5}(?:[\s.-]\d{2,5})?\b/g,
|
|
57
57
|
replacement: "[phone]",
|
|
58
58
|
},
|
|
59
59
|
// Long bare digit runs: account ids, card numbers, unseparated phone numbers.
|
|
@@ -81,7 +81,9 @@ export function redact(text) {
|
|
|
81
81
|
try {
|
|
82
82
|
if (typeof text !== "string")
|
|
83
83
|
return undefined;
|
|
84
|
-
|
|
84
|
+
// Slice before the rules run; a multi-KB digit run makes them backtrack.
|
|
85
|
+
const SCAN_LIMIT = MAX_LENGTH * 8;
|
|
86
|
+
let out = text.slice(0, SCAN_LIMIT).replace(/\s+/g, " ").trim();
|
|
85
87
|
if (!out)
|
|
86
88
|
return undefined;
|
|
87
89
|
for (const rule of RULES)
|