@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.
@@ -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.25;
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
- * Fill the page by rank, but never let one product's SIZE shut another out entirely.
443
- *
444
- * Scores are comparable across products rarity spans the whole corpus by design — but
445
- * the number of chances to score is not: tm has 173 capabilities to Load Testing's 20. On
446
- * a query using words both products share ("test", "config", "tag"), tm simply has more
447
- * entries near the top and takes the page. Measured before this: "add a tag to xyz test"
448
- * returned tm tm LT tm tm, and an agent reading the first row went to the wrong product.
449
- *
450
- * So each product that matched at all is guaranteed a couple of slots, and everything else
451
- * is still strict rank order. This does NOT reorder anything or touch scoring — the best
452
- * hit stays the best hit it only refuses to let a product be invisible because it is
453
- * small. The cost when a query really is single-product is a row or two of another
454
- * product's shortlist, which since the split is a few hundred bytes.
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 withEveryProductRepresented(scored, limit) {
457
- if (scored.length <= limit)
458
- return scored;
459
- const products = new Set(scored.map((s) => s.product));
460
- if (products.size < 2)
461
- return scored.slice(0, limit);
462
- const taken = new Set();
463
- // Reserve first, so a product near the bottom of the ranking still gets its footing.
464
- for (const product of products) {
465
- for (const hit of scored
466
- .filter((s) => s.product === product)
467
- .slice(0, PRODUCT_FLOOR)) {
468
- if (taken.size < limit)
469
- taken.add(hit);
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
- for (const hit of scored) {
473
- if (taken.size >= limit)
474
- break;
475
- taken.add(hit);
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
- // Emit in the original ranked order: the floor decides WHO appears, never in what order.
478
- return scored.filter((hit) => taken.has(hit));
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 only, and `title` dropped as a near-duplicate of `entity` ("Test run" next to
490
- * `test_run` buys nothing). The entity `key_facts` are richer prose but ten times the size,
491
- * and a caller who needs them can ask describeEntity once it knows which entity to ask
492
- * aboutwhich is exactly what this hands over.
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: withEveryProductRepresented(scored, limit).map(({ product, capability }) => ({ product, capability })),
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.15",
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"