@browserstack/mcp-server 2.2.3 → 2.3.0-beta.2

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.
@@ -6,12 +6,23 @@
6
6
  * lets searchCapability rank across products from N single-product files.
7
7
  */
8
8
  import { readFileSync } from "node:fs";
9
+ import appConfig from "../../config.js";
9
10
  import { basename, dirname } from "node:path";
10
11
  import { ENVELOPE_KEYS, SUPPORTED_SCHEMA_VERSION, } from "./types.js";
11
12
  export class IndexError extends Error {
12
13
  }
13
14
  /** Thrown to the caller as a tool error, so the wording is caller-facing. */
14
15
  export class InvocationError extends Error {
16
+ /**
17
+ * Set when the handle exists in the artifact but this surface withholds it.
18
+ *
19
+ * The MESSAGE deliberately does not say so — a caller is told the capability does not
20
+ * exist, because "withheld" invites a retry, a search for synonyms, or a question to the
21
+ * user about enabling it, none of which can succeed here. The flag keeps the distinction
22
+ * for telemetry, where knowing how often a withheld handle is reached for is how we learn
23
+ * whether withholding it was the right call.
24
+ */
25
+ withheld;
15
26
  }
16
27
  /** The file inside a product subdirectory, for the earlier nested layout. */
17
28
  export const INDEX_FILE = "index.json";
@@ -43,9 +54,59 @@ export function endpointKey(method, path) {
43
54
  * FAILS CLOSED: any truthy `disabled` hides it, not just `=== true`. The flag exists to
44
55
  * suppress something that cannot work, so an unexpected shape must hide it rather than be
45
56
  * ignored — the opposite default would turn a typo into a silently re-enabled capability.
57
+ *
58
+ * `local_only` withholds on a remote server only, for a capability that reads the caller's
59
+ * own machine: there, the path it would take resolves against the operator's disk instead.
60
+ * Read at call time rather than captured, because the remote server builds its config per
61
+ * session and a value captured at module load would outlive the deployment it described.
46
62
  */
47
63
  export function isDisabled(capability) {
48
- return Boolean(capability.disabled);
64
+ if (capability.disabled)
65
+ return true;
66
+ return Boolean(capability.local_only) && Boolean(appConfig.REMOTE_MCP);
67
+ }
68
+ /**
69
+ * Is this capability published for reference only on this surface?
70
+ *
71
+ * `local_only` on a remote server. The capability is real and the caller may well be asking
72
+ * for exactly it, so hiding it leaves the agent concluding no such route exists. Publishing
73
+ * it without the means to call it, and with a note saying what to do instead, answers the
74
+ * question it was asked.
75
+ */
76
+ export function isReferenceOnly(capability) {
77
+ if (capability.disabled)
78
+ return false;
79
+ return Boolean(capability.local_only) && Boolean(appConfig.REMOTE_MCP);
80
+ }
81
+ /**
82
+ * The reference form: what it is and what to do instead, and nothing to call it with.
83
+ *
84
+ * The parameters and response shapes go because publishing them invites the attempt — a
85
+ * model handed a body schema will fill it in. `intent` stays, because that is what the
86
+ * search matched on, and `mode` stays so a reader can still see what kind of operation it
87
+ * would have been.
88
+ */
89
+ function asReference(capability) {
90
+ const reference = { ...capability };
91
+ for (const field of [
92
+ "path_params",
93
+ "query",
94
+ "body",
95
+ "responses",
96
+ "returns",
97
+ "paginated",
98
+ "max_page_size",
99
+ "encoding",
100
+ ]) {
101
+ delete reference[field];
102
+ }
103
+ return {
104
+ ...reference,
105
+ invocable: false,
106
+ guidance: capability.unavailable_note
107
+ ? [capability.unavailable_note]
108
+ : ["This capability cannot be called on this server."],
109
+ };
49
110
  }
50
111
  function isPlainObject(value) {
51
112
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -181,6 +242,16 @@ export class CapabilityRegistry {
181
242
  // between a disabled name and a live one is still a broken artifact, and skipping
182
243
  // the disabled half would mean re-enabling the flag could fail the load later,
183
244
  // somewhere with less context than here.
245
+ if (isReferenceOnly(capability)) {
246
+ // PUBLISHED, BUT NOT CALLABLE. It keeps its name and intent so the search that
247
+ // should find it does, and loses everything a caller would need to invoke it.
248
+ const reference = asReference(capability);
249
+ enabled.push(reference);
250
+ lookup.set(endpointKey(capability.method, capability.path), reference);
251
+ if (capability.name)
252
+ names.set(capability.name, reference);
253
+ continue;
254
+ }
184
255
  if (isDisabled(capability)) {
185
256
  withheld.set(endpointKey(capability.method, capability.path), capability);
186
257
  if (capability.name)
@@ -219,26 +290,25 @@ export class CapabilityRegistry {
219
290
  this.index = { ...index, products };
220
291
  }
221
292
  /**
222
- * The `capability_disabled` message for a handle this build withholds, if it is one.
293
+ * Does this build withhold the handle?
223
294
  *
224
- * Says THAT it is disabled and nothing about why. The reason is documentation and lives
225
- * with the decision; repeating it here would put four copies of a paragraph in the
226
- * shipped artifact to serve a path the flag itself makes rare.
295
+ * WHAT THE CALLER IS TOLD IS THAT IT DOES NOT EXIST, which is why this returns a boolean
296
+ * rather than a message. Saying "withheld" describes the artifact rather than the
297
+ * surface, and every action it invites — retry, hunt for a synonym, ask the user to turn
298
+ * it on — fails here. A capability absent from search, describe and every listing should
299
+ * read the same way at invocation as it does everywhere else.
227
300
  *
228
- * Returns undefined for anything else, so the caller falls through to its own
229
- * not-found wording.
301
+ * The caller raises its own not-found error and marks it `withheld`, so the distinction
302
+ * survives in telemetry without reaching the agent.
230
303
  */
231
- withheldMatch(handle, product) {
304
+ isWithheld(handle, product) {
232
305
  for (const [owner, withheld] of this.withheld) {
233
306
  if (product && owner !== product)
234
307
  continue;
235
- if (!withheld.has(handle))
236
- continue;
237
- return (`capability_disabled: ${handle} is disabled on this surface. This is ` +
238
- `deliberate — it is withheld by the ${owner} index, not missing from it — so ` +
239
- `retrying will not help.`);
308
+ if (withheld.has(handle))
309
+ return true;
240
310
  }
241
- return undefined;
311
+ return false;
242
312
  }
243
313
  /** How many capabilities each product withholds. Startup logging only. */
244
314
  disabledCounts() {
@@ -326,13 +396,13 @@ export class CapabilityRegistry {
326
396
  matches.push({ product: owner, capability });
327
397
  }
328
398
  if (matches.length === 0) {
329
- // WITHHELD IS A DIFFERENT ANSWER FROM ABSENT, and only here. Search cannot surface a
330
- // disabled capability, so reaching this branch means the caller already held the
331
- // exact handle — from an older index, a doc, or a human. "unknown_capability" would
332
- // send it hunting for a synonym that does not exist; the reason ends the attempt.
333
- const off = this.withheldMatch(name, product);
334
- if (off)
335
- throw new InvocationError(off);
399
+ // A WITHHELD HANDLE ANSWERS THE SAME WAY AS AN ABSENT ONE. Search, describe and the
400
+ // listings already behave as though it does not exist, and invocation is no place to
401
+ // contradict them: "withheld" is a fact about the artifact, not about this surface,
402
+ // and it invites a retry, a hunt for a synonym, or a question to the user about
403
+ // enabling something that cannot be enabled here. The error carries `withheld` so the
404
+ // attempt is still countable in telemetry.
405
+ const withheldHere = this.isWithheld(name, product);
336
406
  // Say whether names are published at all for the product asked about: "no such name"
337
407
  // and "this product does not name its capabilities" need different fixes.
338
408
  const unnamed = [...this.byName]
@@ -342,7 +412,10 @@ export class CapabilityRegistry {
342
412
  ? ` ${unnamed.sort().join(", ")} ${unnamed.length > 1 ? "publish" : "publishes"} ` +
343
413
  `no capability names yet — call those by \`method\` and \`path\` instead.`
344
414
  : " Search again — names come from searchCapability and describeEntity.";
345
- throw new InvocationError(`unknown_capability: ${name}.${hint}`);
415
+ const error = new InvocationError(`unknown_capability: ${name}.${hint}`);
416
+ if (withheldHere)
417
+ error.withheld = true;
418
+ throw error;
346
419
  }
347
420
  if (matches.length > 1 && !product) {
348
421
  const owners = matches
@@ -371,11 +444,11 @@ export class CapabilityRegistry {
371
444
  matches.push({ product: name, capability });
372
445
  }
373
446
  if (matches.length === 0) {
374
- const off = this.withheldMatch(key, product);
375
- if (off)
376
- throw new InvocationError(off);
377
- throw new InvocationError(`unknown_endpoint: ${key}. Search again — send \`method\` and \`path\` exactly as ` +
447
+ const error = new InvocationError(`unknown_endpoint: ${key}. Search again — send \`method\` and \`path\` exactly as ` +
378
448
  `searchCapability returned them, placeholders included.`);
449
+ if (this.isWithheld(key, product))
450
+ error.withheld = true;
451
+ throw error;
379
452
  }
380
453
  if (matches.length > 1 && !product) {
381
454
  const owners = matches
@@ -32,7 +32,7 @@ export interface RegistryDeps {
32
32
  * Per-product base URL. Never baked into the artifact — it is environment AND account
33
33
  * specific: tm is region-sharded, so this is resolved per call, not once at startup.
34
34
  */
35
- baseUrlFor: (product: string) => Promise<string>;
35
+ baseUrlFor: (product: string, host?: string) => Promise<string>;
36
36
  credentialsFor: () => Credentials;
37
37
  transport?: Transport;
38
38
  }
@@ -81,7 +81,7 @@ export function addCapabilityRegistryToolsFromConfig(server, config) {
81
81
  registry,
82
82
  // The whole product bundle, not just its host: a region-sharded product declares
83
83
  // several candidates and the probe needs an endpoint from its own capabilities.
84
- baseUrlFor: (product) => resolveBaseUrl(product, config, registry.index.products[product]),
84
+ baseUrlFor: (product, host) => resolveBaseUrl(product, config, registry.index.products[product], undefined, host),
85
85
  // Read per call, not captured: the remote server rebuilds config per session, so a
86
86
  // captured credential would outlive the session it belongs to.
87
87
  credentialsFor: () => ({
@@ -147,7 +147,12 @@ export function addCapabilityRegistryTools(server, deps, config) {
147
147
  * below pin each branch against the message its source actually produces; if you reword
148
148
  * a refusal, one of them will tell you.
149
149
  */
150
- const invocationReason = (message) => {
150
+ const invocationReason = (message, error) => {
151
+ // A WITHHELD HANDLE IS REPORTED TO THE CALLER AS NOT FOUND, so the message cannot be
152
+ // what distinguishes it. The flag on the error can, which keeps the refusal countable
153
+ // without the wording admitting the capability exists.
154
+ if (error instanceof InvocationError && error.withheld)
155
+ return "capability_disabled";
151
156
  if (message.startsWith("unknown_capability:"))
152
157
  return "unknown_capability";
153
158
  if (message.startsWith("unknown_endpoint:"))
@@ -812,7 +817,7 @@ export function addCapabilityRegistryTools(server, deps, config) {
812
817
  }
813
818
  catch (error) {
814
819
  if (error instanceof InvocationError)
815
- return refuse("describeCapability", invocationReason(error.message), error.message, undefined, identity);
820
+ return refuse("describeCapability", invocationReason(error.message, error), error.message, undefined, identity);
816
821
  logger.error("describeCapability failed: %s", error instanceof Error ? error.message : String(error));
817
822
  return refuse("describeCapability", "unexpected_error", "that capability could not be described", undefined, identity);
818
823
  }
@@ -910,6 +915,17 @@ export function addCapabilityRegistryTools(server, deps, config) {
910
915
  query: input.query,
911
916
  body: input.body,
912
917
  };
918
+ if (capability.invocable === false) {
919
+ // THE ONE PLACE describe AND invoke DIVERGE, deliberately. Everywhere else a name
920
+ // that describes is a name that invokes, and breaking that is its own bug class —
921
+ // but a reference-only capability exists precisely to be read and not called. The
922
+ // refusal carries the instruction, because the note is the whole point of it
923
+ // still being published.
924
+ return refuse("invokeCapability", "capability_not_invocable", `${handle} cannot be called on this server. ` +
925
+ (capability.guidance?.[0] ??
926
+ "Describe it for what to do instead.") +
927
+ " Follow that instead of retrying.", undefined, identity);
928
+ }
913
929
  if (capability.mode === "destructive") {
914
930
  // Refused before binding, so consent is never sought for something that cannot run.
915
931
  return refuse("invokeCapability", "destructive_blocked", `${handle} is a destructive operation and is not available through this surface`, undefined, identity);
@@ -932,7 +948,7 @@ export function addCapabilityRegistryTools(server, deps, config) {
932
948
  return refuse("invokeCapability", "change_summary_missing", "change_summary is required: state what will change", undefined, identity);
933
949
  }
934
950
  }
935
- const result = await invoke(capability, args, await deps.baseUrlFor(product), deps.credentialsFor(), transport, registry.index.products[product]?.auth, product);
951
+ const result = await invoke(capability, args, await deps.baseUrlFor(product, capability.host), deps.credentialsFor(), transport, registry.index.products[product]?.auth, product);
936
952
  // The one row for a completed invoke. invoke() returns a 4xx/5xx rather than
937
953
  // throwing, so `success` keeps its tool-level meaning and these two fields carry
938
954
  // whether the product call worked. Status 0 means it could not be reached.
@@ -946,7 +962,7 @@ export function addCapabilityRegistryTools(server, deps, config) {
946
962
  }
947
963
  catch (error) {
948
964
  if (error instanceof InvocationError)
949
- return refuse("invokeCapability", invocationReason(error.message), error.message, undefined, identity);
965
+ return refuse("invokeCapability", invocationReason(error.message, error), error.message, undefined, identity);
950
966
  logger.error("invokeCapability failed: %s", error instanceof Error ? error.message : String(error));
951
967
  return refuse("invokeCapability", "unexpected_error", "that capability could not be invoked", undefined, identity);
952
968
  }
@@ -16,6 +16,8 @@ import { invalidateToken } from "../../lib/central-oauth.js";
16
16
  import { bind } from "./bind.js";
17
17
  import { authHeaders } from "./egress.js";
18
18
  import { resolveScope, resolveTokenUrl } from "./oauth.js";
19
+ import { openAsBlob } from "node:fs";
20
+ import { basename } from "node:path";
19
21
  import { InvocationError } from "./index-loader.js";
20
22
  function hasNextPage(body) {
21
23
  if (typeof body !== "object" || body === null || Array.isArray(body))
@@ -63,6 +65,30 @@ function assertTransportSafe(baseUrl) {
63
65
  `carries your BrowserStack access key, so the base URL must be https (or a local ` +
64
66
  `host for development). Check CAPABILITY_REGISTRY_BASE_URL_* for a typo.`);
65
67
  }
68
+ /**
69
+ * Turn a bound body into a FormData, streaming any `file` parameter from disk.
70
+ *
71
+ * `bind` has already canonicalised and checked the path, so what arrives here is a file
72
+ * this server is configured to read. The blob is opened rather than buffered: an app build
73
+ * runs to gigabytes and reading one into memory to post it is how a server falls over.
74
+ */
75
+ async function multipartBody(capability, body) {
76
+ const form = new FormData();
77
+ for (const param of capability.body || []) {
78
+ const value = body?.[param.name];
79
+ if (value === undefined || value === null)
80
+ continue;
81
+ if (param.type === "file") {
82
+ const path = String(value);
83
+ const blob = await openAsBlob(path);
84
+ form.append(param.name, blob, basename(path));
85
+ }
86
+ else {
87
+ form.append(param.name, String(value));
88
+ }
89
+ }
90
+ return form;
91
+ }
66
92
  export async function invoke(capability, args, baseUrl, credentials, transport, auth, product) {
67
93
  if (!baseUrl)
68
94
  throw new InvocationError("no base URL is configured for that product");
@@ -71,7 +97,22 @@ export async function invoke(capability, args, baseUrl, credentials, transport,
71
97
  // Before binding would be wrong and after sending is too late: a scheme that mints a
72
98
  // token must not spend a round trip on a call that `bind` was going to reject anyway.
73
99
  const url = `${baseUrl.replace(/\/$/, "")}${bound.path}`;
74
- const send = async () => transport(capability.method, url, await authHeaders(credentials, auth, product), bound.query, bound.body);
100
+ const multipart = capability.encoding === "multipart"
101
+ ? await multipartBody(capability, bound.body)
102
+ : undefined;
103
+ const send = async () => {
104
+ const headers = await authHeaders(credentials, auth, product);
105
+ if (multipart) {
106
+ // fetch writes its own Content-Type with the boundary. Leaving the JSON one on sends
107
+ // a body the product cannot parse, under a header that says it should be able to.
108
+ delete headers["Content-Type"];
109
+ }
110
+ const payload = multipart ??
111
+ (bound.body === undefined && capability.send_empty_body
112
+ ? {}
113
+ : bound.body);
114
+ return transport(capability.method, url, headers, bound.query, payload);
115
+ };
75
116
  let response = await send();
76
117
  /**
77
118
  * One replay, for a minted token the product rejected.
@@ -116,6 +116,23 @@ export interface Capability {
116
116
  name?: string;
117
117
  method: string;
118
118
  path: string;
119
+ /**
120
+ * Which of the product's named `hosts` serves this one capability, when it is not the
121
+ * product's own `base_url`.
122
+ *
123
+ * A product is normally one host. App Automate is not: the account-level API (devices,
124
+ * app builds, plan, session records) answers on one origin and the Appium hub that
125
+ * drives the device answers on another, and neither serves the other's paths. Splitting
126
+ * them into two products would split one entity too — the session a hub capability
127
+ * creates is the session the account-level API reports on — and a caller holding the id
128
+ * would have to know it had changed products to use it.
129
+ *
130
+ * Unset means the product's own host, so every existing index is unaffected. A name that
131
+ * `hosts` does not define is refused by name at invocation rather than quietly falling
132
+ * back, because the fallback would send the call to a host that cannot serve the path and
133
+ * return a 404 that reads like the caller's mistake.
134
+ */
135
+ host?: string;
119
136
  mode: Mode;
120
137
  entity: string;
121
138
  path_params?: WireParam[];
@@ -155,6 +172,63 @@ export interface Capability {
155
172
  * suppression must not be ignored for arriving in an unexpected shape.
156
173
  */
157
174
  disabled?: boolean;
175
+ /**
176
+ * Withheld when the server is not running on the caller's own machine.
177
+ *
178
+ * Some capabilities only mean anything when the person and the process share a computer:
179
+ * one that reads a file the caller names is the clear case. On a remote server that path
180
+ * resolves against the OPERATOR's disk, so honouring it would read one user's files on
181
+ * another's behalf — the capability does not become unreliable there, it becomes wrong.
182
+ *
183
+ * Distinct from `disabled`, which withholds a capability everywhere. This one is a claim
184
+ * about where it can run, so the same artifact serves both deployments: a local server
185
+ * publishes it and a remote one does not.
186
+ *
187
+ * FAILS CLOSED in the same way, and takes the same `capability_disabled` path at
188
+ * invocation, so a caller holding the handle is told the absence is deliberate rather
189
+ * than hunting for a synonym.
190
+ */
191
+ local_only?: boolean;
192
+ /**
193
+ * What to do instead, on a surface where this capability cannot run.
194
+ *
195
+ * A `local_only` capability is NOT hidden on a remote server: the query that should find
196
+ * it still finds it, because an agent asked to upload a file from the user's machine
197
+ * needs to learn that the route exists and what replaces it here. What it loses is the
198
+ * means to call it — the parameters and response shapes are stripped at load, and
199
+ * invocation is refused — so the note is the whole of what remains.
200
+ *
201
+ * Write it as the instruction the caller should follow, not as an apology.
202
+ */
203
+ unavailable_note?: string;
204
+ /**
205
+ * FALSE when this build publishes the capability for reference but will not call it.
206
+ *
207
+ * Set by the loader, never authored: it is the surface's answer, not the artifact's.
208
+ */
209
+ invocable?: boolean;
210
+ /**
211
+ * How the request body is encoded. Absent means JSON, which is every capability today.
212
+ *
213
+ * `multipart` exists for endpoints that take the bytes of a file rather than a reference
214
+ * to one. A capability declaring it must also declare a `file` parameter, and should be
215
+ * `local_only`: the path names the caller's own machine.
216
+ */
217
+ encoding?: "multipart";
218
+ /**
219
+ * Send `{}` rather than no body at all, for an endpoint that takes no parameters.
220
+ *
221
+ * Normally a capability with nothing to send sends nothing, which is right for a REST API
222
+ * and wrong for W3C WebDriver: Chrome answers 400 "invalid argument" to a parameterless
223
+ * POST with the body omitted and 200 to the same call carrying `{}`. Appium tolerates the
224
+ * omission, which is why the identical capability needs this on Automate and not on App
225
+ * Automate.
226
+ *
227
+ * Opt-in per capability rather than a rule for every bodyless POST, because 27 of those
228
+ * exist across the shipped indexes and changing what reaches their products is not
229
+ * something this can verify.
230
+ */
231
+ send_empty_body?: boolean;
158
232
  paginated?: boolean;
159
233
  /** The largest page the operation declares. */
160
234
  max_page_size?: number;
@@ -313,6 +387,18 @@ export interface ProductIndex {
313
387
  * (see `discovery.probePath`); declare it when the derived choice would be wrong.
314
388
  */
315
389
  probe_path?: string;
390
+ /**
391
+ * Additional hosts this product is served from, by name, for capabilities that declare
392
+ * `host`.
393
+ *
394
+ * DISTINCT FROM `base_urls`, which is a list of CANDIDATES for one host — probed in turn
395
+ * so a region-sharded product lands on the account's own region. These are several hosts
396
+ * that all serve the product at once, each addressing a different part of it, and the
397
+ * capability says which it needs rather than the server discovering it.
398
+ *
399
+ * `base_url` remains the default for every capability that names no host.
400
+ */
401
+ hosts?: Record<string, string>;
316
402
  capabilities: Capability[];
317
403
  entities: Record<string, EntityDoc>;
318
404
  paging?: Record<string, PagingRule>;
@@ -1,6 +1,6 @@
1
1
  import { apiClient } from "../../lib/apiClient.js";
2
2
  import { z } from "zod";
3
- import { errorText } from "../../lib/error.js";
3
+ import { formatAxiosError } from "../../lib/error.js";
4
4
  import { fetchFormFields, normalizeDefaultFields as normalizeDefaultFieldsFromForm, projectIdentifierToId, } from "./TCG-utils/api.js";
5
5
  import { getTMBaseURL } from "../../lib/tm-base-url.js";
6
6
  import { getBrowserStackAuth } from "../../lib/get-auth.js";
@@ -218,14 +218,12 @@ Error details: ${JSON.stringify(err.response?.data || err.message)}`,
218
218
  isError: true,
219
219
  };
220
220
  }
221
- const status = err.response?.status;
222
- const detail = errorText(err, "unknown error");
223
- const hint = status === 401 ? " Check the BrowserStack credentials in use." : "";
221
+ const errorMessage = formatAxiosError(err, "Failed to update test case");
224
222
  return {
225
223
  content: [
226
224
  {
227
225
  type: "text",
228
- text: `Failed to update test case${status ? ` (HTTP ${status})` : ""}: ${detail}.${hint}`,
226
+ text: `Failed to update test case: ${errorMessage}. Please verify your credentials and try again.`,
229
227
  },
230
228
  ],
231
229
  isError: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@browserstack/mcp-server",
3
- "version": "2.2.3",
3
+ "version": "2.3.0-beta.2",
4
4
  "description": "BrowserStack's Official MCP Server",
5
5
  "mcpName": "io.github.browserstack/mcp-server",
6
6
  "main": "dist/index.js",