@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.
- package/capability/appautomate.capability-index.json +3233 -0
- package/capability/automate.capability-index.json +2068 -0
- package/dist/tools/accessibility.js +3 -2
- package/dist/tools/appautomate-utils/appium-sdk/constants.d.ts +1 -1
- package/dist/tools/appautomate-utils/appium-sdk/constants.js +3 -3
- package/dist/tools/appautomate-utils/native-execution/constants.d.ts +1 -1
- package/dist/tools/appautomate-utils/native-execution/constants.js +20 -4
- package/dist/tools/appautomate.js +3 -3
- package/dist/tools/applive.js +4 -4
- package/dist/tools/ask-browserstack/register.js +13 -6
- package/dist/tools/capability-registry/bind.js +56 -0
- package/dist/tools/capability-registry/config.d.ts +1 -25
- package/dist/tools/capability-registry/config.js +40 -1
- package/dist/tools/capability-registry/discovery.d.ts +1 -1
- package/dist/tools/capability-registry/egress.js +21 -3
- package/dist/tools/capability-registry/index-loader.d.ts +33 -7
- package/dist/tools/capability-registry/index-loader.js +99 -26
- package/dist/tools/capability-registry/register.d.ts +1 -1
- package/dist/tools/capability-registry/register.js +111 -19
- package/dist/tools/capability-registry/resolve.js +42 -1
- package/dist/tools/capability-registry/types.d.ts +86 -0
- package/dist/tools/get-failure-logs.js +1 -1
- package/dist/tools/live.js +2 -2
- package/dist/tools/rca-agent-utils/constants.js +2 -2
- package/dist/tools/rca-agent.js +1 -1
- package/dist/tools/sdk-utils/common/constants.d.ts +1 -1
- package/dist/tools/sdk-utils/common/constants.js +1 -1
- package/dist/tools/sdk-utils/common/schema.js +1 -1
- package/dist/tools/selfheal.js +16 -2
- package/dist/tools/testmanagement-utils/create-testcase.js +3 -3
- package/dist/tools/testmanagement-utils/list-testcases.js +1 -1
- package/dist/tools/testmanagement-utils/update-testcase.js +3 -3
- package/dist/tools/testmanagement.js +16 -12
- package/dist/tools/tool-handoff.d.ts +2 -2
- package/dist/tools/tool-handoff.js +16 -10
- 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
|
-
|
|
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
|
-
*
|
|
293
|
+
* Does this build withhold the handle?
|
|
223
294
|
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
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
|
-
*
|
|
229
|
-
*
|
|
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
|
-
|
|
304
|
+
isWithheld(handle, product) {
|
|
232
305
|
for (const [owner, withheld] of this.withheld) {
|
|
233
306
|
if (product && owner !== product)
|
|
234
307
|
continue;
|
|
235
|
-
if (
|
|
236
|
-
|
|
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
|
|
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
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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("
|
|
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("
|
|
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("
|
|
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
|
|
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
|
|
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", "
|
|
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(
|
|
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
|
|
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", "
|
|
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(
|
|
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
|
|
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])
|