@browserstack/mcp-server 1.4.0-beta.2 → 1.5.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/capability/loadtesting.capability-index.json +1754 -0
- package/capability/tm.capability-index.json +19793 -0
- package/dist/index.js +2 -5
- package/dist/server-factory.js +5 -5
- package/dist/tools/accessibility.js +2 -5
- package/dist/tools/capability-registry/bind.d.ts +29 -0
- package/dist/tools/capability-registry/bind.js +134 -0
- package/dist/tools/capability-registry/config.d.ts +62 -0
- package/dist/tools/capability-registry/config.js +218 -0
- package/dist/tools/capability-registry/discovery.d.ts +44 -0
- package/dist/tools/capability-registry/discovery.js +99 -0
- package/dist/tools/capability-registry/egress.d.ts +44 -0
- package/dist/tools/capability-registry/egress.js +128 -0
- package/dist/tools/capability-registry/index-loader.d.ts +119 -0
- package/dist/tools/capability-registry/index-loader.js +314 -0
- package/dist/tools/capability-registry/register.d.ts +34 -0
- package/dist/tools/capability-registry/register.js +354 -0
- package/dist/tools/capability-registry/resolve.d.ts +38 -0
- package/dist/tools/capability-registry/resolve.js +45 -0
- package/dist/tools/capability-registry/search.d.ts +65 -0
- package/dist/tools/capability-registry/search.js +342 -0
- package/dist/tools/capability-registry/types.d.ts +208 -0
- package/dist/tools/capability-registry/types.js +33 -0
- package/dist/tools/get-failure-logs.js +1 -3
- package/dist/tools/rca-agent.js +2 -5
- package/dist/tools/selfheal.js +2 -5
- package/dist/tools/testmanagement.js +15 -33
- package/package.json +3 -2
- package/dist/tools/ask-browserstack/central-oauth.d.ts +0 -114
- package/dist/tools/ask-browserstack/central-oauth.js +0 -271
- package/dist/tools/ask-browserstack/config.d.ts +0 -96
- package/dist/tools/ask-browserstack/config.js +0 -134
- package/dist/tools/ask-browserstack/egress.d.ts +0 -34
- package/dist/tools/ask-browserstack/egress.js +0 -31
- package/dist/tools/ask-browserstack/register.d.ts +0 -61
- package/dist/tools/ask-browserstack/register.js +0 -403
- package/dist/tools/ask-browserstack/relay.d.ts +0 -201
- package/dist/tools/ask-browserstack/relay.js +0 -577
- package/dist/tools/ask-browserstack/stream.d.ts +0 -116
- package/dist/tools/ask-browserstack/stream.js +0 -237
- package/dist/tools/ask-browserstack/types.d.ts +0 -196
- package/dist/tools/ask-browserstack/types.js +0 -10
- package/dist/tools/tool-handoff.d.ts +0 -37
- package/dist/tools/tool-handoff.js +0 -47
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ranking capabilities against a plain-language query.
|
|
3
|
+
*
|
|
4
|
+
* Ported from the Python `discover._score`, including the two properties that were each
|
|
5
|
+
* fixed after a live mis-ranking:
|
|
6
|
+
*
|
|
7
|
+
* * PENALTIES REORDER, THEY DO NOT EXCLUDE. `matched` is the pre-penalty term score and is
|
|
8
|
+
* what decides inclusion; `ranked` carries the preferences. Conflating them dropped 40
|
|
9
|
+
* legitimate matches outright, because a cardinality penalty took an otherwise-valid
|
|
10
|
+
* score to zero and the caller saw "no such capability".
|
|
11
|
+
* * CARDINALITY. A "list" query answered by a single-record getter sends the caller to a
|
|
12
|
+
* capability needing an id it cannot possibly have yet.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Every non-alphanumeric character separates, `_` included.
|
|
16
|
+
*
|
|
17
|
+
* `_` used to be a word character, which made `test_case` a single token while every
|
|
18
|
+
* haystack rendered it as "test case" — so the two could never match. That is the exact
|
|
19
|
+
* string `listEntities` hands back, so a caller following the documented flow searched with
|
|
20
|
+
* a term guaranteed to score zero: "list test_runs" matched 19 capabilities and put an
|
|
21
|
+
* admin settings endpoint first, where "list test runs" matched 103 and put the test-runs
|
|
22
|
+
* listing first.
|
|
23
|
+
*/
|
|
24
|
+
const WORD = /[a-z0-9]+/g;
|
|
25
|
+
const STOPWORDS = new Set([
|
|
26
|
+
"a",
|
|
27
|
+
"an",
|
|
28
|
+
"and",
|
|
29
|
+
"are",
|
|
30
|
+
"as",
|
|
31
|
+
"at",
|
|
32
|
+
"be",
|
|
33
|
+
"by",
|
|
34
|
+
"can",
|
|
35
|
+
"do",
|
|
36
|
+
"for",
|
|
37
|
+
"from",
|
|
38
|
+
"how",
|
|
39
|
+
"i",
|
|
40
|
+
"in",
|
|
41
|
+
"is",
|
|
42
|
+
"it",
|
|
43
|
+
"me",
|
|
44
|
+
"my",
|
|
45
|
+
"of",
|
|
46
|
+
"on",
|
|
47
|
+
"or",
|
|
48
|
+
"has",
|
|
49
|
+
"have",
|
|
50
|
+
"that",
|
|
51
|
+
"the",
|
|
52
|
+
"these",
|
|
53
|
+
"this",
|
|
54
|
+
"those",
|
|
55
|
+
"to",
|
|
56
|
+
"want",
|
|
57
|
+
"what",
|
|
58
|
+
"which",
|
|
59
|
+
"with",
|
|
60
|
+
"you",
|
|
61
|
+
]);
|
|
62
|
+
// Verbs that reveal what the caller means to DO. A preference, not a filter — an explicit
|
|
63
|
+
// `mode` argument is the filter.
|
|
64
|
+
const READ_VERBS = new Set([
|
|
65
|
+
"list",
|
|
66
|
+
"get",
|
|
67
|
+
"show",
|
|
68
|
+
"find",
|
|
69
|
+
"fetch",
|
|
70
|
+
"read",
|
|
71
|
+
"count",
|
|
72
|
+
"search",
|
|
73
|
+
"view",
|
|
74
|
+
"which",
|
|
75
|
+
"how",
|
|
76
|
+
]);
|
|
77
|
+
const WRITE_VERBS = new Set([
|
|
78
|
+
"create",
|
|
79
|
+
"add",
|
|
80
|
+
"update",
|
|
81
|
+
"edit",
|
|
82
|
+
"delete",
|
|
83
|
+
"remove",
|
|
84
|
+
"move",
|
|
85
|
+
"copy",
|
|
86
|
+
"archive",
|
|
87
|
+
"assign",
|
|
88
|
+
"restore",
|
|
89
|
+
"reorder",
|
|
90
|
+
"bulk",
|
|
91
|
+
"set",
|
|
92
|
+
"upload",
|
|
93
|
+
"import",
|
|
94
|
+
"clone",
|
|
95
|
+
]);
|
|
96
|
+
// Words that mean "give me many", which is what makes a single-record getter the wrong answer.
|
|
97
|
+
const PLURAL_INTENT = new Set([
|
|
98
|
+
"list",
|
|
99
|
+
"all",
|
|
100
|
+
"every",
|
|
101
|
+
"many",
|
|
102
|
+
"count",
|
|
103
|
+
"search",
|
|
104
|
+
"find",
|
|
105
|
+
"which",
|
|
106
|
+
"each",
|
|
107
|
+
]);
|
|
108
|
+
/**
|
|
109
|
+
* Query/haystack terms. Verbs are deliberately NOT stopwords — they carry the intent.
|
|
110
|
+
*
|
|
111
|
+
* camelCase is split before lowercasing, so `testRunId`, `test_run_id` and `test run id`
|
|
112
|
+
* all tokenize alike.
|
|
113
|
+
*/
|
|
114
|
+
export function terms(text) {
|
|
115
|
+
return [
|
|
116
|
+
...(text || "")
|
|
117
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
118
|
+
.toLowerCase()
|
|
119
|
+
.matchAll(WORD),
|
|
120
|
+
]
|
|
121
|
+
.map((match) => match[0])
|
|
122
|
+
.filter((word) => !STOPWORDS.has(word));
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* A term plus its naive singular variants.
|
|
126
|
+
*
|
|
127
|
+
* QUERY SIDE ONLY, which is what makes this cheap and safe. Matching is one-directional
|
|
128
|
+
* substring containment, so indexed `attachments` already contains a query of `attachment`;
|
|
129
|
+
* only the reverse — a plural query against singular text — needs help. Stemming the
|
|
130
|
+
* indexed side too would mean rewriting the product's own vocabulary to guess at English,
|
|
131
|
+
* for no additional match.
|
|
132
|
+
*/
|
|
133
|
+
export function termForms(term) {
|
|
134
|
+
const forms = [term];
|
|
135
|
+
// A stripped form must still be three characters. `has` -> `ha` matched more than half
|
|
136
|
+
// the surface as a substring and pushed a correct answer out of the top 8 entirely;
|
|
137
|
+
// short fragments are noise, not variants.
|
|
138
|
+
const add = (form) => {
|
|
139
|
+
if (form.length >= 3)
|
|
140
|
+
forms.push(form);
|
|
141
|
+
};
|
|
142
|
+
if (term.endsWith("es"))
|
|
143
|
+
add(term.slice(0, -2));
|
|
144
|
+
if (term.endsWith("s") && !term.endsWith("ss"))
|
|
145
|
+
add(term.slice(0, -1));
|
|
146
|
+
return forms;
|
|
147
|
+
}
|
|
148
|
+
/** A haystack as one lowercased, space-separated string, ready for containment tests. */
|
|
149
|
+
function haystack(text) {
|
|
150
|
+
return terms(text).join(" ");
|
|
151
|
+
}
|
|
152
|
+
export function modeHint(query) {
|
|
153
|
+
const words = new Set(terms(query));
|
|
154
|
+
const wantsWrite = [...words].some((word) => WRITE_VERBS.has(word));
|
|
155
|
+
if (wantsWrite)
|
|
156
|
+
return "write";
|
|
157
|
+
const wantsRead = [...words].some((word) => READ_VERBS.has(word));
|
|
158
|
+
return wantsRead ? "read" : "";
|
|
159
|
+
}
|
|
160
|
+
export function wantsCollection(query) {
|
|
161
|
+
return [...(query || "").toLowerCase().matchAll(WORD)].some((match) => PLURAL_INTENT.has(match[0]));
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* True when a capability answers with many records rather than one.
|
|
165
|
+
*
|
|
166
|
+
* Pagination is the reliable signal — a paged operation is a listing by construction. The
|
|
167
|
+
* plural terminal path segment is a weaker fallback for unpaged collections. (The Python
|
|
168
|
+
* side used the capability NAME here; the artifact publishes no name, and the path's own
|
|
169
|
+
* terminal noun carries the same signal because operationIds were derived from it.)
|
|
170
|
+
*/
|
|
171
|
+
export function isCollection(capability) {
|
|
172
|
+
if (capability.paginated)
|
|
173
|
+
return true;
|
|
174
|
+
const segments = capability.path
|
|
175
|
+
.split("/")
|
|
176
|
+
.filter((s) => s && !s.startsWith("{"));
|
|
177
|
+
const tail = segments[segments.length - 1] || "";
|
|
178
|
+
return tail.endsWith("s") && !tail.endsWith("ss");
|
|
179
|
+
}
|
|
180
|
+
/** Everything a caller might say that lives on a parameter rather than in the prose. */
|
|
181
|
+
function parameterText(capability) {
|
|
182
|
+
const parts = [];
|
|
183
|
+
for (const group of [
|
|
184
|
+
capability.path_params,
|
|
185
|
+
capability.query,
|
|
186
|
+
capability.body,
|
|
187
|
+
]) {
|
|
188
|
+
for (const param of group || []) {
|
|
189
|
+
parts.push(param.name);
|
|
190
|
+
if (param.description)
|
|
191
|
+
parts.push(param.description);
|
|
192
|
+
if (param.values)
|
|
193
|
+
parts.push(param.values.map(String).join(" "));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return parts.join(" ");
|
|
197
|
+
}
|
|
198
|
+
/** Path words stand in for the capability name as the identity haystack. */
|
|
199
|
+
function identityText(capability) {
|
|
200
|
+
return capability.path
|
|
201
|
+
.split("/")
|
|
202
|
+
.filter((segment) => segment && !segment.startsWith("{") && segment !== "api")
|
|
203
|
+
.join(" ")
|
|
204
|
+
.replace(/[-_]/g, " ");
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* How much one term is worth, by how rare it is.
|
|
208
|
+
*
|
|
209
|
+
* Containment made every project-scoped endpoint match the term `project` — ~150 of tm's
|
|
210
|
+
* 173 capabilities — so that word carried as much weight as `access`, which appears in
|
|
211
|
+
* exactly one. Rarity is what separates them: a term matching everything scores near zero,
|
|
212
|
+
* a term matching one capability scores near one.
|
|
213
|
+
*
|
|
214
|
+
* This is the IDF idea alone, not BM25. The term-frequency saturation and length
|
|
215
|
+
* normalisation BM25 adds would rescale every score, and the mode and cardinality
|
|
216
|
+
* adjustments below are absolute constants fitted against live mis-rankings. Bounding the
|
|
217
|
+
* factor to 0..1 keeps those constants meaningful.
|
|
218
|
+
*/
|
|
219
|
+
function rarity(documents, forms) {
|
|
220
|
+
let df = 0;
|
|
221
|
+
for (const text of documents) {
|
|
222
|
+
if (forms.some((form) => text.includes(form)))
|
|
223
|
+
df += 1;
|
|
224
|
+
}
|
|
225
|
+
const total = documents.length || 1;
|
|
226
|
+
return Math.log((total + 1) / (df + 1)) / Math.log(total + 1);
|
|
227
|
+
}
|
|
228
|
+
function score(capability, wanted, weights, aliases, hint, plural) {
|
|
229
|
+
if (wanted.length === 0)
|
|
230
|
+
return { matched: 1, ranked: 1 };
|
|
231
|
+
const haystacks = [
|
|
232
|
+
[identityText(capability), 6],
|
|
233
|
+
[capability.entity, 4],
|
|
234
|
+
[(aliases[capability.entity] || []).join(" "), 4],
|
|
235
|
+
[capability.intent || "", 2],
|
|
236
|
+
// `returns` is scored BELOW identity, not gated on it. At parity with intent it put a
|
|
237
|
+
// projects listing at #2 for "list test cases in a project" (its returns carries
|
|
238
|
+
// `test_cases_count`); gating it on an identity match instead made a field reachable
|
|
239
|
+
// only through returns unreachable, which is worse.
|
|
240
|
+
[(capability.returns || []).join(" "), 1],
|
|
241
|
+
[(capability.guidance || []).join(" "), 1],
|
|
242
|
+
// Parameter names, their descriptions, and their enum values — 330 descriptions and 34
|
|
243
|
+
// value lists that the artifact already carries and nothing was reading. The vocabulary
|
|
244
|
+
// a caller uses is often the value they mean to send: `pass` and `fail` appear nowhere
|
|
245
|
+
// else in the index, only as the `status` enum on the test-result writes.
|
|
246
|
+
[parameterText(capability), 1],
|
|
247
|
+
];
|
|
248
|
+
// CONTAINMENT, not set membership. A query of `attachment` has to reach an endpoint whose
|
|
249
|
+
// path says `attachments`; under exact token equality it did not, and that endpoint fell
|
|
250
|
+
// out of the results entirely. A term scores its field once however many forms match.
|
|
251
|
+
let ranked = 0;
|
|
252
|
+
for (const [text, weight] of haystacks) {
|
|
253
|
+
const blob = haystack(text);
|
|
254
|
+
if (!blob)
|
|
255
|
+
continue;
|
|
256
|
+
for (let i = 0; i < wanted.length; i += 1) {
|
|
257
|
+
if (wanted[i].some((form) => blob.includes(form)))
|
|
258
|
+
ranked += weight * weights[i];
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// PHRASE. Adjacent query terms occurring together say more than the same two words
|
|
262
|
+
// scattered: "test case" is one noun in this vocabulary, "test" and "case" separately
|
|
263
|
+
// are two of the commonest words in the index. Scored at half the field's weight and
|
|
264
|
+
// still scaled by rarity, so it sharpens an existing match rather than creating one.
|
|
265
|
+
for (const [text, weight] of haystacks) {
|
|
266
|
+
const blob = haystack(text);
|
|
267
|
+
if (!blob)
|
|
268
|
+
continue;
|
|
269
|
+
for (let i = 0; i + 1 < wanted.length; i += 1) {
|
|
270
|
+
if (blob.includes(`${wanted[i][0]} ${wanted[i + 1][0]}`)) {
|
|
271
|
+
ranked += weight * 0.5 * (weights[i] + weights[i + 1]);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const matched = ranked;
|
|
276
|
+
if (hint && capability.mode !== hint)
|
|
277
|
+
ranked -= 20;
|
|
278
|
+
else if (hint && capability.mode === hint)
|
|
279
|
+
ranked += 6;
|
|
280
|
+
if (plural)
|
|
281
|
+
ranked += isCollection(capability) ? 8 : -8;
|
|
282
|
+
return { matched, ranked };
|
|
283
|
+
}
|
|
284
|
+
export function searchCapabilities(products, query, options = {}) {
|
|
285
|
+
const limit = options.limit && options.limit > 0 ? options.limit : 8;
|
|
286
|
+
// Forms are computed once per query, not per capability: 173 capabilities x 6 haystacks
|
|
287
|
+
// would otherwise rebuild the same handful of strings a thousand times.
|
|
288
|
+
const wanted = terms(query).map(termForms);
|
|
289
|
+
const hint = options.mode ? "" : modeHint(query);
|
|
290
|
+
const plural = wantsCollection(query);
|
|
291
|
+
const scored = [];
|
|
292
|
+
const searched = Object.entries(products).filter(([name]) => !options.product || name === options.product);
|
|
293
|
+
const aliasesByProduct = {};
|
|
294
|
+
for (const [name, bundle] of searched) {
|
|
295
|
+
const aliases = {};
|
|
296
|
+
for (const [entity, doc] of Object.entries(bundle.entities)) {
|
|
297
|
+
aliases[entity] = (doc.aliases || []);
|
|
298
|
+
}
|
|
299
|
+
aliasesByProduct[name] = aliases;
|
|
300
|
+
}
|
|
301
|
+
// ONE CORPUS ACROSS EVERY SEARCHED PRODUCT, not one per product.
|
|
302
|
+
//
|
|
303
|
+
// Measured per product, a term's rarity inverts across them: `load` appears in nearly
|
|
304
|
+
// every Load Testing capability, so it scored as noise there, while it appears in 16 of
|
|
305
|
+
// tm's 173 (`upload`, `download`, reached by containment), so it scored as gold there.
|
|
306
|
+
// The word that identifies a product was worth least inside it, and "list load tests"
|
|
307
|
+
// returned five tm results and no Load Testing ones at all.
|
|
308
|
+
//
|
|
309
|
+
// It is also measured against EVERY capability, not the entity- or mode-filtered subset:
|
|
310
|
+
// narrowing a search must not make a common word look rare.
|
|
311
|
+
const corpus = searched.flatMap(([name, bundle]) => bundle.capabilities.map((capability) => [
|
|
312
|
+
identityText(capability),
|
|
313
|
+
capability.entity,
|
|
314
|
+
(aliasesByProduct[name][capability.entity] || []).join(" "),
|
|
315
|
+
capability.intent || "",
|
|
316
|
+
(capability.returns || []).join(" "),
|
|
317
|
+
parameterText(capability),
|
|
318
|
+
]
|
|
319
|
+
.map(haystack)
|
|
320
|
+
.join(" ")));
|
|
321
|
+
const weights = wanted.map((forms) => rarity(corpus, forms));
|
|
322
|
+
for (const [name, bundle] of searched) {
|
|
323
|
+
const aliases = aliasesByProduct[name];
|
|
324
|
+
for (const capability of bundle.capabilities) {
|
|
325
|
+
if (options.entity && capability.entity !== options.entity)
|
|
326
|
+
continue;
|
|
327
|
+
if (options.mode && capability.mode !== options.mode)
|
|
328
|
+
continue;
|
|
329
|
+
const { matched, ranked } = score(capability, wanted, weights, aliases, hint, plural);
|
|
330
|
+
if (matched > 0)
|
|
331
|
+
scored.push({ matched, ranked, product: name, capability });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
scored.sort((a, b) => b.ranked - a.ranked || a.capability.path.localeCompare(b.capability.path));
|
|
335
|
+
return {
|
|
336
|
+
hits: scored
|
|
337
|
+
.slice(0, limit)
|
|
338
|
+
.map(({ product, capability }) => ({ product, capability })),
|
|
339
|
+
truncated: scored.length > limit,
|
|
340
|
+
total_matched: scored.length,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shape of the index artifact, which is the contract with the export pipeline.
|
|
3
|
+
*
|
|
4
|
+
* The artifact is generated by the capability-registry build and transformed into its
|
|
5
|
+
* released envelope by the export pipeline's merge step. It contains ONLY data that has
|
|
6
|
+
* already passed that side's outbound boundary. Nothing here is parsed from an OpenAPI spec
|
|
7
|
+
* at runtime: if this half parsed specs it would become the boundary, and every gate (route
|
|
8
|
+
* lint, vocabulary rule, intent lint, discovery denylist) would have to be reimplemented and
|
|
9
|
+
* re-tested here. Reading a pre-projected index means the internal data is not in the
|
|
10
|
+
* package at all.
|
|
11
|
+
*
|
|
12
|
+
* ONE FILE PER PRODUCT. The released artifact is `capability/<product>.capability-index.json`,
|
|
13
|
+
* with the
|
|
14
|
+
* product object at the top level under its own name. The plural `products` wrapper belonged
|
|
15
|
+
* to the pre-release shape and implied multi-product files that never existed.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* The envelope's format marker, for FUTURE shape changes.
|
|
19
|
+
*
|
|
20
|
+
* BOTH the released and the pre-release shapes claim 1, so this number cannot distinguish
|
|
21
|
+
* them — `readIndexFile` shape-detects on the `products` wrapper instead. The check is still
|
|
22
|
+
* worth keeping: it is what refuses a shape the generator has announced but this build does
|
|
23
|
+
* not read.
|
|
24
|
+
*/
|
|
25
|
+
export declare const SUPPORTED_SCHEMA_VERSION = 1;
|
|
26
|
+
/** Envelope keys, i.e. everything at the top level that is NOT the product object. */
|
|
27
|
+
export declare const ENVELOPE_KEYS: readonly ["schema_version", "version", "build_id", "harness_commit", "products"];
|
|
28
|
+
export type Mode = "read" | "write" | "destructive";
|
|
29
|
+
/** One parameter, under the name the OpenAPI spec itself gives it. */
|
|
30
|
+
export interface WireParam {
|
|
31
|
+
name: string;
|
|
32
|
+
type: string;
|
|
33
|
+
required?: true;
|
|
34
|
+
values?: unknown[];
|
|
35
|
+
example?: unknown;
|
|
36
|
+
description?: string;
|
|
37
|
+
/** Field names/types one level inside an array item or nested object. */
|
|
38
|
+
fields?: {
|
|
39
|
+
name: string;
|
|
40
|
+
type: string;
|
|
41
|
+
required?: true;
|
|
42
|
+
}[];
|
|
43
|
+
/**
|
|
44
|
+
* Where a body field sits in the JSON, when that differs from its name. Published
|
|
45
|
+
* because the nesting is not guessable and getting it wrong fails silently — tm's folder
|
|
46
|
+
* create really wants `{folder: {name}}` while the spec's flat `{name}` is what a reader
|
|
47
|
+
* would assume.
|
|
48
|
+
*/
|
|
49
|
+
json_path?: string;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* A reference into a product-level lookup table.
|
|
53
|
+
*
|
|
54
|
+
* Named components are stored ONCE, already dereferenced and allOf-flattened at build time,
|
|
55
|
+
* so resolution is a single dict lookup rather than a recursive $ref walk at runtime.
|
|
56
|
+
*/
|
|
57
|
+
export interface ComponentRef {
|
|
58
|
+
$response?: string;
|
|
59
|
+
$schema?: string;
|
|
60
|
+
}
|
|
61
|
+
/** A JSON Schema fragment, or a reference to a named one. */
|
|
62
|
+
export type SchemaNode = ComponentRef & Record<string, unknown>;
|
|
63
|
+
/** One declared response, or a reference to a named one. */
|
|
64
|
+
export interface ResponseDoc extends ComponentRef {
|
|
65
|
+
description?: string;
|
|
66
|
+
schema?: SchemaNode;
|
|
67
|
+
}
|
|
68
|
+
/** A capability, keyed by the endpoint it exposes. There is deliberately no name. */
|
|
69
|
+
export interface Capability {
|
|
70
|
+
method: string;
|
|
71
|
+
path: string;
|
|
72
|
+
mode: Mode;
|
|
73
|
+
entity: string;
|
|
74
|
+
path_params?: WireParam[];
|
|
75
|
+
query?: WireParam[];
|
|
76
|
+
body?: WireParam[];
|
|
77
|
+
intent?: string;
|
|
78
|
+
/**
|
|
79
|
+
* How to call the endpoint correctly.
|
|
80
|
+
*
|
|
81
|
+
* ABSENT from the released artifact — the export dropped it, and the shape-change note
|
|
82
|
+
* does not say whether that was intended. Kept optional and still scored by search so the
|
|
83
|
+
* server works either way; nothing may depend on it being present.
|
|
84
|
+
*/
|
|
85
|
+
guidance?: string[];
|
|
86
|
+
/** Allowlisted row fields. Absent when `shape` is "discovered". */
|
|
87
|
+
returns?: string[];
|
|
88
|
+
/** "discovered" when the product declares no response schema for this operation. */
|
|
89
|
+
shape?: "discovered";
|
|
90
|
+
requires?: string[];
|
|
91
|
+
paginated?: boolean;
|
|
92
|
+
/** The largest page the operation declares. */
|
|
93
|
+
max_page_size?: number;
|
|
94
|
+
/**
|
|
95
|
+
* Declared responses by status code, values possibly `{$response: "Name"}` references.
|
|
96
|
+
*
|
|
97
|
+
* ADDITIVE and not yet emitted: no capability in the current export carries it. Absence
|
|
98
|
+
* means "no response schema available", never an error. Resolve with `resolveComponent`.
|
|
99
|
+
*/
|
|
100
|
+
responses?: Record<string, ResponseDoc>;
|
|
101
|
+
}
|
|
102
|
+
export interface EntityDoc {
|
|
103
|
+
title?: string;
|
|
104
|
+
aliases?: string[];
|
|
105
|
+
id_convention?: string;
|
|
106
|
+
parents?: string[];
|
|
107
|
+
relations?: {
|
|
108
|
+
entity?: string;
|
|
109
|
+
via?: string;
|
|
110
|
+
}[];
|
|
111
|
+
[key: string]: unknown;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Paging controls, keyed "METHOD /path".
|
|
115
|
+
*
|
|
116
|
+
* NOT USED BY THIS SERVER: it performs one request and returns the response, so paging
|
|
117
|
+
* belongs to the caller and the page parameters are published with the endpoint's other
|
|
118
|
+
* query parameters. The field is still emitted by the build, so it stays described here
|
|
119
|
+
* rather than silently ignored — a consumer that DOES page can use it.
|
|
120
|
+
*/
|
|
121
|
+
export interface PagingRule {
|
|
122
|
+
page?: string;
|
|
123
|
+
size?: string;
|
|
124
|
+
/** The largest page the operation declares. Absent when the spec states no maximum. */
|
|
125
|
+
max?: number;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* How a product wants the caller's credentials presented.
|
|
129
|
+
*
|
|
130
|
+
* The OpenAPI `securityScheme` vocabulary, verbatim — `type` / `in` / `name` / `scheme` —
|
|
131
|
+
* because the spec already describes this and inventing a parallel taxonomy would mean
|
|
132
|
+
* translating between two of them forever. ONE scheme per product, not OpenAPI's list of
|
|
133
|
+
* alternatives: this server holds a username and an access key and nothing else, so
|
|
134
|
+
* "the first alternative we can satisfy" only ever had one answer.
|
|
135
|
+
*
|
|
136
|
+
* `template` is what makes it a contract rather than a guess — a product taking
|
|
137
|
+
* `{username}_{access_key}` is expressible instead of being a special case in the server.
|
|
138
|
+
* It names placeholders, never values: the credential itself stays in config, and the
|
|
139
|
+
* harness's `{ env: … }` value sources must NOT cross the boundary into the artifact.
|
|
140
|
+
*/
|
|
141
|
+
export interface AuthScheme {
|
|
142
|
+
/** "apiKey" puts the rendered template in a header; "http" with scheme "basic" encodes it. */
|
|
143
|
+
type: "apiKey" | "http";
|
|
144
|
+
/** apiKey only. Header is the sole supported location — see `egress.authHeaders`. */
|
|
145
|
+
in?: "header" | "cookie" | "query";
|
|
146
|
+
/** apiKey only: the header name, e.g. "Api-Token". */
|
|
147
|
+
name?: string;
|
|
148
|
+
/** http only. */
|
|
149
|
+
scheme?: string;
|
|
150
|
+
/** Defaults to `{username}:{access_key}`. */
|
|
151
|
+
template?: string;
|
|
152
|
+
}
|
|
153
|
+
export interface ProductIndex {
|
|
154
|
+
summary: string;
|
|
155
|
+
/**
|
|
156
|
+
* How to authenticate to this product. Absent means the historical default: the caller's
|
|
157
|
+
* credentials as `Api-Token: {username}:{access_key}`, which is what every shipped index
|
|
158
|
+
* relies on today.
|
|
159
|
+
*/
|
|
160
|
+
auth?: AuthScheme;
|
|
161
|
+
/**
|
|
162
|
+
* The single host this product is served from, when it has one.
|
|
163
|
+
*
|
|
164
|
+
* A default, not the last word: config overrides it, and for a region-sharded product
|
|
165
|
+
* (one declaring `base_urls`) account discovery outranks it and this is the fallback.
|
|
166
|
+
*/
|
|
167
|
+
base_url?: string;
|
|
168
|
+
/**
|
|
169
|
+
* Candidate regional hosts, in probe order, for a product whose host depends on the
|
|
170
|
+
* ACCOUNT rather than the deployment.
|
|
171
|
+
*
|
|
172
|
+
* Declaring these makes the product region-sharded: the server asks each in turn with the
|
|
173
|
+
* caller's credentials and keeps the one that answers. A single fixed `base_url` cannot
|
|
174
|
+
* express this — it would send every account outside the default region to the wrong host,
|
|
175
|
+
* a failure invisible to anyone testing from inside that region.
|
|
176
|
+
*/
|
|
177
|
+
base_urls?: string[];
|
|
178
|
+
/**
|
|
179
|
+
* The endpoint to probe the candidates with. Derived from the capabilities when absent
|
|
180
|
+
* (see `discovery.probePath`); declare it when the derived choice would be wrong.
|
|
181
|
+
*/
|
|
182
|
+
probe_path?: string;
|
|
183
|
+
capabilities: Capability[];
|
|
184
|
+
entities: Record<string, EntityDoc>;
|
|
185
|
+
paging?: Record<string, PagingRule>;
|
|
186
|
+
/** Named responses, referenced as `{$response: "Name"}`. Additive; not yet emitted. */
|
|
187
|
+
responses?: Record<string, ResponseDoc>;
|
|
188
|
+
/** Named schemas, referenced as `{$schema: "Name"}`. Additive; not yet emitted. */
|
|
189
|
+
schemas?: Record<string, SchemaNode>;
|
|
190
|
+
}
|
|
191
|
+
/** Where one product's data came from. Logging and cache-busting only. */
|
|
192
|
+
export interface Provenance {
|
|
193
|
+
/** `<commit>_<UTC timestamp>` in the released shape. */
|
|
194
|
+
build_id: string;
|
|
195
|
+
/** Dotted content-generation counter, e.g. "1.2". Absent in the pre-release shape. */
|
|
196
|
+
version?: string;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* The merged, in-memory model: every loaded file's product under its own name.
|
|
200
|
+
*
|
|
201
|
+
* This is the server's own structure, not the file format — one file carries one product,
|
|
202
|
+
* and the registry holds all of them so a caller can search across products.
|
|
203
|
+
*/
|
|
204
|
+
export interface RegistryIndex {
|
|
205
|
+
schema_version: number;
|
|
206
|
+
build_id: string;
|
|
207
|
+
products: Record<string, ProductIndex>;
|
|
208
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shape of the index artifact, which is the contract with the export pipeline.
|
|
3
|
+
*
|
|
4
|
+
* The artifact is generated by the capability-registry build and transformed into its
|
|
5
|
+
* released envelope by the export pipeline's merge step. It contains ONLY data that has
|
|
6
|
+
* already passed that side's outbound boundary. Nothing here is parsed from an OpenAPI spec
|
|
7
|
+
* at runtime: if this half parsed specs it would become the boundary, and every gate (route
|
|
8
|
+
* lint, vocabulary rule, intent lint, discovery denylist) would have to be reimplemented and
|
|
9
|
+
* re-tested here. Reading a pre-projected index means the internal data is not in the
|
|
10
|
+
* package at all.
|
|
11
|
+
*
|
|
12
|
+
* ONE FILE PER PRODUCT. The released artifact is `capability/<product>.capability-index.json`,
|
|
13
|
+
* with the
|
|
14
|
+
* product object at the top level under its own name. The plural `products` wrapper belonged
|
|
15
|
+
* to the pre-release shape and implied multi-product files that never existed.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* The envelope's format marker, for FUTURE shape changes.
|
|
19
|
+
*
|
|
20
|
+
* BOTH the released and the pre-release shapes claim 1, so this number cannot distinguish
|
|
21
|
+
* them — `readIndexFile` shape-detects on the `products` wrapper instead. The check is still
|
|
22
|
+
* worth keeping: it is what refuses a shape the generator has announced but this build does
|
|
23
|
+
* not read.
|
|
24
|
+
*/
|
|
25
|
+
export const SUPPORTED_SCHEMA_VERSION = 1;
|
|
26
|
+
/** Envelope keys, i.e. everything at the top level that is NOT the product object. */
|
|
27
|
+
export const ENVELOPE_KEYS = [
|
|
28
|
+
"schema_version",
|
|
29
|
+
"version",
|
|
30
|
+
"build_id",
|
|
31
|
+
"harness_commit",
|
|
32
|
+
"products",
|
|
33
|
+
];
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { trackMCP } from "../lib/instrumentation.js";
|
|
3
|
-
import { NEEDS_SESSION_ID } from "./tool-handoff.js";
|
|
4
3
|
import { retrieveNetworkFailures, retrieveSessionFailures, retrieveConsoleFailures, } from "./failurelogs-utils/automate.js";
|
|
5
4
|
import { retrieveDeviceLogs, retrieveAppiumLogs, retrieveCrashLogs, } from "./failurelogs-utils/app-automate.js";
|
|
6
5
|
import { AppAutomateLogType, AutomateLogType, SessionType, } from "../lib/constants.js";
|
|
@@ -99,8 +98,7 @@ export async function getFailureLogs(args, config) {
|
|
|
99
98
|
// Register tool with the MCP server
|
|
100
99
|
export default function registerGetFailureLogs(server, config) {
|
|
101
100
|
const tools = {};
|
|
102
|
-
tools.getFailureLogs = server.tool("getFailureLogs", "Fetch various types of logs from a BrowserStack session. Supports both automate and app-automate sessions."
|
|
103
|
-
NEEDS_SESSION_ID, {
|
|
101
|
+
tools.getFailureLogs = server.tool("getFailureLogs", "Fetch various types of logs from a BrowserStack session. Supports both automate and app-automate sessions.", {
|
|
104
102
|
sessionType: z
|
|
105
103
|
.enum([SessionType.Automate, SessionType.AppAutomate])
|
|
106
104
|
.describe("Type of BrowserStack session. Must be explicitly provided by the user."),
|
package/dist/tools/rca-agent.js
CHANGED
|
@@ -7,7 +7,6 @@ import { getRCAData } from "./rca-agent-utils/rca-data.js";
|
|
|
7
7
|
import { formatRCAData } from "./rca-agent-utils/format-rca.js";
|
|
8
8
|
import { handleMCPError } from "../lib/utils.js";
|
|
9
9
|
import { trackMCP } from "../index.js";
|
|
10
|
-
import { NEEDS_BUILD_ID, NEEDS_TEST_IDS } from "./tool-handoff.js";
|
|
11
10
|
import { FETCH_RCA_PARAMS, GET_BUILD_ID_PARAMS, LIST_TEST_IDS_PARAMS, } from "./rca-agent-utils/constants.js";
|
|
12
11
|
// Tool function to fetch build ID
|
|
13
12
|
export async function getBuildIdTool(args, config) {
|
|
@@ -131,8 +130,7 @@ export async function listTestIdsTool(args, config) {
|
|
|
131
130
|
}
|
|
132
131
|
export default function addRCATools(server, config) {
|
|
133
132
|
const tools = {};
|
|
134
|
-
tools.fetchRCA = server.tool("fetchRCA", "Fetch AI Root Cause Analysis for the current user's failed BrowserStack Automate/App-Automate tests. Suggests fixes only; never auto-apply, require explicit user approval."
|
|
135
|
-
NEEDS_TEST_IDS, FETCH_RCA_PARAMS, {
|
|
133
|
+
tools.fetchRCA = server.tool("fetchRCA", "Fetch AI Root Cause Analysis for the current user's failed BrowserStack Automate/App-Automate tests. Suggests fixes only; never auto-apply, require explicit user approval.", FETCH_RCA_PARAMS, {
|
|
136
134
|
title: "Fetch Root Cause Analysis",
|
|
137
135
|
readOnlyHint: true,
|
|
138
136
|
openWorldHint: false,
|
|
@@ -177,8 +175,7 @@ export default function addRCATools(server, config) {
|
|
|
177
175
|
return handleMCPError("listBuildId", server, config, error);
|
|
178
176
|
}
|
|
179
177
|
});
|
|
180
|
-
tools.listTestIds = server.tool("listTestIds", "List all tests of a BrowserStack build (each with its status); optional status filter."
|
|
181
|
-
NEEDS_BUILD_ID, LIST_TEST_IDS_PARAMS, {
|
|
178
|
+
tools.listTestIds = server.tool("listTestIds", "List all tests of a BrowserStack build (each with its status); optional status filter.", LIST_TEST_IDS_PARAMS, {
|
|
182
179
|
title: "List Test IDs",
|
|
183
180
|
readOnlyHint: true,
|
|
184
181
|
openWorldHint: false,
|
package/dist/tools/selfheal.js
CHANGED
|
@@ -3,7 +3,6 @@ import { getSelfHealSelectors, fetchSelfHealingReportByBuild, } from "./selfheal
|
|
|
3
3
|
import { fetchTestCodeForSessions, formatTestCodeAsContext, describeTestCodeFetchIssues, } from "./selfheal-utils/fetch-test-code.js";
|
|
4
4
|
import logger from "../logger.js";
|
|
5
5
|
import { trackMCP } from "../lib/instrumentation.js";
|
|
6
|
-
import { NEEDS_SESSION_ID } from "./tool-handoff.js";
|
|
7
6
|
// Local helper: returns the server-configured BrowserStack credentials, or
|
|
8
7
|
// null when either is missing. Lives here because the self-heal tools need
|
|
9
8
|
// to degrade gracefully — `getBrowserStackAuth` throws, which is wrong for
|
|
@@ -431,8 +430,7 @@ export default function addSelfHealTools(server, config) {
|
|
|
431
430
|
"the run. Provide exactly one of `sessionId` (single Automate / " +
|
|
432
431
|
"App-Automate session) or `buildUuid` (full self-healing report for a " +
|
|
433
432
|
"build). Pass the returned locator pairs to `prepareSelfHealingPlan` " +
|
|
434
|
-
"to plan edits."
|
|
435
|
-
NEEDS_SESSION_ID, {
|
|
433
|
+
"to plan edits.", {
|
|
436
434
|
sessionId: z
|
|
437
435
|
.string()
|
|
438
436
|
.describe("Session ID. Mutually exclusive with buildUuid.")
|
|
@@ -501,8 +499,7 @@ export default function addSelfHealTools(server, config) {
|
|
|
501
499
|
"[...]}`, the raw report `{healing_logs: [...]}` (with " +
|
|
502
500
|
"`healed_selectors` aliasing `locators`), and snake_case keys " +
|
|
503
501
|
"(`session_id`, `original_locator`, `healed_locator`, " +
|
|
504
|
-
"`healing_thought`)."
|
|
505
|
-
NEEDS_SESSION_ID, {
|
|
502
|
+
"`healing_thought`).", {
|
|
506
503
|
sessions: sessionsFieldSchema.describe("Sessions to plan edits for. See tool description for accepted shapes."),
|
|
507
504
|
}, {
|
|
508
505
|
title: "Prepare Self-Healing Plan",
|