@browserstack/mcp-server 1.4.0-beta.2 → 1.5.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 (44) hide show
  1. package/capability/loadtesting.capability-index.json +1754 -0
  2. package/capability/tm.capability-index.json +19793 -0
  3. package/dist/index.js +2 -5
  4. package/dist/server-factory.js +5 -5
  5. package/dist/tools/accessibility.js +2 -5
  6. package/dist/tools/capability-registry/bind.d.ts +29 -0
  7. package/dist/tools/capability-registry/bind.js +134 -0
  8. package/dist/tools/capability-registry/config.d.ts +62 -0
  9. package/dist/tools/capability-registry/config.js +218 -0
  10. package/dist/tools/capability-registry/discovery.d.ts +44 -0
  11. package/dist/tools/capability-registry/discovery.js +99 -0
  12. package/dist/tools/capability-registry/egress.d.ts +44 -0
  13. package/dist/tools/capability-registry/egress.js +128 -0
  14. package/dist/tools/capability-registry/index-loader.d.ts +119 -0
  15. package/dist/tools/capability-registry/index-loader.js +314 -0
  16. package/dist/tools/capability-registry/register.d.ts +34 -0
  17. package/dist/tools/capability-registry/register.js +354 -0
  18. package/dist/tools/capability-registry/resolve.d.ts +38 -0
  19. package/dist/tools/capability-registry/resolve.js +45 -0
  20. package/dist/tools/capability-registry/search.d.ts +65 -0
  21. package/dist/tools/capability-registry/search.js +342 -0
  22. package/dist/tools/capability-registry/types.d.ts +208 -0
  23. package/dist/tools/capability-registry/types.js +33 -0
  24. package/dist/tools/get-failure-logs.js +1 -3
  25. package/dist/tools/rca-agent.js +2 -5
  26. package/dist/tools/selfheal.js +2 -5
  27. package/dist/tools/testmanagement.js +15 -33
  28. package/package.json +3 -2
  29. package/dist/tools/ask-browserstack/central-oauth.d.ts +0 -114
  30. package/dist/tools/ask-browserstack/central-oauth.js +0 -271
  31. package/dist/tools/ask-browserstack/config.d.ts +0 -96
  32. package/dist/tools/ask-browserstack/config.js +0 -134
  33. package/dist/tools/ask-browserstack/egress.d.ts +0 -34
  34. package/dist/tools/ask-browserstack/egress.js +0 -31
  35. package/dist/tools/ask-browserstack/register.d.ts +0 -61
  36. package/dist/tools/ask-browserstack/register.js +0 -403
  37. package/dist/tools/ask-browserstack/relay.d.ts +0 -201
  38. package/dist/tools/ask-browserstack/relay.js +0 -577
  39. package/dist/tools/ask-browserstack/stream.d.ts +0 -116
  40. package/dist/tools/ask-browserstack/stream.js +0 -237
  41. package/dist/tools/ask-browserstack/types.d.ts +0 -196
  42. package/dist/tools/ask-browserstack/types.js +0 -10
  43. package/dist/tools/tool-handoff.d.ts +0 -37
  44. package/dist/tools/tool-handoff.js +0 -47
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The outbound call: auth, attribution, and one HTTP request.
3
+ *
4
+ * AUTH IS THE CALLER'S OWN CREDENTIALS, FORWARDED — never a token this server mints. How
5
+ * they are presented is the PRODUCT's to declare, in the OpenAPI `securityScheme` terms its
6
+ * own spec already uses. Absent a declaration the default is tm's: `Api-Token:
7
+ * <username>:<access_key>`, accepted by every /api/v1 route and validated against IAAM
8
+ * OAuth2 v2 — the same identity resolution a minted bearer token produces, one hop earlier.
9
+ * Verified in browserstack/teststack: the 59 v1 controllers inheriting
10
+ * ApplicationApiController resolve it in `current_user`, the 5 inheriting
11
+ * Api::V1::ApiController in `authenticate_token`.
12
+ *
13
+ * That default is right for tm and wrong to assume of everyone. Load Testing's
14
+ * /api/v1/agent/* surface is reported to take HTTP Basic, and before this there was nowhere
15
+ * to say so: the requirement lived in a pull request description while the server sent
16
+ * Api-Token regardless, and the mismatch surfaced as a 401 that reads like the user's
17
+ * credentials are wrong.
18
+ */
19
+ import { AuthScheme } from "./types.js";
20
+ export interface Credentials {
21
+ username: string;
22
+ accessKey: string;
23
+ }
24
+ export interface HttpResponse {
25
+ status: number;
26
+ body: unknown;
27
+ error?: string;
28
+ }
29
+ export type Transport = (method: string, url: string, headers: Record<string, string>, query: Record<string, unknown>, body?: unknown) => Promise<HttpResponse>;
30
+ /** The historical default, and what an index without an `auth` block still gets. */
31
+ export declare const DEFAULT_AUTH: AuthScheme;
32
+ /**
33
+ * Fill a credential template.
34
+ *
35
+ * An unknown placeholder is REFUSED, never passed through. Emitting `{user_id}` literally
36
+ * would send a header that looks well-formed and comes back 401 — indistinguishable from
37
+ * bad credentials, which is the failure this whole mechanism exists to remove. The harness
38
+ * really does carry templates this server cannot fill (`{user_id}_{group_id}`), so this is
39
+ * the common case, not a hypothetical.
40
+ */
41
+ export declare function renderTemplate(template: string, credentials: Credentials): string;
42
+ export declare function authHeaders(credentials: Credentials, auth?: AuthScheme): Record<string, string>;
43
+ /** A fetch-based transport. Redirects are NOT followed. */
44
+ export declare function fetchTransport(timeoutMs?: number): Transport;
@@ -0,0 +1,128 @@
1
+ /**
2
+ * The outbound call: auth, attribution, and one HTTP request.
3
+ *
4
+ * AUTH IS THE CALLER'S OWN CREDENTIALS, FORWARDED — never a token this server mints. How
5
+ * they are presented is the PRODUCT's to declare, in the OpenAPI `securityScheme` terms its
6
+ * own spec already uses. Absent a declaration the default is tm's: `Api-Token:
7
+ * <username>:<access_key>`, accepted by every /api/v1 route and validated against IAAM
8
+ * OAuth2 v2 — the same identity resolution a minted bearer token produces, one hop earlier.
9
+ * Verified in browserstack/teststack: the 59 v1 controllers inheriting
10
+ * ApplicationApiController resolve it in `current_user`, the 5 inheriting
11
+ * Api::V1::ApiController in `authenticate_token`.
12
+ *
13
+ * That default is right for tm and wrong to assume of everyone. Load Testing's
14
+ * /api/v1/agent/* surface is reported to take HTTP Basic, and before this there was nowhere
15
+ * to say so: the requirement lived in a pull request description while the server sent
16
+ * Api-Token regardless, and the mismatch surfaced as a 401 that reads like the user's
17
+ * credentials are wrong.
18
+ */
19
+ import { InvocationError } from "./index-loader.js";
20
+ /** What this server can put into a credential template. Nothing else is fillable. */
21
+ const PLACEHOLDERS = ["username", "access_key"];
22
+ /** The historical default, and what an index without an `auth` block still gets. */
23
+ export const DEFAULT_AUTH = {
24
+ type: "apiKey",
25
+ in: "header",
26
+ name: "Api-Token",
27
+ template: "{username}:{access_key}",
28
+ };
29
+ /**
30
+ * Fill a credential template.
31
+ *
32
+ * An unknown placeholder is REFUSED, never passed through. Emitting `{user_id}` literally
33
+ * would send a header that looks well-formed and comes back 401 — indistinguishable from
34
+ * bad credentials, which is the failure this whole mechanism exists to remove. The harness
35
+ * really does carry templates this server cannot fill (`{user_id}_{group_id}`), so this is
36
+ * the common case, not a hypothetical.
37
+ */
38
+ export function renderTemplate(template, credentials) {
39
+ const unknown = [...template.matchAll(/\{([a-z_]+)\}/g)]
40
+ .map((match) => match[1])
41
+ .filter((nameed) => !PLACEHOLDERS.includes(nameed));
42
+ if (unknown.length > 0) {
43
+ throw new InvocationError(`auth template uses placeholder(s) this server cannot fill: ` +
44
+ `${[...new Set(unknown)].sort().join(", ")}. Available: ` +
45
+ `${PLACEHOLDERS.map((placeholder) => `{${placeholder}}`).join(", ")}`);
46
+ }
47
+ return template
48
+ .replaceAll("{username}", credentials.username)
49
+ .replaceAll("{access_key}", credentials.accessKey);
50
+ }
51
+ export function authHeaders(credentials, auth = DEFAULT_AUTH) {
52
+ if (!credentials?.username || !credentials?.accessKey) {
53
+ // Refusing here beats sending unauthenticated and surfacing the product's 401, which
54
+ // reads like the user's problem when it is our missing configuration.
55
+ throw new InvocationError("this request is not authenticated: BrowserStack username and access key are required");
56
+ }
57
+ const common = {
58
+ // Attribution, so the downstream service can see the call came from an agent.
59
+ "request-source": "ai-chatbot",
60
+ "Content-Type": "application/json",
61
+ };
62
+ const value = renderTemplate(auth.template || DEFAULT_AUTH.template, credentials);
63
+ if (auth.type === "apiKey") {
64
+ // Header only. `cookie` needs a session this server does not have, and `query` would
65
+ // put the credential in a URL, where access logs and proxies keep it.
66
+ if (auth.in && auth.in !== "header") {
67
+ throw new InvocationError(`unsupported auth location '${auth.in}': this server can only send credentials ` +
68
+ `in a header`);
69
+ }
70
+ if (!auth.name) {
71
+ throw new InvocationError("apiKey auth declares no header name");
72
+ }
73
+ return { [auth.name]: value, ...common };
74
+ }
75
+ if (auth.type === "http" && (auth.scheme || "").toLowerCase() === "basic") {
76
+ return {
77
+ Authorization: `Basic ${Buffer.from(value).toString("base64")}`,
78
+ ...common,
79
+ };
80
+ }
81
+ // By name, and refusing: sending nothing would be a 401 the caller reads as their own
82
+ // fault, and guessing a scheme is how credentials end up somewhere they should not be.
83
+ throw new InvocationError(`unsupported auth scheme for this product: ` +
84
+ `${JSON.stringify({ type: auth.type, scheme: auth.scheme })}`);
85
+ }
86
+ /** A fetch-based transport. Redirects are NOT followed. */
87
+ export function fetchTransport(timeoutMs = 45_000) {
88
+ return async (method, url, headers, query, body) => {
89
+ const target = new URL(url);
90
+ for (const [key, value] of Object.entries(query || {})) {
91
+ if (value !== undefined && value !== null)
92
+ target.searchParams.set(key, String(value));
93
+ }
94
+ const controller = new AbortController();
95
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
96
+ try {
97
+ const response = await fetch(target.toString(), {
98
+ method,
99
+ headers,
100
+ // Only send a body when there IS one: a literal `null` payload with a JSON
101
+ // content-type is rejected by several endpoints.
102
+ body: body === undefined ? undefined : JSON.stringify(body),
103
+ // A redirect from an authenticated API is usually a login bounce, and following it
104
+ // turns a clear 401/302 into a 200 carrying an HTML sign-in page — which the
105
+ // resolver would then read as an empty result set rather than a failure.
106
+ redirect: "manual",
107
+ signal: controller.signal,
108
+ });
109
+ let parsed = null;
110
+ const contentType = response.headers.get("content-type") || "";
111
+ if (contentType.includes("json")) {
112
+ parsed = await response.json().catch(() => null);
113
+ }
114
+ return { status: response.status, body: parsed };
115
+ }
116
+ catch {
117
+ // Upstream detail stays out of the reply; the resolver treats status 0 as a failed call.
118
+ return {
119
+ status: 0,
120
+ body: null,
121
+ error: "the product could not be reached",
122
+ };
123
+ }
124
+ finally {
125
+ clearTimeout(timer);
126
+ }
127
+ };
128
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Load the index artifact(s) and expose the lookups the tools need.
3
+ *
4
+ * ONE FILE PER PRODUCT is the released contract, so loading is a merge across files. The
5
+ * merged model keeps the products map the rest of the server already reads, which is what
6
+ * lets searchCapability rank across products from N single-product files.
7
+ */
8
+ import { Capability, ProductIndex, Provenance, RegistryIndex, ResponseDoc, SchemaNode } from "./types.js";
9
+ export declare class IndexError extends Error {
10
+ }
11
+ /** Thrown to the caller as a tool error, so the wording is caller-facing. */
12
+ export declare class InvocationError extends Error {
13
+ }
14
+ /** The file inside a product subdirectory, for the earlier nested layout. */
15
+ export declare const INDEX_FILE = "index.json";
16
+ /** The stored layout, and the name the export publishes: `<product>.capability-index.json`. */
17
+ export declare const FLAT_SUFFIX = ".capability-index.json";
18
+ /**
19
+ * The product a file's LOCATION claims to describe, if its layout says so.
20
+ *
21
+ * `capability/tm.capability-index.json` and `capability/tm/index.json` both name their
22
+ * product, and
23
+ * `fromFiles` cross-checks that against the product key inside. A file in the wrong place
24
+ * would otherwise register its product under the directory's name and answer for endpoints
25
+ * it does not have.
26
+ */
27
+ export declare function productFromPath(file: string): string | undefined;
28
+ export declare function endpointKey(method: string, path: string): string;
29
+ export interface LoadedProduct {
30
+ name: string;
31
+ product: ProductIndex;
32
+ provenance: Provenance;
33
+ }
34
+ /**
35
+ * Read one artifact file into the product it carries.
36
+ *
37
+ * SHAPE DETECTION, NOT VERSION DETECTION. The released envelope and the pre-release one
38
+ * both declare `schema_version: 1`, so the number cannot tell them apart; the `products`
39
+ * wrapper can, and is the documented discriminator. The pre-release branch is defensive
40
+ * only — that shape was never deployed.
41
+ */
42
+ export declare function readIndexFile(raw: unknown, source?: string): LoadedProduct;
43
+ export declare class CapabilityRegistry {
44
+ readonly index: RegistryIndex;
45
+ /** product -> provenance of the file it came from. */
46
+ readonly provenance: Record<string, Provenance>;
47
+ /** product -> "METHOD /path" -> capability */
48
+ private readonly byEndpoint;
49
+ constructor(index: RegistryIndex, provenance?: Record<string, Provenance>);
50
+ static fromFile(file: string): CapabilityRegistry;
51
+ /**
52
+ * Merge every discovered artifact into one registry.
53
+ *
54
+ * ANY unreadable file fails the whole load, deliberately. Skipping one and carrying on
55
+ * would leave a registry that answers "no such capability" for a product that exists —
56
+ * a confident wrong answer, which is worse than the caller-visible absence of the whole
57
+ * surface (which `register.ts` logs a reason for).
58
+ */
59
+ static fromFiles(files: string[]): CapabilityRegistry;
60
+ /** The single product's build id, or `name:id` pairs when several are loaded. */
61
+ get buildId(): string;
62
+ productNames(): string[];
63
+ /** Per-product `{build_id, version}`, for logging and cache-busting only. */
64
+ buildInfo(): Record<string, Provenance>;
65
+ /**
66
+ * Find a capability by the endpoint it exposes — the published handle.
67
+ *
68
+ * The endpoint is what searchCapability returns, so it is the only thing a caller can
69
+ * hold. `unknown_endpoint` is a defined outcome rather than a generic failure: a caller
70
+ * working from stale search output needs to know to search again, not to retry.
71
+ */
72
+ byEndpointLookup(method: string, path: string, product?: string): {
73
+ product: string;
74
+ capability: Capability;
75
+ };
76
+ }
77
+ /**
78
+ * Resolve a `{$response|$schema: "Name"}` reference against the product's lookup tables.
79
+ *
80
+ * ONE HOP. The named component it returns may itself contain references — 39 of tm's 53
81
+ * named responses do — so this is the primitive, not the whole job. Use `resolveResponses`
82
+ * to get a tree with nothing left to look up.
83
+ *
84
+ * A node that is not a reference is returned as-is, and an unresolvable name yields
85
+ * `undefined` rather than throwing: the tables are additive and their absence means "no
86
+ * response schema available".
87
+ */
88
+ export declare function resolveComponent<T extends ResponseDoc | SchemaNode>(product: ProductIndex, node: T | undefined): T | undefined;
89
+ /**
90
+ * Resolve every reference in a tree, however deep.
91
+ *
92
+ * REFERENCES ARE NESTED, which is the part a one-hop reader gets wrong. They appear at the
93
+ * top of a response (`{"$response": "BadRequest"}`), on its schema
94
+ * (`.../schema/{"$schema": "TestCaseListResponse"}`), and inside the schema's own
95
+ * properties (`.../schema/properties/data/properties/folder`). Chains are real too: a
96
+ * capability's 400 resolves to the named `BadRequest`, whose schema is `{"$schema":
97
+ * "ErrorResponse"}`.
98
+ *
99
+ * AN UNRESOLVABLE REFERENCE IS LEFT IN PLACE, not dropped and not thrown on. A dangling
100
+ * name (the tables are built separately from the capabilities) or a cycle (a folder whose
101
+ * schema contains folders) then shows up as the `{"$schema": "…"}` node it is, which a
102
+ * reader can still act on, rather than as a silently truncated schema.
103
+ */
104
+ export declare function resolveDeep<T>(product: ProductIndex, node: T): T;
105
+ /** Which declared responses to hand back. */
106
+ export type ResponseSelection = "success" | "all" | "none";
107
+ /**
108
+ * A capability's declared responses with every reference followed.
109
+ *
110
+ * SUCCESS ONLY BY DEFAULT. The error entries are near-identical across the surface — 148 of
111
+ * 173 capabilities declare the same `InternalServerError`, and all of them bottom out in
112
+ * one `ErrorResponse` schema — so including them multiplies a search payload by 6.4x to
113
+ * repeat boilerplate the caller learns from the actual failure anyway. The 2xx entry is the
114
+ * one that says what a successful call returns, which is what a caller needs BEFORE calling.
115
+ *
116
+ * Returns undefined when the capability declares none — which is every capability in an
117
+ * index built before the response tables were added, and is not an error.
118
+ */
119
+ export declare function resolveResponses(product: ProductIndex, capability: Capability, selection?: ResponseSelection): Record<string, ResponseDoc> | undefined;
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Load the index artifact(s) and expose the lookups the tools need.
3
+ *
4
+ * ONE FILE PER PRODUCT is the released contract, so loading is a merge across files. The
5
+ * merged model keeps the products map the rest of the server already reads, which is what
6
+ * lets searchCapability rank across products from N single-product files.
7
+ */
8
+ import { readFileSync } from "node:fs";
9
+ import { basename, dirname } from "node:path";
10
+ import { ENVELOPE_KEYS, SUPPORTED_SCHEMA_VERSION, } from "./types.js";
11
+ export class IndexError extends Error {
12
+ }
13
+ /** Thrown to the caller as a tool error, so the wording is caller-facing. */
14
+ export class InvocationError extends Error {
15
+ }
16
+ /** The file inside a product subdirectory, for the earlier nested layout. */
17
+ export const INDEX_FILE = "index.json";
18
+ /** The stored layout, and the name the export publishes: `<product>.capability-index.json`. */
19
+ export const FLAT_SUFFIX = ".capability-index.json";
20
+ /**
21
+ * The product a file's LOCATION claims to describe, if its layout says so.
22
+ *
23
+ * `capability/tm.capability-index.json` and `capability/tm/index.json` both name their
24
+ * product, and
25
+ * `fromFiles` cross-checks that against the product key inside. A file in the wrong place
26
+ * would otherwise register its product under the directory's name and answer for endpoints
27
+ * it does not have.
28
+ */
29
+ export function productFromPath(file) {
30
+ const base = basename(file);
31
+ if (base.endsWith(FLAT_SUFFIX))
32
+ return base.slice(0, -FLAT_SUFFIX.length);
33
+ if (base === INDEX_FILE)
34
+ return basename(dirname(file));
35
+ return undefined;
36
+ }
37
+ export function endpointKey(method, path) {
38
+ return `${(method || "").trim().toUpperCase()} ${(path || "").trim()}`;
39
+ }
40
+ function isPlainObject(value) {
41
+ return typeof value === "object" && value !== null && !Array.isArray(value);
42
+ }
43
+ /**
44
+ * Read one artifact file into the product it carries.
45
+ *
46
+ * SHAPE DETECTION, NOT VERSION DETECTION. The released envelope and the pre-release one
47
+ * both declare `schema_version: 1`, so the number cannot tell them apart; the `products`
48
+ * wrapper can, and is the documented discriminator. The pre-release branch is defensive
49
+ * only — that shape was never deployed.
50
+ */
51
+ export function readIndexFile(raw, source = "index") {
52
+ if (!isPlainObject(raw)) {
53
+ throw new IndexError(`${source}: expected a JSON object`);
54
+ }
55
+ if (raw.schema_version !== SUPPORTED_SCHEMA_VERSION) {
56
+ // Refuse rather than best-effort read: a shape change the generator announced is
57
+ // exactly the case where guessing produces silently wrong tool output.
58
+ throw new IndexError(`${source}: unsupported index schema_version ${raw.schema_version}; this build reads ` +
59
+ `${SUPPORTED_SCHEMA_VERSION}. Rebuild the artifact or update the server.`);
60
+ }
61
+ const provenance = {
62
+ build_id: typeof raw.build_id === "string" ? raw.build_id : "",
63
+ ...(typeof raw.version === "string" ? { version: raw.version } : {}),
64
+ };
65
+ if (isPlainObject(raw.products)) {
66
+ // Pre-release shape. Never deployed; recognised so a stray file reads rather than
67
+ // failing in a way that looks like a corrupt artifact.
68
+ const entries = Object.entries(raw.products);
69
+ if (entries.length === 0)
70
+ throw new IndexError(`${source}: contains no products`);
71
+ if (entries.length > 1) {
72
+ throw new IndexError(`${source}: carries ${entries.length} products; one file describes one product`);
73
+ }
74
+ const [name, product] = entries[0];
75
+ return {
76
+ name,
77
+ product: asProduct(product, `${source}:${name}`),
78
+ provenance,
79
+ };
80
+ }
81
+ // Released shape: exactly one key that is not part of the envelope.
82
+ const envelope = new Set(ENVELOPE_KEYS);
83
+ const entries = Object.entries(raw).filter(([key, value]) => !envelope.has(key) && isPlainObject(value));
84
+ if (entries.length === 0) {
85
+ throw new IndexError(`${source}: no product object found; expected one top-level key besides ` +
86
+ `${[...envelope].join(", ")}`);
87
+ }
88
+ if (entries.length > 1) {
89
+ throw new IndexError(`${source}: found ${entries.length} candidate product keys ` +
90
+ `(${entries
91
+ .map(([key]) => key)
92
+ .sort()
93
+ .join(", ")}); one file describes one product`);
94
+ }
95
+ const [name, product] = entries[0];
96
+ return { name, product: asProduct(product, `${source}:${name}`), provenance };
97
+ }
98
+ function asProduct(value, source) {
99
+ if (!isPlainObject(value) || !Array.isArray(value.capabilities)) {
100
+ throw new IndexError(`${source}: product object has no capabilities[]`);
101
+ }
102
+ return value;
103
+ }
104
+ export class CapabilityRegistry {
105
+ index;
106
+ /** product -> provenance of the file it came from. */
107
+ provenance;
108
+ /** product -> "METHOD /path" -> capability */
109
+ byEndpoint = new Map();
110
+ constructor(index, provenance = {}) {
111
+ if (index?.schema_version !== SUPPORTED_SCHEMA_VERSION) {
112
+ throw new IndexError(`unsupported index schema_version ${index?.schema_version}; this build reads ` +
113
+ `${SUPPORTED_SCHEMA_VERSION}. Rebuild the artifact or update the server.`);
114
+ }
115
+ if (!index.products || Object.keys(index.products).length === 0) {
116
+ throw new IndexError("index contains no products");
117
+ }
118
+ this.index = index;
119
+ this.provenance = provenance;
120
+ for (const [product, bundle] of Object.entries(index.products)) {
121
+ const lookup = new Map();
122
+ for (const capability of bundle.capabilities) {
123
+ lookup.set(endpointKey(capability.method, capability.path), capability);
124
+ }
125
+ this.byEndpoint.set(product, lookup);
126
+ }
127
+ }
128
+ static fromFile(file) {
129
+ return CapabilityRegistry.fromFiles([file]);
130
+ }
131
+ /**
132
+ * Merge every discovered artifact into one registry.
133
+ *
134
+ * ANY unreadable file fails the whole load, deliberately. Skipping one and carrying on
135
+ * would leave a registry that answers "no such capability" for a product that exists —
136
+ * a confident wrong answer, which is worse than the caller-visible absence of the whole
137
+ * surface (which `register.ts` logs a reason for).
138
+ */
139
+ static fromFiles(files) {
140
+ if (files.length === 0)
141
+ throw new IndexError("no index files to load");
142
+ const products = {};
143
+ const provenance = {};
144
+ for (const file of files) {
145
+ let parsed;
146
+ try {
147
+ parsed = JSON.parse(readFileSync(file, "utf8"));
148
+ }
149
+ catch (error) {
150
+ throw new IndexError(`${file}: could not be read as JSON (${error instanceof Error ? error.message : String(error)})`);
151
+ }
152
+ const loaded = readIndexFile(parsed, file);
153
+ const claimed = productFromPath(file);
154
+ if (claimed && claimed !== loaded.name) {
155
+ // The name is stated twice — by the path and by the key inside — and they must
156
+ // agree. Trusting either one alone would serve a product's endpoints under the
157
+ // other's name, and every search result would then point at the wrong host.
158
+ throw new IndexError(`${file}: is stored as product '${claimed}' but declares '${loaded.name}'`);
159
+ }
160
+ if (products[loaded.name]) {
161
+ // Two files claiming one product cannot both be right, and picking one silently
162
+ // decides which endpoints exist.
163
+ throw new IndexError(`product '${loaded.name}' is declared by more than one index file; ${file} is a duplicate`);
164
+ }
165
+ products[loaded.name] = loaded.product;
166
+ provenance[loaded.name] = loaded.provenance;
167
+ }
168
+ return new CapabilityRegistry({
169
+ schema_version: SUPPORTED_SCHEMA_VERSION,
170
+ build_id: compositeBuildId(provenance),
171
+ products,
172
+ }, provenance);
173
+ }
174
+ /** The single product's build id, or `name:id` pairs when several are loaded. */
175
+ get buildId() {
176
+ return this.index.build_id;
177
+ }
178
+ productNames() {
179
+ return Object.keys(this.index.products).sort();
180
+ }
181
+ /** Per-product `{build_id, version}`, for logging and cache-busting only. */
182
+ buildInfo() {
183
+ return this.provenance;
184
+ }
185
+ /**
186
+ * Find a capability by the endpoint it exposes — the published handle.
187
+ *
188
+ * The endpoint is what searchCapability returns, so it is the only thing a caller can
189
+ * hold. `unknown_endpoint` is a defined outcome rather than a generic failure: a caller
190
+ * working from stale search output needs to know to search again, not to retry.
191
+ */
192
+ byEndpointLookup(method, path, product) {
193
+ const key = endpointKey(method, path);
194
+ const matches = [];
195
+ for (const [name, lookup] of this.byEndpoint) {
196
+ if (product && name !== product)
197
+ continue;
198
+ const capability = lookup.get(key);
199
+ if (capability)
200
+ matches.push({ product: name, capability });
201
+ }
202
+ if (matches.length === 0) {
203
+ throw new InvocationError(`unknown_endpoint: ${key}. Search again — send \`method\` and \`path\` exactly as ` +
204
+ `searchCapability returned them, placeholders included.`);
205
+ }
206
+ if (matches.length > 1 && !product) {
207
+ const owners = matches
208
+ .map((m) => m.product)
209
+ .sort()
210
+ .join(", ");
211
+ throw new InvocationError(`${key} exists in several products (${owners}); pass product`);
212
+ }
213
+ return matches[0];
214
+ }
215
+ }
216
+ function compositeBuildId(provenance) {
217
+ const names = Object.keys(provenance).sort();
218
+ if (names.length === 1)
219
+ return provenance[names[0]].build_id;
220
+ return names.map((name) => `${name}:${provenance[name].build_id}`).join(" ");
221
+ }
222
+ /**
223
+ * Resolve a `{$response|$schema: "Name"}` reference against the product's lookup tables.
224
+ *
225
+ * ONE HOP. The named component it returns may itself contain references — 39 of tm's 53
226
+ * named responses do — so this is the primitive, not the whole job. Use `resolveResponses`
227
+ * to get a tree with nothing left to look up.
228
+ *
229
+ * A node that is not a reference is returned as-is, and an unresolvable name yields
230
+ * `undefined` rather than throwing: the tables are additive and their absence means "no
231
+ * response schema available".
232
+ */
233
+ export function resolveComponent(product, node) {
234
+ if (!node || typeof node !== "object")
235
+ return node;
236
+ const ref = node;
237
+ if (typeof ref.$response === "string") {
238
+ return product.responses?.[ref.$response];
239
+ }
240
+ if (typeof ref.$schema === "string") {
241
+ return product.schemas?.[ref.$schema];
242
+ }
243
+ return node;
244
+ }
245
+ /**
246
+ * Resolve every reference in a tree, however deep.
247
+ *
248
+ * REFERENCES ARE NESTED, which is the part a one-hop reader gets wrong. They appear at the
249
+ * top of a response (`{"$response": "BadRequest"}`), on its schema
250
+ * (`.../schema/{"$schema": "TestCaseListResponse"}`), and inside the schema's own
251
+ * properties (`.../schema/properties/data/properties/folder`). Chains are real too: a
252
+ * capability's 400 resolves to the named `BadRequest`, whose schema is `{"$schema":
253
+ * "ErrorResponse"}`.
254
+ *
255
+ * AN UNRESOLVABLE REFERENCE IS LEFT IN PLACE, not dropped and not thrown on. A dangling
256
+ * name (the tables are built separately from the capabilities) or a cycle (a folder whose
257
+ * schema contains folders) then shows up as the `{"$schema": "…"}` node it is, which a
258
+ * reader can still act on, rather than as a silently truncated schema.
259
+ */
260
+ export function resolveDeep(product, node) {
261
+ return resolveNode(product, node, new Set());
262
+ }
263
+ function resolveNode(product, node, seen) {
264
+ if (Array.isArray(node)) {
265
+ return node.map((item) => resolveNode(product, item, seen));
266
+ }
267
+ if (typeof node !== "object" || node === null)
268
+ return node;
269
+ const ref = node;
270
+ const kind = typeof ref.$response === "string"
271
+ ? "$response"
272
+ : typeof ref.$schema === "string"
273
+ ? "$schema"
274
+ : undefined;
275
+ if (kind) {
276
+ const name = (kind === "$response" ? ref.$response : ref.$schema);
277
+ const key = `${kind}:${name}`;
278
+ const target = kind === "$response"
279
+ ? product.responses?.[name]
280
+ : product.schemas?.[name];
281
+ // Leave the reference visible when it cannot be followed, or when following it would
282
+ // revisit a name already on this path.
283
+ if (!target || seen.has(key))
284
+ return node;
285
+ return resolveNode(product, target, new Set([...seen, key]));
286
+ }
287
+ const out = {};
288
+ for (const [field, value] of Object.entries(node)) {
289
+ out[field] = resolveNode(product, value, seen);
290
+ }
291
+ return out;
292
+ }
293
+ /**
294
+ * A capability's declared responses with every reference followed.
295
+ *
296
+ * SUCCESS ONLY BY DEFAULT. The error entries are near-identical across the surface — 148 of
297
+ * 173 capabilities declare the same `InternalServerError`, and all of them bottom out in
298
+ * one `ErrorResponse` schema — so including them multiplies a search payload by 6.4x to
299
+ * repeat boilerplate the caller learns from the actual failure anyway. The 2xx entry is the
300
+ * one that says what a successful call returns, which is what a caller needs BEFORE calling.
301
+ *
302
+ * Returns undefined when the capability declares none — which is every capability in an
303
+ * index built before the response tables were added, and is not an error.
304
+ */
305
+ export function resolveResponses(product, capability, selection = "success") {
306
+ if (selection === "none" || !capability.responses)
307
+ return undefined;
308
+ const wanted = selection === "all"
309
+ ? Object.entries(capability.responses)
310
+ : Object.entries(capability.responses).filter(([status]) => status.startsWith("2"));
311
+ if (wanted.length === 0)
312
+ return undefined;
313
+ return resolveDeep(product, Object.fromEntries(wanted));
314
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The tool surface: four discovery tools plus ONE invoke tool.
3
+ *
4
+ * ONE invoke tool means one set of MCP annotations, so they describe the whole surface
5
+ * honestly: it can write (not read-only) and it can never delete, because destructive
6
+ * endpoints are refused before binding. Write consent therefore rests on `user_permission`
7
+ * enforced HERE rather than on a client-side hint — which is the one thing a separate
8
+ * read/write tool pair was buying.
9
+ */
10
+ import { McpServer, RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js";
11
+ import { BrowserStackConfig } from "../../lib/types.js";
12
+ import { Credentials, Transport } from "./egress.js";
13
+ import { CapabilityRegistry } from "./index-loader.js";
14
+ export declare const PERMISSION_VALUES: readonly ["not_asked", "granted", "denied"];
15
+ export interface RegistryDeps {
16
+ registry: CapabilityRegistry;
17
+ /**
18
+ * Per-product base URL. Never baked into the artifact — it is environment AND account
19
+ * specific: tm is region-sharded, so this is resolved per call, not once at startup.
20
+ */
21
+ baseUrlFor: (product: string) => Promise<string>;
22
+ credentialsFor: () => Credentials;
23
+ transport?: Transport;
24
+ }
25
+ /**
26
+ * The tool-adder the server factory calls.
27
+ *
28
+ * Registers NOTHING when the artifact is absent or unreadable, rather than throwing: a
29
+ * missing index is a packaging problem, and taking the whole MCP server down with it would
30
+ * remove every other product's tools too. The reason is logged so it is not silent.
31
+ */
32
+ export declare function addCapabilityRegistryToolsFromConfig(server: McpServer, config: BrowserStackConfig): Record<string, RegisteredTool>;
33
+ export declare function addCapabilityRegistryTools(server: McpServer, deps: RegistryDeps, config?: BrowserStackConfig): Record<string, RegisteredTool>;
34
+ export default addCapabilityRegistryToolsFromConfig;