@browserstack/mcp-server 1.4.0-beta.3 → 1.5.0-beta.10

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 (46) hide show
  1. package/capability/loadtesting.capability-index.json +1792 -0
  2. package/capability/tm.capability-index.json +20094 -0
  3. package/dist/config.d.ts +1 -4
  4. package/dist/config.js +2 -23
  5. package/dist/index.js +2 -5
  6. package/dist/server-factory.js +5 -5
  7. package/dist/tools/accessibility.js +2 -5
  8. package/dist/tools/capability-registry/bind.d.ts +29 -0
  9. package/dist/tools/capability-registry/bind.js +134 -0
  10. package/dist/tools/capability-registry/config.d.ts +62 -0
  11. package/dist/tools/capability-registry/config.js +218 -0
  12. package/dist/tools/capability-registry/discovery.d.ts +44 -0
  13. package/dist/tools/capability-registry/discovery.js +99 -0
  14. package/dist/tools/capability-registry/egress.d.ts +44 -0
  15. package/dist/tools/capability-registry/egress.js +128 -0
  16. package/dist/tools/capability-registry/index-loader.d.ts +133 -0
  17. package/dist/tools/capability-registry/index-loader.js +369 -0
  18. package/dist/tools/capability-registry/register.d.ts +34 -0
  19. package/dist/tools/capability-registry/register.js +396 -0
  20. package/dist/tools/capability-registry/resolve.d.ts +38 -0
  21. package/dist/tools/capability-registry/resolve.js +45 -0
  22. package/dist/tools/capability-registry/search.d.ts +97 -0
  23. package/dist/tools/capability-registry/search.js +527 -0
  24. package/dist/tools/capability-registry/types.d.ts +232 -0
  25. package/dist/tools/capability-registry/types.js +33 -0
  26. package/dist/tools/get-failure-logs.js +1 -3
  27. package/dist/tools/rca-agent.js +2 -5
  28. package/dist/tools/selfheal.js +2 -5
  29. package/dist/tools/testmanagement.js +15 -37
  30. package/package.json +3 -2
  31. package/dist/tools/ask-browserstack/central-oauth.d.ts +0 -120
  32. package/dist/tools/ask-browserstack/central-oauth.js +0 -277
  33. package/dist/tools/ask-browserstack/config.d.ts +0 -102
  34. package/dist/tools/ask-browserstack/config.js +0 -140
  35. package/dist/tools/ask-browserstack/egress.d.ts +0 -34
  36. package/dist/tools/ask-browserstack/egress.js +0 -31
  37. package/dist/tools/ask-browserstack/register.d.ts +0 -61
  38. package/dist/tools/ask-browserstack/register.js +0 -416
  39. package/dist/tools/ask-browserstack/relay.d.ts +0 -201
  40. package/dist/tools/ask-browserstack/relay.js +0 -577
  41. package/dist/tools/ask-browserstack/stream.d.ts +0 -116
  42. package/dist/tools/ask-browserstack/stream.js +0 -236
  43. package/dist/tools/ask-browserstack/types.d.ts +0 -196
  44. package/dist/tools/ask-browserstack/types.js +0 -14
  45. package/dist/tools/tool-handoff.d.ts +0 -62
  46. package/dist/tools/tool-handoff.js +0 -75
package/dist/config.d.ts CHANGED
@@ -11,10 +11,7 @@ export declare class Config {
11
11
  readonly O11Y_TFA_RCA_BASE_URL: string;
12
12
  readonly BROWSERSTACK_AUTOMATION_BASE_URL: string;
13
13
  readonly BROWSERSTACK_O11Y_UI_BASE_URL: string;
14
- readonly ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY: boolean;
15
- readonly ASK_BROWSERSTACK_ATLAS_URL: string | undefined;
16
- readonly ASK_BROWSERSTACK_AUTH_TOKEN_URL: string | undefined;
17
- constructor(DEV_MODE: boolean, browserstackLocalOptions: Record<string, any>, USE_OWN_LOCAL_BINARY_PROCESS: boolean, REMOTE_MCP: boolean, UPLOAD_BASE_DIR: string | undefined, O11Y_TFA_RCA_BASE_URL: string, BROWSERSTACK_AUTOMATION_BASE_URL: string, BROWSERSTACK_O11Y_UI_BASE_URL: string, ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY: boolean, ASK_BROWSERSTACK_ATLAS_URL: string | undefined, ASK_BROWSERSTACK_AUTH_TOKEN_URL: string | undefined);
14
+ constructor(DEV_MODE: boolean, browserstackLocalOptions: Record<string, any>, USE_OWN_LOCAL_BINARY_PROCESS: boolean, REMOTE_MCP: boolean, UPLOAD_BASE_DIR: string | undefined, O11Y_TFA_RCA_BASE_URL: string, BROWSERSTACK_AUTOMATION_BASE_URL: string, BROWSERSTACK_O11Y_UI_BASE_URL: string);
18
15
  }
19
16
  declare const config: Config;
20
17
  export default config;
package/dist/config.js CHANGED
@@ -47,18 +47,7 @@ export class Config {
47
47
  O11Y_TFA_RCA_BASE_URL;
48
48
  BROWSERSTACK_AUTOMATION_BASE_URL;
49
49
  BROWSERSTACK_O11Y_UI_BASE_URL;
50
- ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY;
51
- ASK_BROWSERSTACK_ATLAS_URL;
52
- ASK_BROWSERSTACK_AUTH_TOKEN_URL;
53
- constructor(DEV_MODE, browserstackLocalOptions, USE_OWN_LOCAL_BINARY_PROCESS, REMOTE_MCP, UPLOAD_BASE_DIR, O11Y_TFA_RCA_BASE_URL, BROWSERSTACK_AUTOMATION_BASE_URL, BROWSERSTACK_O11Y_UI_BASE_URL,
54
- // askBrowserStackAI's process-startup settings. Declared here rather than read from
55
- // process.env inside src/tools/, per rules/tool-design.md — and so the remote wrapper,
56
- // which only forwards env it knows about, has one place to look.
57
- //
58
- // ASK_BROWSERSTACK_DISABLED is deliberately NOT here: it is a kill switch, and reading
59
- // it per call keeps it effective without a restart. Fixing it at boot would mean a pod
60
- // roll to disable the tool, which is slowest exactly when you need it fastest.
61
- ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY, ASK_BROWSERSTACK_ATLAS_URL, ASK_BROWSERSTACK_AUTH_TOKEN_URL) {
50
+ constructor(DEV_MODE, browserstackLocalOptions, USE_OWN_LOCAL_BINARY_PROCESS, REMOTE_MCP, UPLOAD_BASE_DIR, O11Y_TFA_RCA_BASE_URL, BROWSERSTACK_AUTOMATION_BASE_URL, BROWSERSTACK_O11Y_UI_BASE_URL) {
62
51
  this.DEV_MODE = DEV_MODE;
63
52
  this.browserstackLocalOptions = browserstackLocalOptions;
64
53
  this.USE_OWN_LOCAL_BINARY_PROCESS = USE_OWN_LOCAL_BINARY_PROCESS;
@@ -67,9 +56,6 @@ export class Config {
67
56
  this.O11Y_TFA_RCA_BASE_URL = O11Y_TFA_RCA_BASE_URL;
68
57
  this.BROWSERSTACK_AUTOMATION_BASE_URL = BROWSERSTACK_AUTOMATION_BASE_URL;
69
58
  this.BROWSERSTACK_O11Y_UI_BASE_URL = BROWSERSTACK_O11Y_UI_BASE_URL;
70
- this.ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY = ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY;
71
- this.ASK_BROWSERSTACK_ATLAS_URL = ASK_BROWSERSTACK_ATLAS_URL;
72
- this.ASK_BROWSERSTACK_AUTH_TOKEN_URL = ASK_BROWSERSTACK_AUTH_TOKEN_URL;
73
59
  }
74
60
  }
75
61
  const config = new Config(process.env.DEV_MODE === "true", browserstackLocalOptions, process.env.USE_OWN_LOCAL_BINARY_PROCESS === "true", process.env.REMOTE_MCP === "true", process.env.MCP_UPLOAD_BASE_DIR && process.env.MCP_UPLOAD_BASE_DIR.length > 0
@@ -83,12 +69,5 @@ const config = new Config(process.env.DEV_MODE === "true", browserstackLocalOpti
83
69
  : DEFAULT_BROWSERSTACK_AUTOMATION_BASE_URL, process.env.BROWSERSTACK_O11Y_UI_BASE_URL &&
84
70
  process.env.BROWSERSTACK_O11Y_UI_BASE_URL.length > 0
85
71
  ? process.env.BROWSERSTACK_O11Y_UI_BASE_URL
86
- : DEFAULT_BROWSERSTACK_O11Y_UI_BASE_URL, (process.env.ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY || "").toLowerCase() ===
87
- "true", process.env.ASK_BROWSERSTACK_ATLAS_URL &&
88
- process.env.ASK_BROWSERSTACK_ATLAS_URL.trim().length > 0
89
- ? process.env.ASK_BROWSERSTACK_ATLAS_URL
90
- : undefined, process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL &&
91
- process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL.trim().length > 0
92
- ? process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL
93
- : undefined);
72
+ : DEFAULT_BROWSERSTACK_O11Y_UI_BASE_URL);
94
73
  export default config;
package/dist/index.js CHANGED
@@ -29,12 +29,9 @@ async function main() {
29
29
  await mcpServer.getInstance().connect(transport);
30
30
  }
31
31
  main().catch(console.error);
32
- // Ensure logs are flushed before exit. Optional-called because an exit handler must not
33
- // be the thing that throws: `flush` is absent from every `vi.mock("src/logger")` in the
34
- // suite, so any test file that loads this module used to fail the run with
35
- // `default.flush is not a function` depending on how vitest scheduled workers.
32
+ // Ensure logs are flushed before exit
36
33
  process.on("exit", () => {
37
- logger.flush?.();
34
+ logger.flush();
38
35
  });
39
36
  export { setLogger } from "./logger.js";
40
37
  export { BrowserStackMcpServer } from "./server-factory.js";
@@ -16,7 +16,7 @@ import addAppLiveTools from "./tools/applive.js";
16
16
  import addBuildInsightsTools from "./tools/build-insights.js";
17
17
  import { setupOnInitialized } from "./oninitialized.js";
18
18
  import addRCATools from "./tools/rca-agent.js";
19
- import addAskBrowserStackAITool from "./tools/ask-browserstack/register.js";
19
+ import addCapabilityRegistryTools from "./tools/capability-registry/register.js";
20
20
  /**
21
21
  * Wrapper class for BrowserStack MCP Server
22
22
  * Stores a map of registered tools by name
@@ -52,10 +52,10 @@ export class BrowserStackMcpServer {
52
52
  addSelfHealTools,
53
53
  addBuildInsightsTools,
54
54
  addRCATools,
55
- // Hands a plain-language task to BrowserStack's agent and relays its mid-run
56
- // permission asks back to this client, so a write can be confirmed by the human
57
- // sitting in front of it rather than refused for want of anyone to ask.
58
- addAskBrowserStackAITool,
55
+ // Driven by a prebuilt index rather than hand-written per endpoint. Registers
56
+ // nothing (and logs why) when the artifact is absent, so a packaging problem cannot
57
+ // take the other products' tools down with it.
58
+ addCapabilityRegistryTools,
59
59
  ];
60
60
  toolAdders.forEach((adder) => {
61
61
  // Each adder now returns a Record<string, Tool>
@@ -8,7 +8,6 @@ import { queryAccessibilityRAG } from "./accessiblity-utils/accessibility-rag.js
8
8
  import { getBrowserStackAuth } from "../lib/get-auth.js";
9
9
  import { elicitCredentialsIfSupported } from "../lib/elicit-credentials.js";
10
10
  import logger from "../logger.js";
11
- import { NEEDS_A11Y_CONFIG_ID, NEEDS_A11Y_SCAN_ID } from "./tool-handoff.js";
12
11
  function setupAuth(config) {
13
12
  const authString = getBrowserStackAuth(config);
14
13
  const [username, password] = authString.split(":");
@@ -304,8 +303,7 @@ export default function addAccessibilityTools(server, config) {
304
303
  return handleMCPError("createAccessibilityAuthConfig", server, config, error);
305
304
  }
306
305
  });
307
- tools.getAccessibilityAuthConfig = server.tool("getAccessibilityAuthConfig", "Retrieve an existing authentication configuration by ID." +
308
- NEEDS_A11Y_CONFIG_ID, {
306
+ tools.getAccessibilityAuthConfig = server.tool("getAccessibilityAuthConfig", "Retrieve an existing authentication configuration by ID.", {
309
307
  configId: z.number().describe("ID of the auth configuration to retrieve"),
310
308
  }, {
311
309
  title: "Get Accessibility Auth Config",
@@ -316,8 +314,7 @@ export default function addAccessibilityTools(server, config) {
316
314
  }, async (args) => {
317
315
  return await executeGetAuthConfig(args, server, config);
318
316
  });
319
- tools.fetchAccessibilityIssues = server.tool("fetchAccessibilityIssues", "Fetch accessibility issues from a completed scan with pagination support. Use cursor parameter to get subsequent pages of results." +
320
- NEEDS_A11Y_SCAN_ID, {
317
+ tools.fetchAccessibilityIssues = server.tool("fetchAccessibilityIssues", "Fetch accessibility issues from a completed scan with pagination support. Use cursor parameter to get subsequent pages of results.", {
321
318
  scanId: z
322
319
  .string()
323
320
  .describe("The scan ID from a completed accessibility scan"),
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Turn grouped caller arguments into a path, a query and a body.
3
+ *
4
+ * Arguments arrive GROUPED — {path_params, query, body} — because spec parameter names
5
+ * collide across locations: four tm operations declare one name in two places (`bulk-move`
6
+ * has `folder_id` as both a path parameter and a body field). A flat map cannot say which
7
+ * one is meant, which is exactly why the Python side used to rename body fields `body_*`.
8
+ * Grouping removes the collision AND the rename, so a caller sends the spec's own names.
9
+ */
10
+ import { Capability, WireParam } from "./types.js";
11
+ export interface GroupedArguments {
12
+ path_params?: Record<string, unknown>;
13
+ query?: Record<string, unknown>;
14
+ body?: Record<string, unknown>;
15
+ }
16
+ export interface BoundRequest {
17
+ path: string;
18
+ query: Record<string, unknown>;
19
+ body?: Record<string, unknown>;
20
+ }
21
+ /**
22
+ * Check one argument against its declared schema, raising a caller-safe error.
23
+ *
24
+ * Type checking is also the injection defence for path parameters: most of tm's 278 path
25
+ * parameters are `type: integer`, so a traversal attempt like `../../admin-v2` fails here
26
+ * rather than being encoded into a URL.
27
+ */
28
+ export declare function coerce(value: unknown, param: WireParam): unknown;
29
+ export declare function bind(capability: Capability, args: GroupedArguments): BoundRequest;
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Turn grouped caller arguments into a path, a query and a body.
3
+ *
4
+ * Arguments arrive GROUPED — {path_params, query, body} — because spec parameter names
5
+ * collide across locations: four tm operations declare one name in two places (`bulk-move`
6
+ * has `folder_id` as both a path parameter and a body field). A flat map cannot say which
7
+ * one is meant, which is exactly why the Python side used to rename body fields `body_*`.
8
+ * Grouping removes the collision AND the rename, so a caller sends the spec's own names.
9
+ */
10
+ import { InvocationError } from "./index-loader.js";
11
+ /**
12
+ * Check one argument against its declared schema, raising a caller-safe error.
13
+ *
14
+ * Type checking is also the injection defence for path parameters: most of tm's 278 path
15
+ * parameters are `type: integer`, so a traversal attempt like `../../admin-v2` fails here
16
+ * rather than being encoded into a URL.
17
+ */
18
+ export function coerce(value, param) {
19
+ const expected = param.type;
20
+ if (expected === "object" || expected === "array") {
21
+ // An opaque body object is passed through as given: the spec does not describe its
22
+ // fields, so validating or reshaping it would mean inventing a contract.
23
+ if (expected === "object" &&
24
+ (typeof value !== "object" || value === null || Array.isArray(value))) {
25
+ throw new InvocationError(`'${param.name}' must be an object`);
26
+ }
27
+ if (expected === "array" && !Array.isArray(value)) {
28
+ throw new InvocationError(`'${param.name}' must be a list`);
29
+ }
30
+ return value;
31
+ }
32
+ if (expected === "integer" || expected === "number") {
33
+ const parsed = Number(String(value).trim());
34
+ if (!Number.isFinite(parsed)) {
35
+ throw new InvocationError(`'${param.name}' must be a number`);
36
+ }
37
+ return expected === "integer" ? Math.trunc(parsed) : parsed;
38
+ }
39
+ if (expected === "boolean") {
40
+ if (typeof value === "boolean")
41
+ return value;
42
+ const text = String(value).trim().toLowerCase();
43
+ if (["true", "1", "yes"].includes(text))
44
+ return true;
45
+ if (["false", "0", "no"].includes(text))
46
+ return false;
47
+ throw new InvocationError(`'${param.name}' must be true or false`);
48
+ }
49
+ const text = String(value);
50
+ if (param.values && param.values.length > 0) {
51
+ const allowed = param.values.map((v) => String(v));
52
+ if (!allowed.includes(text)) {
53
+ throw new InvocationError(`'${param.name}' must be one of: ${allowed.join(", ")}`);
54
+ }
55
+ }
56
+ return text;
57
+ }
58
+ /** Place a value at a JSON-pointer-ish path, creating the objects on the way. */
59
+ function place(root, pointer, value) {
60
+ const segments = pointer.split("/").filter((segment) => segment !== "");
61
+ let cursor = root;
62
+ for (const segment of segments.slice(0, -1)) {
63
+ const next = cursor[segment];
64
+ if (typeof next !== "object" || next === null || Array.isArray(next)) {
65
+ cursor[segment] = {};
66
+ }
67
+ cursor = cursor[segment];
68
+ }
69
+ cursor[segments[segments.length - 1]] = value;
70
+ }
71
+ const GROUPS = [
72
+ { group: "path_params", declared: "path_params" },
73
+ { group: "query", declared: "query" },
74
+ { group: "body", declared: "body" },
75
+ ];
76
+ export function bind(capability, args) {
77
+ let path = capability.path;
78
+ const query = {};
79
+ const body = {};
80
+ for (const { group, declared } of GROUPS) {
81
+ const supplied = args[group] || {};
82
+ if (typeof supplied !== "object" ||
83
+ supplied === null ||
84
+ Array.isArray(supplied)) {
85
+ throw new InvocationError(`${group} must be an object of name -> value`);
86
+ }
87
+ const params = capability[declared] || [];
88
+ const byName = new Map(params.map((param) => [param.name, param]));
89
+ // Unknown arguments are an error rather than being dropped: silently ignoring a
90
+ // misspelled filter would return a larger result set that looks like a correct answer.
91
+ const unknown = Object.keys(supplied).filter((name) => !byName.has(name));
92
+ if (unknown.length > 0) {
93
+ throw new InvocationError(`unknown ${group}: ${unknown.sort().join(", ")}. accepted: ` +
94
+ `${[...byName.keys()].sort().join(", ") || "none"}`);
95
+ }
96
+ for (const [name, raw] of Object.entries(supplied)) {
97
+ const param = byName.get(name);
98
+ const value = coerce(raw, param);
99
+ if (group === "path_params") {
100
+ // Encode with nothing exempt: a `/` inside a path value would otherwise rewrite the
101
+ // route. Schema checking already stops this for integer ids; this covers strings.
102
+ path = path.replaceAll(`{${name}}`, encodeURIComponent(String(value)));
103
+ }
104
+ else if (group === "body") {
105
+ place(body, param.json_path || `/${name}`, value);
106
+ }
107
+ else {
108
+ query[name] = value;
109
+ }
110
+ }
111
+ }
112
+ // `required` is enforced for BODY as well as path. It was path-only on the Python side at
113
+ // first, so a missing required body field passed silently and the product answered with a
114
+ // 4xx that read like the caller's fault.
115
+ const missing = [];
116
+ for (const { group, declared } of GROUPS) {
117
+ if (group === "query")
118
+ continue;
119
+ const supplied = args[group] || {};
120
+ for (const param of capability[declared] ||
121
+ []) {
122
+ if (param.required && !(param.name in supplied))
123
+ missing.push(param.name);
124
+ }
125
+ }
126
+ if (missing.length > 0) {
127
+ throw new InvocationError(`missing required parameter(s): ${missing.sort().join(", ")}`);
128
+ }
129
+ const leftover = path.match(/\{[a-z_]+\}/gi);
130
+ if (leftover) {
131
+ throw new InvocationError(`path placeholder(s) not supplied: ${leftover.join(", ")}`);
132
+ }
133
+ return { path, query, body: Object.keys(body).length > 0 ? body : undefined };
134
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Where the index comes from, and where each product lives.
3
+ *
4
+ * A product may DECLARE its host in its own index file (`<product>.base_url`, authored in
5
+ * the harness's product.yaml and carried through by the export). That is the default, not
6
+ * the last word: config overrides it, and for a region-sharded product account discovery
7
+ * outranks it — see `resolveBaseUrl`. The same artifact therefore still ships to every
8
+ * environment, because anything environment- or account-specific is resolved here.
9
+ */
10
+ import { BrowserStackConfig } from "../../lib/types.js";
11
+ import { HostSource } from "./discovery.js";
12
+ import { Transport } from "./egress.js";
13
+ /**
14
+ * The environment this DEPLOYMENT points at, e.g. "preprod".
15
+ *
16
+ * Process-level on purpose, and the distinction from region matters: an environment is a
17
+ * property of the deployment (this instance talks to preprod), whereas a REGION is a
18
+ * property of the account (this user's data lives in EU). That is why region discovery is
19
+ * per request and never cached under REMOTE_MCP, while the environment is read once here.
20
+ */
21
+ export declare function selectedEnvironment(): string;
22
+ /** True when this product's host is account-specific and must be discovered per call. */
23
+ export declare function isRegionSharded(product: string, source?: HostSource): boolean;
24
+ /**
25
+ * Resolve a product's host.
26
+ *
27
+ * Atlas resolves: an explicit per-session override, then the host for the session's
28
+ * environment (`harness.extra_environments[env][product]`), then the profile's own
29
+ * `base_url`. The same rungs, in the same order, with one addition:
30
+ *
31
+ * 1. CAPABILITY_REGISTRY_BASE_URL_<PRODUCT> explicit, environment-agnostic
32
+ * 2. CAPABILITY_REGISTRY_BASE_URL_<PRODUCT>_<ENV> this environment's host
33
+ * 3. CAPABILITY_REGISTRY_BASE_URLS {product:{env:url}} the same, as one map
34
+ * 4. account discovery — the index's `base_urls`, probed
35
+ * 5. the single host declared in the index, `base_url`
36
+ * 6. refuse, by name
37
+ *
38
+ * DISCOVERY OUTRANKS THE DECLARED HOST, which is where this departs from Atlas. A declared
39
+ * host is one fixed origin, so for a region-sharded product it would send every account
40
+ * outside the default region to the wrong host — a failure that only shows up on those
41
+ * accounts, and so passes any test run from inside the default one. Discovery answers with
42
+ * the region the account is actually on; `base_url` is the fallback for when the probe
43
+ * cannot answer at all.
44
+ *
45
+ * Refusing rather than guessing is deliberate: a guessed host fails as a DNS error or a 404
46
+ * that reads like the caller's problem, when it is our missing configuration.
47
+ */
48
+ export declare function resolveBaseUrl(product: string, config: BrowserStackConfig, source?: HostSource, transport?: Transport): Promise<string>;
49
+ /**
50
+ * Locate the artifact(s).
51
+ *
52
+ * ONE FILE PER PRODUCT, so this returns a list. Explicit env wins; otherwise look for a
53
+ * `capability/` directory beside the compiled module and at the package root, then fall
54
+ * back to a single `capability-index.json` for the pre-release layout. The package-root
55
+ * candidates exist because `tsc` compiles TS and does not copy JSON into `dist`.
56
+ *
57
+ * Returns an empty list when nothing is found — the caller logs that and registers no
58
+ * tools, rather than throwing.
59
+ */
60
+ export declare function indexPaths(): string[];
61
+ /** Off by default is wrong for a shipped feature, but a kill switch is not. */
62
+ export declare function isEnabled(): boolean;
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Where the index comes from, and where each product lives.
3
+ *
4
+ * A product may DECLARE its host in its own index file (`<product>.base_url`, authored in
5
+ * the harness's product.yaml and carried through by the export). That is the default, not
6
+ * the last word: config overrides it, and for a region-sharded product account discovery
7
+ * outranks it — see `resolveBaseUrl`. The same artifact therefore still ships to every
8
+ * environment, because anything environment- or account-specific is resolved here.
9
+ */
10
+ import { existsSync, readdirSync, statSync } from "node:fs";
11
+ import { fileURLToPath } from "node:url";
12
+ import { dirname, join, resolve } from "node:path";
13
+ import logger from "../../logger.js";
14
+ import { getTMBaseURL } from "../../lib/tm-base-url.js";
15
+ import { discoverBaseUrl } from "./discovery.js";
16
+ import { fetchTransport } from "./egress.js";
17
+ import { FLAT_SUFFIX, INDEX_FILE, InvocationError } from "./index-loader.js";
18
+ /**
19
+ * The environment this DEPLOYMENT points at, e.g. "preprod".
20
+ *
21
+ * Process-level on purpose, and the distinction from region matters: an environment is a
22
+ * property of the deployment (this instance talks to preprod), whereas a REGION is a
23
+ * property of the account (this user's data lives in EU). That is why region discovery is
24
+ * per request and never cached under REMOTE_MCP, while the environment is read once here.
25
+ */
26
+ export function selectedEnvironment() {
27
+ return (process.env.CAPABILITY_REGISTRY_ENV || "").trim();
28
+ }
29
+ /** product -> env -> host, the analogue of Atlas's `harness.extra_environments`. */
30
+ function environmentMap() {
31
+ const raw = process.env.CAPABILITY_REGISTRY_BASE_URLS;
32
+ if (!raw)
33
+ return {};
34
+ try {
35
+ const parsed = JSON.parse(raw);
36
+ return parsed && typeof parsed === "object" ? parsed : {};
37
+ }
38
+ catch {
39
+ // A malformed map must not silently mean "no override" — that would send a preprod
40
+ // deployment at production.
41
+ throw new InvocationError("CAPABILITY_REGISTRY_BASE_URLS is not valid JSON; expected {product: {env: url}}");
42
+ }
43
+ }
44
+ /**
45
+ * Built-in host discovery, for a product whose regional hosts are not in its index yet.
46
+ *
47
+ * The general mechanism lives in `discovery.ts` and is driven by the index's `base_urls`.
48
+ * This map is the bridge for products that predate that: tm's hosts are still hardcoded in
49
+ * `lib/tm-base-url.ts` and shared with the hand-written Test Management tools. Once tm's
50
+ * product.yaml declares `base_urls`, this entry can go and nothing else changes.
51
+ */
52
+ const BUILTIN_DISCOVERY = {
53
+ tm: getTMBaseURL,
54
+ };
55
+ /** True when this product's host is account-specific and must be discovered per call. */
56
+ export function isRegionSharded(product, source) {
57
+ return (source?.base_urls?.length ?? 0) > 1 || product in BUILTIN_DISCOVERY;
58
+ }
59
+ function credentialsFrom(config) {
60
+ return {
61
+ username: config["browserstack-username"],
62
+ accessKey: config["browserstack-access-key"],
63
+ };
64
+ }
65
+ /**
66
+ * Resolve a product's host.
67
+ *
68
+ * Atlas resolves: an explicit per-session override, then the host for the session's
69
+ * environment (`harness.extra_environments[env][product]`), then the profile's own
70
+ * `base_url`. The same rungs, in the same order, with one addition:
71
+ *
72
+ * 1. CAPABILITY_REGISTRY_BASE_URL_<PRODUCT> explicit, environment-agnostic
73
+ * 2. CAPABILITY_REGISTRY_BASE_URL_<PRODUCT>_<ENV> this environment's host
74
+ * 3. CAPABILITY_REGISTRY_BASE_URLS {product:{env:url}} the same, as one map
75
+ * 4. account discovery — the index's `base_urls`, probed
76
+ * 5. the single host declared in the index, `base_url`
77
+ * 6. refuse, by name
78
+ *
79
+ * DISCOVERY OUTRANKS THE DECLARED HOST, which is where this departs from Atlas. A declared
80
+ * host is one fixed origin, so for a region-sharded product it would send every account
81
+ * outside the default region to the wrong host — a failure that only shows up on those
82
+ * accounts, and so passes any test run from inside the default one. Discovery answers with
83
+ * the region the account is actually on; `base_url` is the fallback for when the probe
84
+ * cannot answer at all.
85
+ *
86
+ * Refusing rather than guessing is deliberate: a guessed host fails as a DNS error or a 404
87
+ * that reads like the caller's problem, when it is our missing configuration.
88
+ */
89
+ export async function resolveBaseUrl(product, config, source, transport) {
90
+ const key = product.toUpperCase();
91
+ const environment = selectedEnvironment();
92
+ const trim = (url) => url.replace(/\/$/, "");
93
+ const explicit = process.env[`CAPABILITY_REGISTRY_BASE_URL_${key}`];
94
+ if (explicit)
95
+ return trim(explicit);
96
+ if (environment) {
97
+ const suffixed = process.env[`CAPABILITY_REGISTRY_BASE_URL_${key}_${environment.toUpperCase()}`];
98
+ if (suffixed)
99
+ return trim(suffixed);
100
+ const mapped = environmentMap()[product]?.[environment];
101
+ if (mapped)
102
+ return trim(String(mapped));
103
+ // An environment was named and nothing defines its host. Falling back to discovery or
104
+ // to the declared production host here would send a preprod deployment at production,
105
+ // silently.
106
+ throw new InvocationError(`environment '${environment}' has no host for product '${product}'. Set ` +
107
+ `CAPABILITY_REGISTRY_BASE_URL_${key}_${environment.toUpperCase()} or add it to ` +
108
+ `CAPABILITY_REGISTRY_BASE_URLS.`);
109
+ }
110
+ /** A declared host is a better answer than none — but only after discovery has tried. */
111
+ const fallback = (error) => {
112
+ if (!source?.base_url)
113
+ throw error;
114
+ logger.warn("host discovery failed for product %s (%s); falling back to the declared host %s", product, error instanceof Error ? error.message : String(error), source.base_url);
115
+ return trim(source.base_url);
116
+ };
117
+ if ((source?.base_urls?.length ?? 0) > 0) {
118
+ try {
119
+ return await discoverBaseUrl(product, source, credentialsFrom(config), transport || fetchTransport());
120
+ }
121
+ catch (error) {
122
+ return fallback(error);
123
+ }
124
+ }
125
+ const builtin = BUILTIN_DISCOVERY[product];
126
+ if (builtin) {
127
+ try {
128
+ return trim(await builtin(config));
129
+ }
130
+ catch (error) {
131
+ return fallback(error);
132
+ }
133
+ }
134
+ if (source?.base_url)
135
+ return trim(source.base_url);
136
+ throw new InvocationError(`no host is configured for product '${product}': its index declares none and there ` +
137
+ `is no override. Set CAPABILITY_REGISTRY_BASE_URL_${key}.`);
138
+ }
139
+ /** The directory holding one subdirectory per product. */
140
+ const INDEX_ROOT = "capability";
141
+ function isDirectory(path) {
142
+ try {
143
+ return statSync(path).isDirectory();
144
+ }
145
+ catch {
146
+ return false;
147
+ }
148
+ }
149
+ /**
150
+ * Every product index under one directory, sorted so load order is stable.
151
+ *
152
+ * `<product>.capability-index.json` is the stored layout — the export publishes that exact
153
+ * filename, so an artifact drops in unrenamed. `<product>/index.json` is also accepted, so
154
+ * a checkout using the earlier nested layout keeps working.
155
+ */
156
+ function filesIn(directory) {
157
+ if (!isDirectory(directory))
158
+ return [];
159
+ const found = [];
160
+ for (const entry of readdirSync(directory).sort()) {
161
+ const path = join(directory, entry);
162
+ if (entry.endsWith(FLAT_SUFFIX)) {
163
+ found.push(path);
164
+ }
165
+ else if (isDirectory(path) && existsSync(join(path, INDEX_FILE))) {
166
+ found.push(join(path, INDEX_FILE));
167
+ }
168
+ }
169
+ return found;
170
+ }
171
+ /**
172
+ * Locate the artifact(s).
173
+ *
174
+ * ONE FILE PER PRODUCT, so this returns a list. Explicit env wins; otherwise look for a
175
+ * `capability/` directory beside the compiled module and at the package root, then fall
176
+ * back to a single `capability-index.json` for the pre-release layout. The package-root
177
+ * candidates exist because `tsc` compiles TS and does not copy JSON into `dist`.
178
+ *
179
+ * Returns an empty list when nothing is found — the caller logs that and registers no
180
+ * tools, rather than throwing.
181
+ */
182
+ export function indexPaths() {
183
+ const configuredFile = process.env.CAPABILITY_REGISTRY_INDEX;
184
+ if (configuredFile) {
185
+ return existsSync(configuredFile) ? [resolve(configuredFile)] : [];
186
+ }
187
+ const configuredDir = process.env.CAPABILITY_REGISTRY_INDEX_DIR;
188
+ if (configuredDir)
189
+ return filesIn(resolve(configuredDir));
190
+ const here = dirname(fileURLToPath(import.meta.url));
191
+ const roots = [
192
+ here,
193
+ join(here, "..", "..", ".."), // dist/ or src/ -> package root
194
+ join(here, "..", "..", "..", ".."),
195
+ ];
196
+ for (const root of roots) {
197
+ // `capabilities` (plural) is the layout this shipped with before the move to
198
+ // `capability/<product>/index.json`; still read so an existing checkout keeps working.
199
+ for (const name of [INDEX_ROOT, "capabilities"]) {
200
+ const found = filesIn(join(root, name));
201
+ if (found.length > 0)
202
+ return found;
203
+ }
204
+ const loose = filesIn(root);
205
+ if (loose.length > 0)
206
+ return loose;
207
+ }
208
+ for (const root of roots) {
209
+ const legacy = join(root, "capability-index.json");
210
+ if (existsSync(legacy))
211
+ return [legacy];
212
+ }
213
+ return [];
214
+ }
215
+ /** Off by default is wrong for a shipped feature, but a kill switch is not. */
216
+ export function isEnabled() {
217
+ return ((process.env.CAPABILITY_REGISTRY_DISABLED || "").toLowerCase() !== "true");
218
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Which regional host does THIS account live on?
3
+ *
4
+ * Generalised from `lib/tm-base-url.ts`, which asks the question for Test Management by
5
+ * walking test-management{,-eu,-in}.browserstack.com and keeping the one that authenticates.
6
+ * The mechanism is not tm-specific — any product sharded by region answers the same way —
7
+ * so the candidate hosts move into the product's own index (`base_urls`) and this module
8
+ * performs the walk for all of them.
9
+ *
10
+ * A PROBE, NOT A LOOKUP. An account's data lives in exactly one region and the other regions
11
+ * reject its credentials, so the right host identifies itself. There is no mapping table to
12
+ * keep in sync, which is the point: a table would go stale silently.
13
+ */
14
+ import { Credentials, Transport } from "./egress.js";
15
+ import { Capability, ProductIndex } from "./types.js";
16
+ /** What the resolver needs from a product's index to find its host. */
17
+ export type HostSource = Pick<ProductIndex, "base_url" | "base_urls" | "probe_path" | "auth"> & {
18
+ capabilities?: Capability[];
19
+ };
20
+ /** Exposed for tests; a long-lived process must not pin a stale region forever. */
21
+ export declare function clearDiscoveryCache(): void;
22
+ /**
23
+ * Pick the endpoint to probe with.
24
+ *
25
+ * An explicit `probe_path` in the index wins. Otherwise derive one, and derive it
26
+ * CONSERVATIVELY — the probe reads a 2xx as "this is the account's region", so an endpoint
27
+ * that can fail for a reason unrelated to region would walk straight past the right host:
28
+ *
29
+ * * reads only, and never with a path placeholder — there is no id to supply yet;
30
+ * * no required query parameters, for the same reason;
31
+ * * paginated, because a paged listing is a primary collection by construction;
32
+ * * nothing under /admin, which 403s for an ordinary user;
33
+ * * shortest path, to prefer the root collection over its variants.
34
+ *
35
+ * For tm this lands on `/api/v1/projects`, the same family as the hand-written probe.
36
+ */
37
+ export declare function probePath(source: HostSource): string | undefined;
38
+ /**
39
+ * Return the first candidate host that accepts the caller's credentials.
40
+ *
41
+ * Probed with the SAME auth the real calls use (`Api-Token`), not a second scheme, so a
42
+ * host that answers here is one that will answer for the invocation that follows.
43
+ */
44
+ export declare function discoverBaseUrl(product: string, source: HostSource, credentials: Credentials, transport: Transport): Promise<string>;