@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
@@ -0,0 +1,99 @@
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 appConfig from "../../config.js";
15
+ import logger from "../../logger.js";
16
+ import { authHeaders } from "./egress.js";
17
+ import { InvocationError } from "./index-loader.js";
18
+ /**
19
+ * Cached per product AND per user, so the answer cannot cross accounts.
20
+ *
21
+ * `lib/tm-base-url.ts` disables its cache entirely under REMOTE_MCP because a process-wide
22
+ * single slot would serve the first user's region to everyone after them. Keying by user
23
+ * fixes that by construction, so the cache stays useful in remote mode too.
24
+ */
25
+ const cache = new Map();
26
+ /** Exposed for tests; a long-lived process must not pin a stale region forever. */
27
+ export function clearDiscoveryCache() {
28
+ cache.clear();
29
+ }
30
+ /**
31
+ * Pick the endpoint to probe with.
32
+ *
33
+ * An explicit `probe_path` in the index wins. Otherwise derive one, and derive it
34
+ * CONSERVATIVELY — the probe reads a 2xx as "this is the account's region", so an endpoint
35
+ * that can fail for a reason unrelated to region would walk straight past the right host:
36
+ *
37
+ * * reads only, and never with a path placeholder — there is no id to supply yet;
38
+ * * no required query parameters, for the same reason;
39
+ * * paginated, because a paged listing is a primary collection by construction;
40
+ * * nothing under /admin, which 403s for an ordinary user;
41
+ * * shortest path, to prefer the root collection over its variants.
42
+ *
43
+ * For tm this lands on `/api/v1/projects`, the same family as the hand-written probe.
44
+ */
45
+ export function probePath(source) {
46
+ if (source.probe_path)
47
+ return source.probe_path;
48
+ const usable = (source.capabilities || []).filter((capability) => capability.mode === "read" &&
49
+ capability.paginated &&
50
+ !capability.path.includes("{") &&
51
+ !capability.path.includes("/admin") &&
52
+ !(capability.query || []).some((param) => param.required));
53
+ usable.sort((a, b) => a.path.length - b.path.length || a.path.localeCompare(b.path));
54
+ return usable[0]?.path;
55
+ }
56
+ /**
57
+ * Return the first candidate host that accepts the caller's credentials.
58
+ *
59
+ * Probed with the SAME auth the real calls use (`Api-Token`), not a second scheme, so a
60
+ * host that answers here is one that will answer for the invocation that follows.
61
+ */
62
+ export async function discoverBaseUrl(product, source, credentials, transport) {
63
+ const candidates = (source.base_urls || []).map((url) => url.replace(/\/$/, ""));
64
+ if (candidates.length === 0) {
65
+ throw new InvocationError(`product '${product}' declares no base_urls to probe`);
66
+ }
67
+ // One candidate is not a region question; skip the round trip.
68
+ if (candidates.length === 1)
69
+ return candidates[0];
70
+ const key = `${product}\n${credentials.username}`;
71
+ const cached = cache.get(key);
72
+ if (cached) {
73
+ logger.debug("using cached %s host for this account: %s", product, cached);
74
+ return cached;
75
+ }
76
+ const path = probePath(source);
77
+ if (!path) {
78
+ throw new InvocationError(`product '${product}' declares several base_urls but no endpoint to probe them with; ` +
79
+ `set probe_path in its index`);
80
+ }
81
+ // The same scheme the invocation will use, so a host that answers here answers there.
82
+ const headers = authHeaders(credentials, source.auth);
83
+ const failures = [];
84
+ for (const candidate of candidates) {
85
+ const response = await transport("GET", `${candidate}${path}`, headers, {});
86
+ if (response.status >= 200 && response.status < 300) {
87
+ // Under REMOTE_MCP the key already carries the user, so this is safe to keep.
88
+ if (!appConfig.REMOTE_MCP || credentials.username)
89
+ cache.set(key, candidate);
90
+ logger.info("resolved %s to %s for this account", product, candidate);
91
+ return candidate;
92
+ }
93
+ failures.push(`${candidate}: HTTP ${response.status || "unreachable"}`);
94
+ }
95
+ // Every region refused. Saying which, and with what, is the difference between a
96
+ // debuggable report and "it did not work".
97
+ throw new InvocationError(`could not determine which region this account's ${product} lives on. Probed ${path} ` +
98
+ `on each host — ${failures.join("; ")}`);
99
+ }
@@ -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,133 @@
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
+ /** product -> capability name -> capability. Empty for products that publish no names. */
50
+ private readonly byName;
51
+ constructor(index: RegistryIndex, provenance?: Record<string, Provenance>);
52
+ static fromFile(file: string): CapabilityRegistry;
53
+ /**
54
+ * Merge every discovered artifact into one registry.
55
+ *
56
+ * ANY unreadable file fails the whole load, deliberately. Skipping one and carrying on
57
+ * would leave a registry that answers "no such capability" for a product that exists —
58
+ * a confident wrong answer, which is worse than the caller-visible absence of the whole
59
+ * surface (which `register.ts` logs a reason for).
60
+ */
61
+ static fromFiles(files: string[]): CapabilityRegistry;
62
+ /** The single product's build id, or `name:id` pairs when several are loaded. */
63
+ get buildId(): string;
64
+ productNames(): string[];
65
+ /** Per-product `{build_id, version}`, for logging and cache-busting only. */
66
+ buildInfo(): Record<string, Provenance>;
67
+ /**
68
+ * Find a capability by its published name — the preferred handle.
69
+ *
70
+ * Names are unique within a product but not across products, so an ambiguous name is
71
+ * reported rather than resolved by load order. A name that exists in no index is
72
+ * `unknown_capability`: a distinct outcome from a name that exists elsewhere, because the
73
+ * caller's next move differs — search again, versus pass `product`.
74
+ */
75
+ byNameLookup(name: string, product?: string): {
76
+ product: string;
77
+ capability: Capability;
78
+ };
79
+ /**
80
+ * Find a capability by the endpoint it exposes — the handle for unnamed products.
81
+ *
82
+ * The endpoint is what searchCapability returns, so it is the only thing a caller can
83
+ * hold. `unknown_endpoint` is a defined outcome rather than a generic failure: a caller
84
+ * working from stale search output needs to know to search again, not to retry.
85
+ */
86
+ byEndpointLookup(method: string, path: string, product?: string): {
87
+ product: string;
88
+ capability: Capability;
89
+ };
90
+ }
91
+ /**
92
+ * Resolve a `{$response|$schema: "Name"}` reference against the product's lookup tables.
93
+ *
94
+ * ONE HOP. The named component it returns may itself contain references — 39 of tm's 53
95
+ * named responses do — so this is the primitive, not the whole job. Use `resolveResponses`
96
+ * to get a tree with nothing left to look up.
97
+ *
98
+ * A node that is not a reference is returned as-is, and an unresolvable name yields
99
+ * `undefined` rather than throwing: the tables are additive and their absence means "no
100
+ * response schema available".
101
+ */
102
+ export declare function resolveComponent<T extends ResponseDoc | SchemaNode>(product: ProductIndex, node: T | undefined): T | undefined;
103
+ /**
104
+ * Resolve every reference in a tree, however deep.
105
+ *
106
+ * REFERENCES ARE NESTED, which is the part a one-hop reader gets wrong. They appear at the
107
+ * top of a response (`{"$response": "BadRequest"}`), on its schema
108
+ * (`.../schema/{"$schema": "TestCaseListResponse"}`), and inside the schema's own
109
+ * properties (`.../schema/properties/data/properties/folder`). Chains are real too: a
110
+ * capability's 400 resolves to the named `BadRequest`, whose schema is `{"$schema":
111
+ * "ErrorResponse"}`.
112
+ *
113
+ * AN UNRESOLVABLE REFERENCE IS LEFT IN PLACE, not dropped and not thrown on. A dangling
114
+ * name (the tables are built separately from the capabilities) or a cycle (a folder whose
115
+ * schema contains folders) then shows up as the `{"$schema": "…"}` node it is, which a
116
+ * reader can still act on, rather than as a silently truncated schema.
117
+ */
118
+ export declare function resolveDeep<T>(product: ProductIndex, node: T): T;
119
+ /** Which declared responses to hand back. */
120
+ export type ResponseSelection = "success" | "all" | "none";
121
+ /**
122
+ * A capability's declared responses with every reference followed.
123
+ *
124
+ * SUCCESS ONLY BY DEFAULT. The error entries are near-identical across the surface — 148 of
125
+ * 173 capabilities declare the same `InternalServerError`, and all of them bottom out in
126
+ * one `ErrorResponse` schema — so including them multiplies a search payload by 6.4x to
127
+ * repeat boilerplate the caller learns from the actual failure anyway. The 2xx entry is the
128
+ * one that says what a successful call returns, which is what a caller needs BEFORE calling.
129
+ *
130
+ * Returns undefined when the capability declares none — which is every capability in an
131
+ * index built before the response tables were added, and is not an error.
132
+ */
133
+ export declare function resolveResponses(product: ProductIndex, capability: Capability, selection?: ResponseSelection): Record<string, ResponseDoc> | undefined;