@browserstack/mcp-server 2.2.2-beta.2 → 2.3.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 (36) hide show
  1. package/capability/appautomate.capability-index.json +3233 -0
  2. package/capability/automate.capability-index.json +2068 -0
  3. package/dist/tools/accessibility.js +3 -2
  4. package/dist/tools/appautomate-utils/appium-sdk/constants.d.ts +1 -1
  5. package/dist/tools/appautomate-utils/appium-sdk/constants.js +3 -3
  6. package/dist/tools/appautomate-utils/native-execution/constants.d.ts +1 -1
  7. package/dist/tools/appautomate-utils/native-execution/constants.js +20 -4
  8. package/dist/tools/appautomate.js +3 -3
  9. package/dist/tools/applive.js +4 -4
  10. package/dist/tools/ask-browserstack/register.js +13 -6
  11. package/dist/tools/capability-registry/bind.js +56 -0
  12. package/dist/tools/capability-registry/config.d.ts +1 -25
  13. package/dist/tools/capability-registry/config.js +40 -1
  14. package/dist/tools/capability-registry/discovery.d.ts +1 -1
  15. package/dist/tools/capability-registry/egress.js +21 -3
  16. package/dist/tools/capability-registry/index-loader.d.ts +33 -7
  17. package/dist/tools/capability-registry/index-loader.js +99 -26
  18. package/dist/tools/capability-registry/register.d.ts +1 -1
  19. package/dist/tools/capability-registry/register.js +111 -19
  20. package/dist/tools/capability-registry/resolve.js +42 -1
  21. package/dist/tools/capability-registry/types.d.ts +86 -0
  22. package/dist/tools/get-failure-logs.js +1 -1
  23. package/dist/tools/live.js +2 -2
  24. package/dist/tools/rca-agent-utils/constants.js +2 -2
  25. package/dist/tools/rca-agent.js +1 -1
  26. package/dist/tools/sdk-utils/common/constants.d.ts +1 -1
  27. package/dist/tools/sdk-utils/common/constants.js +1 -1
  28. package/dist/tools/sdk-utils/common/schema.js +1 -1
  29. package/dist/tools/selfheal.js +16 -2
  30. package/dist/tools/testmanagement-utils/create-testcase.js +3 -3
  31. package/dist/tools/testmanagement-utils/list-testcases.js +1 -1
  32. package/dist/tools/testmanagement-utils/update-testcase.js +3 -3
  33. package/dist/tools/testmanagement.js +16 -12
  34. package/dist/tools/tool-handoff.d.ts +2 -2
  35. package/dist/tools/tool-handoff.js +16 -10
  36. package/package.json +1 -1
@@ -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:"))
@@ -189,11 +194,24 @@ export function addCapabilityRegistryTools(server, deps, config) {
189
194
  }
190
195
  return failed(message, extra);
191
196
  };
192
- tools.listProducts = server.tool("listProducts", "List BrowserStack products and their entities. START HERE: searchCapability needs a product name from this. Pass the user's request as `query`; if it reports a clash, ask the user which product they mean and wait. Never pick for them or merge products.", {
197
+ tools.listProducts = server.tool("listProducts", "List the BrowserStack products this surface can reach: what each one does, the " +
198
+ "entities it models, and a line saying what each entity is. START HERE — " +
199
+ "searchCapability needs a product, and this is what tells you which one. If two " +
200
+ "products could both fit the task, ask the user rather than choosing for them. " +
201
+ "ALWAYS pass the user's request, in their own words, as `query`: the answer then " +
202
+ "says outright whether the products clash over the words in it. WHEN IT REPORTS A " +
203
+ "CLASH — two products claiming the same word for different things — ASK THE USER " +
204
+ "WHICH PRODUCT THEY MEAN AND WAIT FOR THEIR ANSWER BEFORE SEARCHING OR INVOKING " +
205
+ "ANYTHING. The response carries the question to put to them and what the shared " +
206
+ "word means in each product. Choosing for them, or searching each product in turn " +
207
+ "and merging the results, is the failure this call exists to prevent.", {
193
208
  query: z
194
209
  .string()
195
210
  .optional()
196
- .describe("The user's request, in their own words; enables clash detection."),
211
+ .describe("The user's request, in their own words. With it the response carries a " +
212
+ "`routing` block saying whether the products share the vocabulary this " +
213
+ "request uses — and, when they do, the question to put to the user before " +
214
+ "searching either product."),
197
215
  }, {
198
216
  title: "List Capability Products",
199
217
  readOnlyHint: true,
@@ -336,7 +354,9 @@ export function addCapabilityRegistryTools(server, deps, config) {
336
354
  // each. That is the right trade while routing is the problem it solves (you cannot
337
355
  // choose between products by looking at one of them), but past a dozen products it may
338
356
  // need a names-only default with aliases on request.
339
- tools.describeEntity = server.tool("describeEntity", "Describe an entity: identifiers, relations and product vocabulary. Read before filtering or writing; ids often need resolving first.", {
357
+ tools.describeEntity = server.tool("describeEntity", "Describe one entity: what it is, what identifies it, what it relates to, and the " +
358
+ "vocabulary the product uses for it. Read this before filtering or writing, because " +
359
+ "ids and field values usually have to be resolved first.", {
340
360
  product: productArg().describe(`Which product the entity belongs to: ${productList}.`),
341
361
  entity: z.string().describe("Entity name, as listProducts returns it."),
342
362
  }, {
@@ -356,10 +376,38 @@ export function addCapabilityRegistryTools(server, deps, config) {
356
376
  }
357
377
  return ok({ product, entity, ...doc });
358
378
  });
359
- tools.searchCapability = server.tool("searchCapability", "Find capabilities in ONE product by plain language. CALL listProducts FIRST; `product` must be spelled exactly as listProducts spells it. If `weak_match` is true, search again with `suggested_vocabulary` before invoking. Results are a shortlist: call describeCapability for the contract, then invokeCapability. query='*' lists all; page with `next_offset`.", {
379
+ tools.searchCapability = server.tool("searchCapability", "Find endpoints this surface can call, by plain language, within ONE product. " +
380
+ "CALL listProducts FIRST, passing the user's request as its `query`: it names the " +
381
+ "products, says whether they clash over the words in that request, and gives you " +
382
+ "the question to ask when they do. You need it anyway — `product` here takes a name " +
383
+ "exactly as listProducts spells it. Where two products use the same word for " +
384
+ "different things, ask the USER and wait for an answer rather than picking. " +
385
+ "Search matches the product's OWN words, not synonyms. When your words are not the " +
386
+ "product's, the response says so: `weak_match: true` with a `suggested_vocabulary` " +
387
+ "map of the product's entities and their aliases. Results are still returned, but " +
388
+ "treat them as unconfirmed — pick the closest entity from that map and search again " +
389
+ "using its vocabulary, or call describeEntity on it for the fuller picture. That one " +
390
+ "extra round trip is far cheaper than invoking the wrong capability. " +
391
+ "Narrowing further with `entity` sharpens results. " +
392
+ "THIS IS A SHORTLIST, NOT A CONTRACT. Each result carries only what you need to " +
393
+ "CHOOSE: `name` (the handle), `product`, `mode` (whether it writes), `intent` and " +
394
+ "`guidance` (what it does and what goes wrong), and `method`/`path` for products " +
395
+ "that publish no name yet. " +
396
+ "It does NOT carry parameters or response shapes. Once you have picked one, call " +
397
+ "describeCapability for its full contract, then invokeCapability. Fetching the " +
398
+ "contract only for the one you chose is the difference between ~1k and ~8.6k tokens " +
399
+ "a search. Results are ranked and capped. " +
400
+ "TO SEE EVERYTHING rather than search, pass query='*' — that lists the product " +
401
+ "(honouring `entity` and `mode`) in a stable order instead of ranking it, which is " +
402
+ "what you want when the task is 'what can this product do' rather than a lookup. " +
403
+ "TO PAGE, send `offset`: every response carries `offset` and, when more matched, " +
404
+ "`next_offset` — resend the SAME query with that value. `next_offset` is absent on " +
405
+ "the last page, so walk until it stops rather than comparing counts.", {
360
406
  query: z
361
407
  .string()
362
- .describe("Goal in plain language, or '*' to list every capability."),
408
+ .describe("What you are trying to do, in plain language. Pass '*' (or 'all') to BROWSE " +
409
+ "instead of search: every capability in the product, in a stable order, " +
410
+ "paginated — use it when you want to see the surface rather than find one thing."),
363
411
  entity: z
364
412
  .string()
365
413
  .optional()
@@ -387,7 +435,9 @@ export function addCapabilityRegistryTools(server, deps, config) {
387
435
  // refused here instead, by a message that names listProducts.
388
436
  product: z
389
437
  .string()
390
- .describe("Product name, exactly as listProducts spells it."),
438
+ .describe("Which product to search, named exactly as listProducts spells it. Call " +
439
+ "listProducts with the user's request to get the name — and to find out " +
440
+ "whether the products clash over it, in which case ask the user first."),
391
441
  // `product_choice` USED TO LIVE HERE, AND WAS ITS OWN DEFEAT.
392
442
  //
393
443
  // A required enum of 'user_confirmed' | 'not_asked' publishes, in the schema every
@@ -400,11 +450,18 @@ export function addCapabilityRegistryTools(server, deps, config) {
400
450
  routing_token: z
401
451
  .string()
402
452
  .optional()
403
- .describe("Latest `routing_token` from listProducts or a clash refusal; send with `user_words` after a clash."),
453
+ .describe("The `routing_token` from the last listProducts call or product_ambiguous " +
454
+ "refusal — it carries what that step concluded about the user's request, " +
455
+ "signed. Always pass the most recent one. When it reports a clash, send the " +
456
+ "user's answer in `user_words` alongside it; neither works alone."),
404
457
  user_words: z
405
458
  .string()
406
459
  .optional()
407
- .describe("The user's own words, verbatim, naming the product or a term only it uses. Never paraphrase."),
460
+ .describe("The USER'S OWN words, verbatim, that settle which product this is — either " +
461
+ "the product's name or a term only one product uses ('quality gate', 'test " +
462
+ "plan'). Quote them; do not paraphrase, and do not write words the user did " +
463
+ "not say. When a query could mean either product this is what lifts the " +
464
+ "refusal, because it is checked against the index rather than taken on trust."),
408
465
  mode: z
409
466
  .enum(["read", "write", "destructive"])
410
467
  .optional()
@@ -413,7 +470,8 @@ export function addCapabilityRegistryTools(server, deps, config) {
413
470
  offset: z
414
471
  .number()
415
472
  .optional()
416
- .describe("`next_offset` from the previous page, with the same query."),
473
+ .describe("Skip this many results — send the `next_offset` from the previous response " +
474
+ "with the SAME query to get the next page. Omit for the first page."),
417
475
  }, {
418
476
  title: "Search Capabilities",
419
477
  readOnlyHint: true,
@@ -670,7 +728,16 @@ export function addCapabilityRegistryTools(server, deps, config) {
670
728
  : {}),
671
729
  });
672
730
  });
673
- tools.describeCapability = server.tool("describeCapability", "Full contract (parameters, returns, response shapes) for ONE capability from searchCapability. Call it before invokeCapability. Identify by `name`; use `method` + `path` only when no name was returned.", {
731
+ tools.describeCapability = server.tool("describeCapability", "The full contract for ONE capability you picked from searchCapability: its " +
732
+ "parameters grouped into path_params / query / body under the spec's own names, " +
733
+ "what it returns, and its declared response shapes fully expanded. Call this after " +
734
+ "search and before invokeCapability — search deliberately omits all of it, because " +
735
+ "it is only needed for the one capability you actually call. " +
736
+ "Identify it by `name`, exactly as search returned it; only when a result carries " +
737
+ "no `name` (some products publish none yet) pass `method` and `path` instead. " +
738
+ "Every parameter lists its type, whether it is required, its allowed values where " +
739
+ "the set is closed, and any limits the product declares — obey those before calling " +
740
+ "rather than discovering them from a rejected request.", {
674
741
  name: z
675
742
  .string()
676
743
  .optional()
@@ -685,11 +752,14 @@ export function addCapabilityRegistryTools(server, deps, config) {
685
752
  .describe("Path with {placeholders} intact — only for capabilities returned without a `name`."),
686
753
  product: productArg()
687
754
  .optional()
688
- .describe("Owning product; needed only when names or paths collide."),
755
+ .describe(`Which product owns it (${productList}). searchCapability returns it on every ` +
756
+ "result; required only when two products share a name or a path."),
689
757
  include_responses: z
690
758
  .enum(["success", "all", "none"])
691
759
  .optional()
692
- .describe("'success' (default), 'all' (adds error shapes, larger) or 'none'."),
760
+ .describe("Which declared responses to expand: 'success' (default, the 2xx shape), 'all' " +
761
+ "(adds the error shapes — several times larger, and near-identical across " +
762
+ "endpoints), or 'none'."),
693
763
  }, {
694
764
  title: "Describe Capability",
695
765
  readOnlyHint: true,
@@ -747,12 +817,22 @@ export function addCapabilityRegistryTools(server, deps, config) {
747
817
  }
748
818
  catch (error) {
749
819
  if (error instanceof InvocationError)
750
- return refuse("describeCapability", invocationReason(error.message), error.message, undefined, identity);
820
+ return refuse("describeCapability", invocationReason(error.message, error), error.message, undefined, identity);
751
821
  logger.error("describeCapability failed: %s", error instanceof Error ? error.message : String(error));
752
822
  return refuse("describeCapability", "unexpected_error", "that capability could not be described", undefined, identity);
753
823
  }
754
824
  });
755
- tools.invokeCapability = server.tool("invokeCapability", "Run a capability after describeCapability, by `name` (or `method` + `path` if unnamed); args go in path_params / query / body. If `completed` is false, fetch the next page. Writes: ask the user first, then resend with user_permission='granted' and a change_summary. Deletes are refused; archive, close and merge are writes.", {
825
+ tools.invokeCapability = server.tool("invokeCapability", "Call a capability whose contract you have from describeCapability. Pass `name` exactly " +
826
+ "as given — that is the handle. Only when a result carries no `name` (some products " +
827
+ "do not publish them yet) pass `method` and `path` instead, exactly as returned. " +
828
+ "Arguments go in path_params / query / body under the spec's own names. One call " +
829
+ "makes exactly one request and returns the product's own response untouched; when " +
830
+ "`completed` is false there is another page, which you fetch by sending the " +
831
+ "capability's own page parameter. If the mode is 'write' you MUST ask the user " +
832
+ "first, then resend with user_permission='granted' and a change_summary; both are " +
833
+ "recorded. Capabilities whose mode is 'destructive' (deletes) are refused outright — " +
834
+ "archiving, closing and merging are ordinary writes and DO run, so read the mode and " +
835
+ "intent before confirming with the user.", {
756
836
  name: z
757
837
  .string()
758
838
  .optional()
@@ -780,7 +860,8 @@ export function addCapabilityRegistryTools(server, deps, config) {
780
860
  .describe("Body fields, under the spec's names."),
781
861
  product: productArg()
782
862
  .optional()
783
- .describe("Owning product; needed only when names or paths collide."),
863
+ .describe(`Which product owns the capability (${productList}). searchCapability returns it ` +
864
+ "on every result; required only when two products share a name or a path."),
784
865
  user_permission: z
785
866
  .enum(PERMISSION_VALUES)
786
867
  .optional()
@@ -834,6 +915,17 @@ export function addCapabilityRegistryTools(server, deps, config) {
834
915
  query: input.query,
835
916
  body: input.body,
836
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
+ }
837
929
  if (capability.mode === "destructive") {
838
930
  // Refused before binding, so consent is never sought for something that cannot run.
839
931
  return refuse("invokeCapability", "destructive_blocked", `${handle} is a destructive operation and is not available through this surface`, undefined, identity);
@@ -856,7 +948,7 @@ export function addCapabilityRegistryTools(server, deps, config) {
856
948
  return refuse("invokeCapability", "change_summary_missing", "change_summary is required: state what will change", undefined, identity);
857
949
  }
858
950
  }
859
- 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);
860
952
  // The one row for a completed invoke. invoke() returns a 4xx/5xx rather than
861
953
  // throwing, so `success` keeps its tool-level meaning and these two fields carry
862
954
  // whether the product call worked. Status 0 means it could not be reached.
@@ -870,7 +962,7 @@ export function addCapabilityRegistryTools(server, deps, config) {
870
962
  }
871
963
  catch (error) {
872
964
  if (error instanceof InvocationError)
873
- return refuse("invokeCapability", invocationReason(error.message), error.message, undefined, identity);
965
+ return refuse("invokeCapability", invocationReason(error.message, error), error.message, undefined, identity);
874
966
  logger.error("invokeCapability failed: %s", error instanceof Error ? error.message : String(error));
875
967
  return refuse("invokeCapability", "unexpected_error", "that capability could not be invoked", undefined, identity);
876
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>;
@@ -115,7 +115,7 @@ export async function getFailureLogs(args, config) {
115
115
  // Register tool with the MCP server
116
116
  export default function registerGetFailureLogs(server, config) {
117
117
  const tools = {};
118
- tools.getFailureLogs = server.tool("getFailureLogs", "Fetch logs or the video URL for an Automate/App Automate session." +
118
+ tools.getFailureLogs = server.tool("getFailureLogs", "Fetch logs, or the session video URL, for an Automate/App Automate session." +
119
119
  NEEDS_SESSION_ID, {
120
120
  sessionType: z
121
121
  .enum([SessionType.Automate, SessionType.AppAutomate])