@browserstack/mcp-server 2.1.0 → 2.2.0-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.
Files changed (40) hide show
  1. package/capability/loadtesting.capability-index.json +2978 -0
  2. package/capability/tm.capability-index.json +2 -1
  3. package/capability/tra.capability-index.json +52074 -0
  4. package/dist/config.d.ts +2 -27
  5. package/dist/config.js +6 -19
  6. package/dist/lib/central-oauth.d.ts +133 -0
  7. package/dist/lib/central-oauth.js +246 -0
  8. package/dist/lib/utils.js +0 -8
  9. package/dist/tools/ask-browserstack/central-oauth.d.ts +25 -46
  10. package/dist/tools/ask-browserstack/central-oauth.js +55 -177
  11. package/dist/tools/ask-browserstack/config.d.ts +14 -0
  12. package/dist/tools/ask-browserstack/config.js +16 -0
  13. package/dist/tools/ask-browserstack/register.d.ts +2 -3
  14. package/dist/tools/ask-browserstack/register.js +72 -219
  15. package/dist/tools/ask-browserstack/relay.js +17 -11
  16. package/dist/tools/ask-browserstack/stream.d.ts +5 -27
  17. package/dist/tools/ask-browserstack/stream.js +14 -25
  18. package/dist/tools/ask-browserstack/types.d.ts +3 -54
  19. package/dist/tools/ask-browserstack/types.js +1 -11
  20. package/dist/tools/automate-utils/fetch-screenshots.js +15 -17
  21. package/dist/tools/capability-registry/bind.js +43 -7
  22. package/dist/tools/capability-registry/discovery.js +3 -1
  23. package/dist/tools/capability-registry/egress.d.ts +12 -5
  24. package/dist/tools/capability-registry/egress.js +24 -1
  25. package/dist/tools/capability-registry/oauth.d.ts +63 -0
  26. package/dist/tools/capability-registry/oauth.js +176 -0
  27. package/dist/tools/capability-registry/register.js +289 -41
  28. package/dist/tools/capability-registry/resolve.d.ts +1 -1
  29. package/dist/tools/capability-registry/resolve.js +24 -3
  30. package/dist/tools/capability-registry/routing-token.d.ts +25 -0
  31. package/dist/tools/capability-registry/routing-token.js +87 -0
  32. package/dist/tools/capability-registry/search.d.ts +28 -1
  33. package/dist/tools/capability-registry/search.js +202 -4
  34. package/dist/tools/capability-registry/types.d.ts +42 -3
  35. package/dist/tools/testmanagement.js +5 -5
  36. package/dist/tools/tool-handoff.d.ts +15 -12
  37. package/dist/tools/tool-handoff.js +21 -18
  38. package/package.json +2 -1
  39. package/dist/tools/ask-browserstack/deferred.d.ts +0 -74
  40. package/dist/tools/ask-browserstack/deferred.js +0 -242
@@ -11,14 +11,4 @@
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 = [
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
- ];
14
+ export const ASK_STATUSES = ["ok", "blocked", "error", "rate_limited"];
@@ -2,7 +2,6 @@ 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";
6
5
  async function extractScreenshotUrls(sessionId, sessionType, config) {
7
6
  const authString = getBrowserStackAuth(config);
8
7
  const auth = Buffer.from(authString).toString("base64");
@@ -37,24 +36,23 @@ async function extractScreenshotUrls(sessionId, sessionType, config) {
37
36
  }
38
37
  return urls;
39
38
  }
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
- }
46
39
  //Converts screenshot URLs to base64 encoded images
47
40
  async function convertUrlsToBase64(urls) {
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));
41
+ const screenshots = await Promise.all(urls.map(async (url) => {
42
+ const response = await apiClient.get({
43
+ url,
44
+ responseType: "arraybuffer",
45
+ });
46
+ // Axios returns response.data as a Buffer for binary data
47
+ const base64 = Buffer.from(response.data).toString("base64");
48
+ // Compress the base64 image if needed
49
+ const compressedBase64 = await maybeCompressBase64(base64);
50
+ return {
51
+ url,
52
+ base64: compressedBase64,
53
+ };
54
+ }));
55
+ return screenshots;
58
56
  }
59
57
  //Fetches and converts screenshot URLs to base64 encoded images
60
58
  export async function fetchAutomationScreenshots(sessionId, sessionType = SessionType.Automate, config) {
@@ -230,6 +230,15 @@ 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
+ }
233
242
  return parsed;
234
243
  }
235
244
  if (expected === "boolean") {
@@ -280,6 +289,12 @@ function place(root, pointer, value) {
280
289
  }
281
290
  cursor[segments[segments.length - 1]] = value;
282
291
  }
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
+ }
283
298
  const GROUPS = [
284
299
  { group: "path_params", declared: "path_params" },
285
300
  { group: "query", declared: "query" },
@@ -298,9 +313,10 @@ export function bind(capability, args) {
298
313
  }
299
314
  const params = capability[declared] || [];
300
315
  const byName = new Map(params.map((param) => [param.name, param]));
316
+ const keyed = keyedParam(group, params);
301
317
  // Unknown arguments are an error rather than being dropped: silently ignoring a
302
318
  // misspelled filter would return a larger result set that looks like a correct answer.
303
- const unknown = Object.keys(supplied).filter((name) => !byName.has(name));
319
+ const unknown = Object.keys(supplied).filter((name) => !byName.has(name) && !keyed);
304
320
  if (unknown.length > 0) {
305
321
  // A CALLER WHO SENT THE WRAPPER WE BUILD DESERVES TO BE TOLD SO.
306
322
  //
@@ -331,7 +347,10 @@ export function bind(capability, args) {
331
347
  : ""));
332
348
  }
333
349
  for (const [name, raw] of Object.entries(supplied)) {
334
- const param = byName.get(name);
350
+ const param = byName.get(name) ?? keyed;
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
+ }
335
354
  const value = coerce(raw, param);
336
355
  if (group === "path_params") {
337
356
  // Encode with nothing exempt: a `/` inside a path value would otherwise rewrite the
@@ -339,7 +358,10 @@ export function bind(capability, args) {
339
358
  path = path.replaceAll(`{${name}}`, encodeURIComponent(pathSegment(value, name)));
340
359
  }
341
360
  else if (group === "body") {
342
- place(body, param.json_path || `/${name}`, value);
361
+ const pointer = param.json_path || `/${param.name}`;
362
+ place(body, param === keyed && !byName.has(name)
363
+ ? pointer.replace(keyed.name, name)
364
+ : pointer, value);
343
365
  }
344
366
  else {
345
367
  query[name] = value;
@@ -354,9 +376,14 @@ export function bind(capability, args) {
354
376
  if (group === "query")
355
377
  continue;
356
378
  const supplied = args[group] || {};
357
- for (const param of capability[declared] ||
358
- []) {
359
- if (param.required && !(param.name in supplied))
379
+ const params = capability[declared] || [];
380
+ const keyed = keyedParam(group, params);
381
+ const declaredNames = new Set(params.map((param) => param.name));
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)
360
387
  missing.push(param.name);
361
388
  }
362
389
  }
@@ -367,5 +394,14 @@ export function bind(capability, args) {
367
394
  if (leftover) {
368
395
  throw new InvocationError(`path placeholder(s) not supplied: ${leftover.join(", ")}`);
369
396
  }
370
- return { path, query, body: Object.keys(body).length > 0 ? body : undefined };
397
+ // An explicitly supplied empty body is still a body. Dropping it sent no request body at
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
+ };
371
407
  }
@@ -79,7 +79,9 @@ 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
- const headers = authHeaders(credentials, source.auth);
82
+ // Minted ONCE for the whole probe, not per candidate: the token is the caller's identity
83
+ // and does not vary by which region answers.
84
+ const headers = await authHeaders(credentials, source.auth, product);
83
85
  const failures = [];
84
86
  for (const candidate of candidates) {
85
87
  const response = await transport("GET", `${candidate}${path}`, headers, {});
@@ -16,11 +16,10 @@
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";
19
20
  import { AuthScheme } from "./types.js";
20
- export interface Credentials {
21
- username: string;
22
- accessKey: string;
23
- }
21
+ export type { Credentials } from "../../lib/central-oauth.js";
22
+ import type { Credentials } from "../../lib/central-oauth.js";
24
23
  export interface HttpResponse {
25
24
  status: number;
26
25
  body: unknown;
@@ -39,6 +38,14 @@ export declare const DEFAULT_AUTH: AuthScheme;
39
38
  * the common case, not a hypothetical.
40
39
  */
41
40
  export declare function renderTemplate(template: string, credentials: Credentials): string;
42
- export declare function authHeaders(credentials: Credentials, auth?: AuthScheme): Record<string, 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>>;
43
50
  /** A fetch-based transport. Redirects are NOT followed. */
44
51
  export declare function fetchTransport(timeoutMs?: number): Transport;
@@ -16,7 +16,9 @@
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";
19
20
  import { InvocationError } from "./index-loader.js";
21
+ import { bearerToken } from "./oauth.js";
20
22
  /** What this server can put into a credential template. Nothing else is fillable. */
21
23
  const PLACEHOLDERS = ["username", "access_key"];
22
24
  /** The historical default, and what an index without an `auth` block still gets. */
@@ -48,7 +50,15 @@ export function renderTemplate(template, credentials) {
48
50
  .replaceAll("{username}", credentials.username)
49
51
  .replaceAll("{access_key}", credentials.accessKey);
50
52
  }
51
- export function authHeaders(credentials, auth = DEFAULT_AUTH) {
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()) {
52
62
  if (!credentials?.username || !credentials?.accessKey) {
53
63
  // Refusing here beats sending unauthenticated and surfacing the product's 401, which
54
64
  // reads like the user's problem when it is our missing configuration.
@@ -59,6 +69,19 @@ export function authHeaders(credentials, auth = DEFAULT_AUTH) {
59
69
  "request-source": "ai-chatbot",
60
70
  "Content-Type": "application/json",
61
71
  };
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
+ }
62
85
  const value = renderTemplate(auth.template || DEFAULT_AUTH.template, credentials);
63
86
  if (auth.type === "apiKey") {
64
87
  // Header only. `cookie` needs a session this server does not have, and `query` would
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Bearer-JWT auth for products whose API does not accept `Api-Token`.
3
+ *
4
+ * The registry was built on "the caller's credentials, forwarded — never a token this server
5
+ * mints", and this is the first product that cannot work that way. The security property is
6
+ * unchanged: the exchange uses the caller's own username and access key, the JWT carries
7
+ * their `user_id`/`group_id` as signed claims, and the scopes narrow it BELOW what the raw
8
+ * credential could do. What changes is that a token is now minted rather than formatted, so
9
+ * the sentence to hold onto is "the server grants nothing beyond the caller's own identity",
10
+ * not "the server mints nothing".
11
+ *
12
+ * The mint itself lives in `lib/central-oauth.ts` and is shared with `askBrowserStackAI`.
13
+ * This module is only the part that is the registry's: where the endpoint and scopes come
14
+ * from, what a product is allowed to ask for, and how a failure is worded.
15
+ */
16
+ import { Credentials, TokenTransport } from "../../lib/central-oauth.js";
17
+ import { AuthScheme } from "./types.js";
18
+ /**
19
+ * Where a central-OAuth JWT is minted, when a product does not name its own.
20
+ *
21
+ * Staging, for anyone setting the override:
22
+ * CAPABILITY_REGISTRY_AUTH_TOKEN_URL = https://auth-preprod.bsstag.com/oauth2/v2/token
23
+ */
24
+ export declare const DEFAULT_AUTH_TOKEN_URL = "https://auth.browserstack.com/oauth2/v2/token";
25
+ /**
26
+ * The default scope set, and why it is a PAIR.
27
+ *
28
+ * `ai_agent` is the scope a product's gate matches on. `oauth_user_profile` is not extra
29
+ * authority — it is what makes the scope obtainable through the username+access_key flow at
30
+ * all, and this is measured rather than assumed: `ai_agent` alone comes back
31
+ * `400 invalid_request`, the pair mints, and the granted token's `scopes` claim carries both.
32
+ *
33
+ * Deliberately NOT `ask-browserstack`'s `CENTRAL_SCOPE`, which pairs `ai_agent_notify`
34
+ * instead — Atlas names its own so that changing this one cannot silently change what Atlas
35
+ * is authorised for.
36
+ */
37
+ export declare const DEFAULT_SCOPES: readonly ["ai_agent", "oauth_user_profile"];
38
+ /**
39
+ * Small, because the registry uses the token for ONE HTTP call.
40
+ *
41
+ * Nothing here holds it for the length of a run the way Atlas does, so this only has to
42
+ * cover clock skew and the flight time of the request it opens.
43
+ */
44
+ export declare const REFRESH_SKEW_MS = 60000;
45
+ /** For tests, and for anything that legitimately re-resolves. */
46
+ export declare function resetOAuthAnnouncements(): void;
47
+ /**
48
+ * Three rungs: the operator's override, then the product's declaration, then the built-in.
49
+ *
50
+ * Matching `base_url`, where config is explicitly "a default, not the last word" — a
51
+ * deployment pointed at staging must not be overruled by whatever an index happens to carry.
52
+ */
53
+ export declare function resolveTokenUrl(product: string, auth?: AuthScheme): string;
54
+ /** Two rungs. A scope does not vary by deployment the way a host does, so there is no env one. */
55
+ export declare function resolveScope(auth?: AuthScheme): string;
56
+ /**
57
+ * The Bearer value for one invocation.
58
+ *
59
+ * Cached per user for the life of the token when this process serves one caller, and not at
60
+ * all when it serves many — `mintCentralToken` is handed `cacheEnabled` rather than deciding
61
+ * for itself, because the rule is about THIS deployment, not about the mint.
62
+ */
63
+ export declare function bearerToken(product: string, auth: AuthScheme | undefined, credentials: Credentials, transport: TokenTransport): Promise<string>;
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Bearer-JWT auth for products whose API does not accept `Api-Token`.
3
+ *
4
+ * The registry was built on "the caller's credentials, forwarded — never a token this server
5
+ * mints", and this is the first product that cannot work that way. The security property is
6
+ * unchanged: the exchange uses the caller's own username and access key, the JWT carries
7
+ * their `user_id`/`group_id` as signed claims, and the scopes narrow it BELOW what the raw
8
+ * credential could do. What changes is that a token is now minted rather than formatted, so
9
+ * the sentence to hold onto is "the server grants nothing beyond the caller's own identity",
10
+ * not "the server mints nothing".
11
+ *
12
+ * The mint itself lives in `lib/central-oauth.ts` and is shared with `askBrowserStackAI`.
13
+ * This module is only the part that is the registry's: where the endpoint and scopes come
14
+ * from, what a product is allowed to ask for, and how a failure is worded.
15
+ */
16
+ import appConfig from "../../config.js";
17
+ import logger from "../../logger.js";
18
+ import { CentralAuthError, mintCentralToken, normaliseScopes, } from "../../lib/central-oauth.js";
19
+ import { InvocationError } from "./index-loader.js";
20
+ /**
21
+ * Where a central-OAuth JWT is minted, when a product does not name its own.
22
+ *
23
+ * Staging, for anyone setting the override:
24
+ * CAPABILITY_REGISTRY_AUTH_TOKEN_URL = https://auth-preprod.bsstag.com/oauth2/v2/token
25
+ */
26
+ export const DEFAULT_AUTH_TOKEN_URL = "https://auth.browserstack.com/oauth2/v2/token";
27
+ /**
28
+ * The default scope set, and why it is a PAIR.
29
+ *
30
+ * `ai_agent` is the scope a product's gate matches on. `oauth_user_profile` is not extra
31
+ * authority — it is what makes the scope obtainable through the username+access_key flow at
32
+ * all, and this is measured rather than assumed: `ai_agent` alone comes back
33
+ * `400 invalid_request`, the pair mints, and the granted token's `scopes` claim carries both.
34
+ *
35
+ * Deliberately NOT `ask-browserstack`'s `CENTRAL_SCOPE`, which pairs `ai_agent_notify`
36
+ * instead — Atlas names its own so that changing this one cannot silently change what Atlas
37
+ * is authorised for.
38
+ */
39
+ export const DEFAULT_SCOPES = ["ai_agent", "oauth_user_profile"];
40
+ /**
41
+ * Small, because the registry uses the token for ONE HTTP call.
42
+ *
43
+ * Nothing here holds it for the length of a run the way Atlas does, so this only has to
44
+ * cover clock skew and the flight time of the request it opens.
45
+ */
46
+ export const REFRESH_SKEW_MS = 60_000;
47
+ /**
48
+ * Hosts a PRODUCT INDEX may name as its token endpoint.
49
+ *
50
+ * The index is a data file that ships to public npm and is read at startup. Letting it name
51
+ * an arbitrary `token_url` would let a file decide where the caller's username and access key
52
+ * get POSTed — so the file declares intent and this list bounds it. An operator setting the
53
+ * environment variable is a deliberate act and is trusted; shipping an index is not.
54
+ */
55
+ const ALLOWED_TOKEN_HOSTS = [
56
+ "auth.browserstack.com",
57
+ "auth-preprod.bsstag.com",
58
+ ];
59
+ /** Announced once per distinct resolution, so a wrong endpoint is a log line, not a deduction. */
60
+ const announced = new Set();
61
+ /** For tests, and for anything that legitimately re-resolves. */
62
+ export function resetOAuthAnnouncements() {
63
+ announced.clear();
64
+ }
65
+ function announce(product, url, source) {
66
+ const line = `${product}|${url}|${source}`;
67
+ if (announced.has(line))
68
+ return;
69
+ announced.add(line);
70
+ logger.info("capability registry: %s mints its token at %s (source: %s)", product, url, source);
71
+ }
72
+ function assertMintable(url, product) {
73
+ let parsed;
74
+ try {
75
+ parsed = new URL(url);
76
+ }
77
+ catch {
78
+ throw new InvocationError(`product '${product}' declares a token_url that is not a URL: ${JSON.stringify(url)}`);
79
+ }
80
+ // Same rule as the product call itself: credentials are never attached over plaintext.
81
+ if (parsed.protocol !== "https:") {
82
+ throw new InvocationError(`product '${product}' declares a token_url that is not https. The caller's ` +
83
+ `credentials are sent to it, so plaintext is refused.`);
84
+ }
85
+ if (!ALLOWED_TOKEN_HOSTS.includes(parsed.hostname)) {
86
+ throw new InvocationError(`product '${product}' declares a token_url on an unrecognised host ` +
87
+ `'${parsed.hostname}'. An index may only mint against ${ALLOWED_TOKEN_HOSTS.join(" or ")}; ` +
88
+ `set CAPABILITY_REGISTRY_AUTH_TOKEN_URL if this deployment really does mint elsewhere.`);
89
+ }
90
+ return url.replace(/\/+$/, "");
91
+ }
92
+ /**
93
+ * Three rungs: the operator's override, then the product's declaration, then the built-in.
94
+ *
95
+ * Matching `base_url`, where config is explicitly "a default, not the last word" — a
96
+ * deployment pointed at staging must not be overruled by whatever an index happens to carry.
97
+ */
98
+ export function resolveTokenUrl(product, auth) {
99
+ const explicit = process.env.CAPABILITY_REGISTRY_AUTH_TOKEN_URL;
100
+ if (explicit && explicit.trim()) {
101
+ const url = explicit.trim().replace(/\/+$/, "");
102
+ announce(product, url, "env");
103
+ return url;
104
+ }
105
+ if (auth?.token_url && auth.token_url.trim()) {
106
+ const url = assertMintable(auth.token_url.trim(), product);
107
+ announce(product, url, "index");
108
+ return url;
109
+ }
110
+ announce(product, DEFAULT_AUTH_TOKEN_URL, "default");
111
+ return DEFAULT_AUTH_TOKEN_URL;
112
+ }
113
+ /** Two rungs. A scope does not vary by deployment the way a host does, so there is no env one. */
114
+ export function resolveScope(auth) {
115
+ const declared = auth?.scopes;
116
+ if (Array.isArray(declared) && declared.length > 0)
117
+ return normaliseScopes(declared);
118
+ return normaliseScopes([...DEFAULT_SCOPES]);
119
+ }
120
+ /**
121
+ * A failure to mint is not a failure of the product, and must not read like one.
122
+ *
123
+ * Nothing from the token endpoint's body reaches these messages — it can echo the access key
124
+ * straight back. Only the classification and the status do.
125
+ */
126
+ function toInvocationError(error, product) {
127
+ if (!(error instanceof CentralAuthError))
128
+ return error;
129
+ const prefix = `token_mint_failed: could not sign in to '${product}'`;
130
+ switch (error.kind) {
131
+ case "no_credentials":
132
+ return new InvocationError(`${prefix}: BrowserStack username and access key are required`);
133
+ case "unreachable":
134
+ return new InvocationError(`${prefix}: BrowserStack auth could not be reached. This is a connectivity or ` +
135
+ `auth-server problem, not a problem with your credentials. Nothing was sent to ` +
136
+ `'${product}'.`);
137
+ case "server_error":
138
+ return new InvocationError(`${prefix}: BrowserStack auth is unavailable (HTTP ${error.status}). YOUR ` +
139
+ `CREDENTIALS ARE NOT THE PROBLEM — a 5xx is the auth service failing, not a ` +
140
+ `rejection. Nothing was sent to '${product}'.`);
141
+ case "refused_scope":
142
+ return new InvocationError(`${prefix}: BrowserStack auth would not issue a token for the scope ` +
143
+ `"${error.scope}" (HTTP ${error.status}). YOUR CREDENTIALS ARE NOT THE PROBLEM — ` +
144
+ `this is a provisioning problem and the scope has to be enabled for this account ` +
145
+ `or application. This server will NOT retry with a weaker scope. Nothing was ` +
146
+ `sent to '${product}'.`);
147
+ case "refused_credentials":
148
+ return new InvocationError(`${prefix}: your BrowserStack credentials were rejected by BrowserStack auth ` +
149
+ `(HTTP ${error.status}). Nothing was sent to '${product}'.`);
150
+ default:
151
+ return new InvocationError(`${prefix}: BrowserStack auth answered HTTP ${error.status} without issuing a ` +
152
+ `token. Nothing was sent to '${product}'.`);
153
+ }
154
+ }
155
+ /**
156
+ * The Bearer value for one invocation.
157
+ *
158
+ * Cached per user for the life of the token when this process serves one caller, and not at
159
+ * all when it serves many — `mintCentralToken` is handed `cacheEnabled` rather than deciding
160
+ * for itself, because the rule is about THIS deployment, not about the mint.
161
+ */
162
+ export async function bearerToken(product, auth, credentials, transport) {
163
+ try {
164
+ return await mintCentralToken({
165
+ url: resolveTokenUrl(product, auth),
166
+ scope: resolveScope(auth),
167
+ credentials,
168
+ transport,
169
+ refreshSkewMs: REFRESH_SKEW_MS,
170
+ cacheEnabled: !appConfig.REMOTE_MCP,
171
+ });
172
+ }
173
+ catch (error) {
174
+ throw toInvocationError(error, product);
175
+ }
176
+ }