@browserstack/mcp-server 1.5.0-beta.15 → 1.5.0-beta.17
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 +48 -0
- package/capability/tm.capability-index.json +33738 -12144
- package/dist/tools/capability-registry/bind.js +42 -1
- package/dist/tools/capability-registry/egress.js +52 -6
- package/dist/tools/capability-registry/index-loader.js +36 -1
- package/dist/tools/capability-registry/register.d.ts +8 -0
- package/dist/tools/capability-registry/register.js +167 -46
- package/dist/tools/capability-registry/search.d.ts +50 -4
- package/dist/tools/capability-registry/search.js +119 -41
- package/dist/tools/capability-registry/types.d.ts +14 -0
- package/package.json +6 -2
|
@@ -145,6 +145,36 @@ export function termForms(term) {
|
|
|
145
145
|
add(term.slice(0, -1));
|
|
146
146
|
return forms;
|
|
147
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* ONE canonical spelling of a word, so two spellings of a noun cannot look like two terms.
|
|
150
|
+
*
|
|
151
|
+
* `termForms` is deliberately generous — it offers every candidate and lets substring
|
|
152
|
+
* containment sort them out. Canonicalising needs the opposite: exactly one answer, and
|
|
153
|
+
* the right one. Taking the SHORTEST candidate is what a first cut did, and it folded
|
|
154
|
+
* `cases` to `cas`, so the vocabulary entry `test case` matched no query containing "test
|
|
155
|
+
* cases" — the phrase it exists for. The ambiguity check then fell through to `test`
|
|
156
|
+
* alone, reaching the right verdict by the wrong route.
|
|
157
|
+
*
|
|
158
|
+
* Strip `es` only after a sibilant, where English actually inserts it (`boxes`,
|
|
159
|
+
* `batches`, `statuses`). Otherwise strip the single `s`, which is right for the `-e`
|
|
160
|
+
* plurals this vocabulary is full of: case, suite, phase, template.
|
|
161
|
+
*/
|
|
162
|
+
export function singular(word) {
|
|
163
|
+
if (word.endsWith("ss"))
|
|
164
|
+
return word;
|
|
165
|
+
// histories -> history. Without this the alias `histories` and the entity word
|
|
166
|
+
// `history` are two unrelated terms, which is the same defect in another spelling.
|
|
167
|
+
if (word.endsWith("ies") && word.length > 4)
|
|
168
|
+
return `${word.slice(0, -3)}y`;
|
|
169
|
+
// Only where the `e` is genuinely inserted. `s` is NOT in this set: `cases` ends in
|
|
170
|
+
// `ses` and is `case` + `s`, not `cas` + `es`, and no rule can tell it from `statuses`
|
|
171
|
+
// by suffix alone — so the commoner reading wins and `-s` is stripped below.
|
|
172
|
+
if (/(?:ch|sh|x|z)es$/.test(word))
|
|
173
|
+
return word.slice(0, -2);
|
|
174
|
+
if (word.endsWith("s") && word.length > 3)
|
|
175
|
+
return word.slice(0, -1);
|
|
176
|
+
return word;
|
|
177
|
+
}
|
|
148
178
|
/** A haystack as one lowercased, space-separated string, ready for containment tests. */
|
|
149
179
|
function haystack(text) {
|
|
150
180
|
return terms(text).join(" ");
|
|
@@ -432,50 +462,88 @@ function score(capability, wanted, weights, aliases, hint, plural) {
|
|
|
432
462
|
* that work at 0.44–1.94. The absolute score cannot separate those (misses reach 2.9,
|
|
433
463
|
* good queries drop to 1.9); this does, with the gap in the same place in all three
|
|
434
464
|
* corpora, which is the property that was missing.
|
|
465
|
+
*
|
|
466
|
+
* RECALIBRATED AT v1.14, and the reason matters more than the number. The ratio is
|
|
467
|
+
* corpus-relative and still is — "make a new bucket for my tests" scores 0.259 against tm
|
|
468
|
+
* alone and 0.258 against both products, which is the property this was built for. What
|
|
469
|
+
* moved is neither corpus size nor query: it is how much TEXT each capability carries.
|
|
470
|
+
* v1.13 gave all 242 entries guidance and tripled the length of `intent`, so the numerator
|
|
471
|
+
* — the best hit's term score — roughly doubled, while the denominator depends only on the
|
|
472
|
+
* rarity of the query's own terms and did not. Both bands shifted up together: genuine
|
|
473
|
+
* misses now land at 0.00–0.46 and working queries at 0.24–4.16.
|
|
474
|
+
*
|
|
475
|
+
* At the old 0.25 the hand-off had silently stopped firing — "bucket", which is nobody's
|
|
476
|
+
* word for a folder, came in at 0.259 and read as a confident answer. Re-measured over 173
|
|
477
|
+
* served eval cases against five queries the product has no words for, 0.50 catches 5 of 5
|
|
478
|
+
* while flagging 3 of 173 served queries (1.7%), which is the same trade the original cut
|
|
479
|
+
* made. Expect to move it again the next time the artifact's text volume changes; the
|
|
480
|
+
* threshold tracks prose per capability, not the number of capabilities.
|
|
435
481
|
*/
|
|
436
|
-
const WEAK_COVERAGE = 0.
|
|
482
|
+
const WEAK_COVERAGE = 0.5;
|
|
437
483
|
/** The heaviest field, and so the yardstick a perfect match is measured against. */
|
|
438
484
|
const IDENTITY_WEIGHT = 6;
|
|
439
|
-
/** Slots a matching product is guaranteed, before the rest of the page fills by rank. */
|
|
440
|
-
const PRODUCT_FLOOR = 2;
|
|
441
485
|
/**
|
|
442
|
-
*
|
|
443
|
-
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
454
|
-
*
|
|
486
|
+
* Products whose OWN vocabulary the query hits, when no word in the query settles it.
|
|
487
|
+
*
|
|
488
|
+
* Requiring `product` made every CALL unambiguous and did nothing about the agent making
|
|
489
|
+
* two of them: "list all projects" was answered by searching tm, then Load Testing, then
|
|
490
|
+
* merging — a question about which product the user meant, answered by guessing both.
|
|
491
|
+
* Tool-description prose asking the agent to check with the user is a suggestion it is
|
|
492
|
+
* free to decline, and declining is cheaper than interrupting someone.
|
|
493
|
+
*
|
|
494
|
+
* WHOLE VOCABULARY ENTRIES, never their constituent words. Splitting them makes almost
|
|
495
|
+
* everything look shared: Load Testing's "load test" and tm's "test case" both yield
|
|
496
|
+
* `test`, which flagged 135 of the 198 eval queries. Matching entries whole flags 27.
|
|
497
|
+
*
|
|
498
|
+
* A UNIQUE TERM SETTLES IT. "create a test case in a folder" contains `folder`, which
|
|
499
|
+
* both products claim, and `test case`, which only tm does — so the query has already
|
|
500
|
+
* answered the question and there is nothing to ask. Only queries whose every recognised
|
|
501
|
+
* word is shared are genuinely undecided.
|
|
455
502
|
*/
|
|
456
|
-
function
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
503
|
+
export function ambiguousProducts(products, query) {
|
|
504
|
+
const none = { products: [], terms: [] };
|
|
505
|
+
if (terms(query).length === 0)
|
|
506
|
+
return none;
|
|
507
|
+
/**
|
|
508
|
+
* One spelling per word, so a plural cannot masquerade as a different term.
|
|
509
|
+
*
|
|
510
|
+
* Both halves need it. On the QUERY side, the entry is `project` and the user types
|
|
511
|
+
* "list all projects" — the reported case, which exact containment missed. On the
|
|
512
|
+
* VOCABULARY side the same fold is what keeps the answer honest: tm lists both `report`
|
|
513
|
+
* and `reports` as aliases, and treating them as two entries made `reports` look
|
|
514
|
+
* tm-exclusive, so "show me the report" read as settled when both products claim it.
|
|
515
|
+
*/
|
|
516
|
+
const fold = (text) => terms(text).map(singular).join(" ");
|
|
517
|
+
const asked_ = ` ${fold(query || "")} `;
|
|
518
|
+
const owners = new Map();
|
|
519
|
+
for (const [product, entries] of Object.entries(vocabularyOf(products))) {
|
|
520
|
+
for (const entry of entries) {
|
|
521
|
+
for (const word of [entry.entity, ...(entry.aliases ?? [])]) {
|
|
522
|
+
const key = fold(String(word));
|
|
523
|
+
if (!key)
|
|
524
|
+
continue;
|
|
525
|
+
if (!owners.has(key))
|
|
526
|
+
owners.set(key, new Set());
|
|
527
|
+
owners.get(key).add(product);
|
|
528
|
+
}
|
|
470
529
|
}
|
|
471
530
|
}
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
531
|
+
const shared = new Map();
|
|
532
|
+
for (const [key, claimants] of owners) {
|
|
533
|
+
if (!asked_.includes(` ${key} `))
|
|
534
|
+
continue;
|
|
535
|
+
// A term only one product claims decides the query outright.
|
|
536
|
+
if (claimants.size < 2)
|
|
537
|
+
return none;
|
|
538
|
+
shared.set(key, claimants);
|
|
476
539
|
}
|
|
477
|
-
|
|
478
|
-
|
|
540
|
+
if (shared.size === 0)
|
|
541
|
+
return none;
|
|
542
|
+
const claimed = new Set();
|
|
543
|
+
for (const claimants of shared.values())
|
|
544
|
+
for (const product of claimants)
|
|
545
|
+
claimed.add(product);
|
|
546
|
+
return { products: [...claimed].sort(), terms: [...shared.keys()].sort() };
|
|
479
547
|
}
|
|
480
548
|
/**
|
|
481
549
|
* The product's vocabulary, for a caller whose words are not the product's words.
|
|
@@ -486,10 +554,14 @@ function withEveryProductRepresented(scored, limit) {
|
|
|
486
554
|
* language model, and given tm's entity list it maps bucket -> folder without effort. It
|
|
487
555
|
* just cannot guess the list unprompted.
|
|
488
556
|
*
|
|
489
|
-
* Aliases
|
|
490
|
-
* `
|
|
491
|
-
*
|
|
492
|
-
*
|
|
557
|
+
* Aliases and a one-line `description`; `title` stays dropped as a near-duplicate of
|
|
558
|
+
* `entity` ("Test run" next to `test_run` buys nothing). Aliases alone route but do not
|
|
559
|
+
* DEFINE — `result -> outcome, execution-result, test-result` never says whether that is
|
|
560
|
+
* the per-case verdict inside a run or a run-level rollup — so an agent holding names and
|
|
561
|
+
* aliases has exactly one way to find out, which is describeEntity once per entity. For
|
|
562
|
+
* tm that is 19 calls at ~1.4KB each, ~27KB, to answer what ~1.6KB of description answers
|
|
563
|
+
* here for every entity at once. The `key_facts` remain out: ten times the size, and the
|
|
564
|
+
* caller can ask describeEntity for the one entity it settles on.
|
|
493
565
|
*
|
|
494
566
|
* Its share of the response grew when search became a shortlist: 3.2KB against a 38KB full
|
|
495
567
|
* search was 8%, against a 6.8KB shortlist it is nearly half. The absolute cost did not
|
|
@@ -504,8 +576,12 @@ export function vocabularyOf(products, only) {
|
|
|
504
576
|
const entries = [];
|
|
505
577
|
for (const [entity, doc] of Object.entries(bundle.entities || {})) {
|
|
506
578
|
const aliases = (doc.aliases || []);
|
|
579
|
+
const description = doc.description;
|
|
507
580
|
entries.push({
|
|
508
581
|
entity,
|
|
582
|
+
// Before the aliases, because it is what the reader needs first: what the thing
|
|
583
|
+
// is, then what else it is called.
|
|
584
|
+
...(description ? { description } : {}),
|
|
509
585
|
...(aliases.length ? { aliases } : {}),
|
|
510
586
|
});
|
|
511
587
|
}
|
|
@@ -575,7 +651,9 @@ export function searchCapabilities(products, query, options = {}) {
|
|
|
575
651
|
const perfect = weights.reduce((sum, w) => sum + IDENTITY_WEIGHT * w, 0);
|
|
576
652
|
const coverage = perfect > 0 ? topMatched / perfect : 1;
|
|
577
653
|
return {
|
|
578
|
-
hits:
|
|
654
|
+
hits: scored
|
|
655
|
+
.slice(0, limit)
|
|
656
|
+
.map(({ product, capability }) => ({ product, capability })),
|
|
579
657
|
truncated: scored.length > limit,
|
|
580
658
|
total_matched: scored.length,
|
|
581
659
|
top_matched: topMatched,
|
|
@@ -148,6 +148,20 @@ export interface Capability {
|
|
|
148
148
|
}
|
|
149
149
|
export interface EntityDoc {
|
|
150
150
|
title?: string;
|
|
151
|
+
/**
|
|
152
|
+
* One line saying what this entity IS. Rides on `listProducts`, next to the aliases.
|
|
153
|
+
*
|
|
154
|
+
* Aliases route; they do not define. `version -> history, revision, version-history`
|
|
155
|
+
* tells a caller which words land here and nothing about whether that is the versioning
|
|
156
|
+
* of a test case or of a project. Without this line the only way to find out is
|
|
157
|
+
* `describeEntity`, once per entity — 19 calls at ~1.4KB for tm, against ~1.6KB to
|
|
158
|
+
* answer it for all of them at once.
|
|
159
|
+
*
|
|
160
|
+
* Capped at 140 characters by the build, and dropped rather than truncated when longer.
|
|
161
|
+
* Often absent: a product that has not authored these emits none (loadtesting has 0 of
|
|
162
|
+
* 9 today), and absence means "unwritten", never an error.
|
|
163
|
+
*/
|
|
164
|
+
description?: string;
|
|
151
165
|
aliases?: string[];
|
|
152
166
|
id_convention?: string;
|
|
153
167
|
parents?: string[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@browserstack/mcp-server",
|
|
3
|
-
"version": "1.5.0-beta.
|
|
3
|
+
"version": "1.5.0-beta.17",
|
|
4
4
|
"description": "BrowserStack's Official MCP Server",
|
|
5
5
|
"mcpName": "io.github.browserstack/mcp-server",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -15,7 +15,11 @@
|
|
|
15
15
|
"dev": "tsx watch --clear-screen=false src/index.ts",
|
|
16
16
|
"test": "vitest run",
|
|
17
17
|
"lint": "eslint . --ext .ts",
|
|
18
|
-
"format": "prettier --write \"src/**/*.ts\""
|
|
18
|
+
"format": "prettier --write \"src/**/*.ts\"",
|
|
19
|
+
"eval:index": "tsx scripts/eval-index.mts",
|
|
20
|
+
"seed:live": "tsx scripts/seed-live.mts",
|
|
21
|
+
"plan:tests": "tsx scripts/plan-capability-tests.mts",
|
|
22
|
+
"replay:live": "tsx scripts/replay-live.mts"
|
|
19
23
|
},
|
|
20
24
|
"bin": {
|
|
21
25
|
"browserstack-mcp-server": "dist/index.js"
|