@browserstack/mcp-server 1.5.0-beta.10 → 1.5.0-beta.12

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.
@@ -16,7 +16,7 @@
16
16
  *
17
17
  * `_` used to be a word character, which made `test_case` a single token while every
18
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
19
+ * string `listProducts` hands back, so a caller following the documented flow searched with
20
20
  * a term guaranteed to score zero: "list test_runs" matched 19 capabilities and put an
21
21
  * admin settings endpoint first, where "list test runs" matched 103 and put the test-runs
22
22
  * listing first.
@@ -337,7 +337,7 @@ function score(capability, wanted, weights, aliases, hint, plural) {
337
337
  if (wanted.length === 0)
338
338
  return { matched: 1, ranked: 1 };
339
339
  const haystacks = [
340
- [identityText(capability), 6],
340
+ [identityText(capability), IDENTITY_WEIGHT],
341
341
  [capability.entity, 4],
342
342
  [(aliases[capability.entity] || []).join(" "), 4],
343
343
  [capability.intent || "", 2],
@@ -412,19 +412,71 @@ function score(capability, wanted, weights, aliases, hint, plural) {
412
412
  return { matched, ranked };
413
413
  }
414
414
  /**
415
- * Below this, the caller is probably not speaking the product's language.
415
+ * How much of a perfect match the best hit actually achieved, below which we doubt it.
416
416
  *
417
- * Measured on tm: vocabulary misses top out at 1.98–2.15 ("where do my things live", "make a
418
- * new bucket for my tests") while queries that work reach 6.5–12. The two ranges do NOT
419
- * separate cleanly "list all projects" scores 1.58 and is nonetheless answered correctly
420
- * at rank 1, because `projects` is in 156 of 173 paths and worth almost nothing.
417
+ * A RATIO, because the raw score is corpus-relative and an absolute threshold therefore
418
+ * cannot travel. `rarity` is measured over whatever is being searched, so the same query
419
+ * scores differently depending on scope: "list my load tests" tops out at 1.9 against Load
420
+ * Testing alone and 10.1 against both products. The old absolute cut of 3 called the first
421
+ * of those weak and the second strong — the same query, the same right answer, opposite
422
+ * verdicts, with the smaller corpus penalised precisely because it is smaller.
421
423
  *
422
- * So this is set generously and false positives are accepted, which is sound only because
423
- * the vocabulary block is ADDITIVE: it arrives next to the results, never instead of them.
424
- * Over-triggering costs ~1.5KB against a response that is routinely 38KB; under-triggering
425
- * costs the caller a wrong answer with no hint that it is wrong. Those are not symmetric.
424
+ * Both halves of this ratio move with the corpus, so it does not:
425
+ *
426
+ * coverage = top_matched / (identity weight x Σ rarity of the query's terms)
427
+ *
428
+ * — "what fraction of a perfect identity match did the best hit manage". Above 1 is
429
+ * ordinary, since a good hit matches several fields, not just the route.
430
+ *
431
+ * Measured across tm-only, LT-only and both: genuine misses land at 0.00–0.18, queries
432
+ * that work at 0.44–1.94. The absolute score cannot separate those (misses reach 2.9,
433
+ * good queries drop to 1.9); this does, with the gap in the same place in all three
434
+ * corpora, which is the property that was missing.
435
+ */
436
+ const WEAK_COVERAGE = 0.25;
437
+ /** The heaviest field, and so the yardstick a perfect match is measured against. */
438
+ 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
+ /**
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.
426
455
  */
427
- const WEAK_MATCH = 3;
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);
470
+ }
471
+ }
472
+ for (const hit of scored) {
473
+ if (taken.size >= limit)
474
+ break;
475
+ taken.add(hit);
476
+ }
477
+ // Emit in the original ranked order: the floor decides WHO appears, never in what order.
478
+ return scored.filter((hit) => taken.has(hit));
479
+ }
428
480
  /**
429
481
  * The product's vocabulary, for a caller whose words are not the product's words.
430
482
  *
@@ -434,10 +486,15 @@ const WEAK_MATCH = 3;
434
486
  * language model, and given tm's entity list it maps bucket -> folder without effort. It
435
487
  * just cannot guess the list unprompted.
436
488
  *
437
- * Aliases only, deliberately. They are the vocabulary map 19 entities in ~1.5KB, against a
438
- * response that is routinely 38KB. The entity `key_facts` are richer prose but ten times the
439
- * size, and a caller who needs them can ask describeEntity once it knows which entity to ask
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
440
492
  * about — which is exactly what this hands over.
493
+ *
494
+ * Its share of the response grew when search became a shortlist: 3.2KB against a 38KB full
495
+ * search was 8%, against a 6.8KB shortlist it is nearly half. The absolute cost did not
496
+ * change and is still under a thousand tokens — far less than one wrong invoke — so this
497
+ * is budgeted in bytes rather than as a fraction of a baseline that now moves.
441
498
  */
442
499
  export function vocabularyOf(products, only) {
443
500
  const out = {};
@@ -449,9 +506,6 @@ export function vocabularyOf(products, only) {
449
506
  const aliases = (doc.aliases || []);
450
507
  entries.push({
451
508
  entity,
452
- ...(doc.title
453
- ? { title: doc.title }
454
- : {}),
455
509
  ...(aliases.length ? { aliases } : {}),
456
510
  });
457
511
  }
@@ -515,13 +569,17 @@ export function searchCapabilities(products, query, options = {}) {
515
569
  // different questions: the mode and cardinality constants can lift a weakly-matched
516
570
  // capability to the top of a page that is entirely wrong.
517
571
  const topMatched = scored.reduce((best, s) => Math.max(best, s.matched), 0);
572
+ // What a perfect identity match would have scored for THIS query in THIS corpus. Both
573
+ // this and `topMatched` scale with the corpus, so their ratio is comparable across
574
+ // products and across `product`/`entity` narrowing — which the raw score is not.
575
+ const perfect = weights.reduce((sum, w) => sum + IDENTITY_WEIGHT * w, 0);
576
+ const coverage = perfect > 0 ? topMatched / perfect : 1;
518
577
  return {
519
- hits: scored
520
- .slice(0, limit)
521
- .map(({ product, capability }) => ({ product, capability })),
578
+ hits: withEveryProductRepresented(scored, limit).map(({ product, capability }) => ({ product, capability })),
522
579
  truncated: scored.length > limit,
523
580
  total_matched: scored.length,
524
581
  top_matched: topMatched,
525
- weak: wanted.length > 0 && topMatched < WEAK_MATCH,
582
+ coverage,
583
+ weak: wanted.length > 0 && perfect > 0 && coverage < WEAK_COVERAGE,
526
584
  };
527
585
  }
@@ -27,7 +27,47 @@ export declare const SUPPORTED_SCHEMA_VERSION = 1;
27
27
  export declare const ENVELOPE_KEYS: readonly ["schema_version", "version", "build_id", "harness_commit", "products"];
28
28
  export type Mode = "read" | "write" | "destructive";
29
29
  /** One parameter, under the name the OpenAPI spec itself gives it. */
30
- export interface WireParam {
30
+ /**
31
+ * The constraints a parameter may declare beyond its type.
32
+ *
33
+ * ALL OPTIONAL, and absent means unchecked. The released artifact carries none of these
34
+ * yet — tm's spec holds 59 `format`, 50 `default`, 23 `minimum`, 12 `maximum`, 10
35
+ * `minItems`, 7 `maxItems`, 4 `minLength`, 3 `pattern` and 2 `maxLength` that the export
36
+ * currently drops — so this ships inert and starts working the moment the export carries
37
+ * them. An older index must keep binding exactly as it does today.
38
+ */
39
+ export interface WireConstraints {
40
+ /** Numbers. `multipleOf` is checked on the coerced number, not the raw string. */
41
+ minimum?: number;
42
+ maximum?: number;
43
+ multipleOf?: number;
44
+ /** Strings. `pattern` is an unanchored regex, compiled with `u` for `\p{…}` classes. */
45
+ minLength?: number;
46
+ maxLength?: number;
47
+ pattern?: string;
48
+ format?: string;
49
+ /** Arrays. */
50
+ minItems?: number;
51
+ maxItems?: number;
52
+ uniqueItems?: boolean;
53
+ /**
54
+ * The value the product uses when the field is omitted.
55
+ *
56
+ * PUBLISHED, NOT INJECTED. Telling a caller the default is what stops the redundant send;
57
+ * filling it in here would put a value on the wire that the caller never chose, and pin
58
+ * a server-side default that is free to change. Omission and "explicitly the default" are
59
+ * different requests, and only the product knows whether that difference matters.
60
+ */
61
+ default?: unknown;
62
+ }
63
+ /** One field inside an array item or a nested object — same constraints, one level down. */
64
+ export interface WireField extends WireConstraints {
65
+ name: string;
66
+ type: string;
67
+ required?: true;
68
+ values?: unknown[];
69
+ }
70
+ export interface WireParam extends WireConstraints {
31
71
  name: string;
32
72
  type: string;
33
73
  required?: true;
@@ -35,11 +75,7 @@ export interface WireParam {
35
75
  example?: unknown;
36
76
  description?: string;
37
77
  /** Field names/types one level inside an array item or nested object. */
38
- fields?: {
39
- name: string;
40
- type: string;
41
- required?: true;
42
- }[];
78
+ fields?: WireField[];
43
79
  /**
44
80
  * Where a body field sits in the JSON, when that differs from its name. Published
45
81
  * because the nesting is not guessable and getting it wrong fails silently — tm's folder
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@browserstack/mcp-server",
3
- "version": "1.5.0-beta.10",
3
+ "version": "1.5.0-beta.12",
4
4
  "description": "BrowserStack's Official MCP Server",
5
5
  "mcpName": "io.github.browserstack/mcp-server",
6
6
  "main": "dist/index.js",