@browserstack/mcp-server 1.5.0-beta.17 → 1.5.0-beta.19

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.
@@ -20,7 +20,7 @@ import { indexPaths, isEnabled, resolveBaseUrl } from "./config.js";
20
20
  import { fetchTransport } from "./egress.js";
21
21
  import { CapabilityRegistry, InvocationError, resolveResponses, } from "./index-loader.js";
22
22
  import { invoke } from "./resolve.js";
23
- import { ambiguousProducts, searchCapabilities, singular, terms, vocabularyOf, } from "./search.js";
23
+ import { ambiguousProducts, isBrowseQuery, searchCapabilities, singular, terms, vocabularyOf, } from "./search.js";
24
24
  export const PERMISSION_VALUES = ["not_asked", "granted", "denied"];
25
25
  /**
26
26
  * The search-side twin of `user_permission`: did a human choose the product, or did you?
@@ -219,10 +219,18 @@ export function addCapabilityRegistryTools(server, deps, config) {
219
219
  "It does NOT carry parameters or response shapes. Once you have picked one, call " +
220
220
  "describeCapability for its full contract, then invokeCapability. Fetching the " +
221
221
  "contract only for the one you chose is the difference between ~1k and ~8.6k tokens " +
222
- "a search. Results are ranked and capped, and `truncated` says when more matched.", {
222
+ "a search. Results are ranked and capped. " +
223
+ "TO SEE EVERYTHING rather than search, pass query='*' — that lists the product " +
224
+ "(honouring `entity` and `mode`) in a stable order instead of ranking it, which is " +
225
+ "what you want when the task is 'what can this product do' rather than a lookup. " +
226
+ "TO PAGE, send `offset`: every response carries `offset` and, when more matched, " +
227
+ "`next_offset` — resend the SAME query with that value. `next_offset` is absent on " +
228
+ "the last page, so walk until it stops rather than comparing counts.", {
223
229
  query: z
224
230
  .string()
225
- .describe("What you are trying to do, in plain language."),
231
+ .describe("What you are trying to do, in plain language. Pass '*' (or 'all') to BROWSE " +
232
+ "instead of search: every capability in the product, in a stable order, " +
233
+ "paginated — use it when you want to see the surface rather than find one thing."),
226
234
  entity: z
227
235
  .string()
228
236
  .optional()
@@ -257,13 +265,18 @@ export function addCapabilityRegistryTools(server, deps, config) {
257
265
  .optional()
258
266
  .describe("Restrict to reads or writes. Omit to let the query decide."),
259
267
  limit: z.number().optional().describe("Max results (default 8)."),
268
+ offset: z
269
+ .number()
270
+ .optional()
271
+ .describe("Skip this many results — send the `next_offset` from the previous response " +
272
+ "with the SAME query to get the next page. Omit for the first page."),
260
273
  }, {
261
274
  title: "Search Capabilities",
262
275
  readOnlyHint: true,
263
276
  destructiveHint: false,
264
277
  idempotentHint: true,
265
278
  openWorldHint: false,
266
- }, async ({ query, entity, product, product_choice, mode, limit }) => {
279
+ }, async ({ query, entity, product, product_choice, mode, limit, offset }) => {
267
280
  track("searchCapability");
268
281
  // THE GATE. Nothing here can tell whether a human was asked — same as the write
269
282
  // gate, which also takes the caller's word. What it can do is refuse to answer a
@@ -274,7 +287,13 @@ export function addCapabilityRegistryTools(server, deps, config) {
274
287
  // both products claim. 27 of the 198 eval queries, against 135 if constituent words
275
288
  // counted. `entity` is an explicit narrowing, so a caller that named one has already
276
289
  // been specific enough and is not asked again.
277
- if (product_choice !== "user_confirmed" && !entity) {
290
+ // The ambiguity gate asks the USER which product a word belongs to. A browse query
291
+ // contains no word to be ambiguous about — `*` names nothing — so there is nothing
292
+ // to ask, and asking anyway would block the one query whose whole purpose is to
293
+ // show what a product contains.
294
+ if (product_choice !== "user_confirmed" &&
295
+ !entity &&
296
+ !isBrowseQuery(query)) {
278
297
  const ambiguity = ambiguousProducts(registry.index.products, query);
279
298
  if (ambiguity.products.length > 1) {
280
299
  // EVERYTHING NEEDED TO ASK, IN THE REFUSAL. Telling the agent to go and call
@@ -323,6 +342,7 @@ export function addCapabilityRegistryTools(server, deps, config) {
323
342
  product,
324
343
  mode: mode,
325
344
  limit,
345
+ offset,
326
346
  });
327
347
  return ok({
328
348
  // NO build_id. It is provenance — for our logs and for cache busting — and
@@ -18,6 +18,14 @@ import { Capability, Mode, ProductIndex } from "./types.js";
18
18
  * camelCase is split before lowercasing, so `testRunId`, `test_run_id` and `test run id`
19
19
  * all tokenize alike.
20
20
  */
21
+ /**
22
+ * Is this the browse wildcard rather than a search?
23
+ *
24
+ * Accepts `*` and `all` — both are what a caller reaches for when the intent is "show me
25
+ * everything", and treating only one of them as the wildcard means the other silently
26
+ * becomes a bad search. Whitespace is tolerated so a pasted value still works.
27
+ */
28
+ export declare function isBrowseQuery(query?: string): boolean;
21
29
  export declare function terms(text: string | undefined): string[];
22
30
  /**
23
31
  * A term plus its naive singular variants.
@@ -72,6 +80,18 @@ export interface SearchResult {
72
80
  hits: SearchHit[];
73
81
  truncated: boolean;
74
82
  total_matched: number;
83
+ /**
84
+ * Where this page started, and where the next one begins.
85
+ *
86
+ * `truncated` has always said more matched than fit; nothing said how to reach it, so
87
+ * the only way past the first page was to raise `limit` and re-read everything already
88
+ * seen. `next_offset` is absent on the last page, which is what makes "walk until it
89
+ * stops" terminate without the caller comparing counts.
90
+ */
91
+ offset: number;
92
+ next_offset?: number;
93
+ /** True when the query was the browse wildcard rather than a search. */
94
+ browse?: boolean;
75
95
  /**
76
96
  * The best pre-penalty term score, and whether it is weak enough to doubt.
77
97
  *
@@ -146,4 +166,5 @@ export declare function searchCapabilities(products: Record<string, ProductIndex
146
166
  product?: string;
147
167
  mode?: Mode;
148
168
  limit?: number;
169
+ offset?: number;
149
170
  }): SearchResult;
@@ -111,6 +111,17 @@ const PLURAL_INTENT = new Set([
111
111
  * camelCase is split before lowercasing, so `testRunId`, `test_run_id` and `test run id`
112
112
  * all tokenize alike.
113
113
  */
114
+ /**
115
+ * Is this the browse wildcard rather than a search?
116
+ *
117
+ * Accepts `*` and `all` — both are what a caller reaches for when the intent is "show me
118
+ * everything", and treating only one of them as the wildcard means the other silently
119
+ * becomes a bad search. Whitespace is tolerated so a pasted value still works.
120
+ */
121
+ export function isBrowseQuery(query) {
122
+ const q = (query || "").trim().toLowerCase();
123
+ return q === "*" || q === "all";
124
+ }
114
125
  export function terms(text) {
115
126
  return [
116
127
  ...(text || "")
@@ -592,6 +603,39 @@ export function vocabularyOf(products, only) {
592
603
  }
593
604
  export function searchCapabilities(products, query, options = {}) {
594
605
  const limit = options.limit && options.limit > 0 ? options.limit : 8;
606
+ const offset = options.offset && options.offset > 0 ? Math.floor(options.offset) : 0;
607
+ // BROWSE MODE: `*` lists the product instead of searching it.
608
+ //
609
+ // Scoring keeps only capabilities with matched > 0, so there was no query that returned
610
+ // everything — an agent that wanted to see what exists had to guess words until the
611
+ // shortlist looked complete, and could never tell when it was. `*` skips scoring
612
+ // entirely and pages the filtered set in a STABLE order (name, then path), because a
613
+ // ranked order is not a safe thing to paginate: ranking is query-dependent and ties
614
+ // break arbitrarily, so page 2 of a ranked list can repeat or skip rows. Browse is
615
+ // ordered by identity, so offset means the same thing on every call.
616
+ if (isBrowseQuery(query)) {
617
+ const all = Object.entries(products)
618
+ .filter(([name]) => !options.product || name === options.product)
619
+ .flatMap(([name, bundle]) => bundle.capabilities
620
+ .filter((capability) => (!options.entity || capability.entity === options.entity) &&
621
+ (!options.mode || capability.mode === options.mode))
622
+ .map((capability) => ({ product: name, capability })))
623
+ .sort((a, b) => (a.capability.name || "").localeCompare(b.capability.name || "") ||
624
+ a.capability.path.localeCompare(b.capability.path));
625
+ const page = all.slice(offset, offset + limit);
626
+ const end = offset + page.length;
627
+ return {
628
+ hits: page,
629
+ truncated: end < all.length,
630
+ total_matched: all.length,
631
+ top_matched: 0,
632
+ coverage: 1,
633
+ weak: false,
634
+ offset,
635
+ ...(end < all.length ? { next_offset: end } : {}),
636
+ browse: true,
637
+ };
638
+ }
595
639
  // Forms are computed once per query, not per capability: 173 capabilities x 6 haystacks
596
640
  // would otherwise rebuild the same handful of strings a thousand times.
597
641
  const wanted = terms(query).map(termForms);
@@ -650,12 +694,14 @@ export function searchCapabilities(products, query, options = {}) {
650
694
  // products and across `product`/`entity` narrowing — which the raw score is not.
651
695
  const perfect = weights.reduce((sum, w) => sum + IDENTITY_WEIGHT * w, 0);
652
696
  const coverage = perfect > 0 ? topMatched / perfect : 1;
697
+ const page = scored.slice(offset, offset + limit);
698
+ const end = offset + page.length;
653
699
  return {
654
- hits: scored
655
- .slice(0, limit)
656
- .map(({ product, capability }) => ({ product, capability })),
657
- truncated: scored.length > limit,
700
+ hits: page.map(({ product, capability }) => ({ product, capability })),
701
+ truncated: end < scored.length,
658
702
  total_matched: scored.length,
703
+ offset,
704
+ ...(end < scored.length ? { next_offset: end } : {}),
659
705
  top_matched: topMatched,
660
706
  coverage,
661
707
  weak: wanted.length > 0 && perfect > 0 && coverage < WEAK_COVERAGE,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@browserstack/mcp-server",
3
- "version": "1.5.0-beta.17",
3
+ "version": "1.5.0-beta.19",
4
4
  "description": "BrowserStack's Official MCP Server",
5
5
  "mcpName": "io.github.browserstack/mcp-server",
6
6
  "main": "dist/index.js",
@@ -16,6 +16,7 @@
16
16
  "test": "vitest run",
17
17
  "lint": "eslint . --ext .ts",
18
18
  "format": "prettier --write \"src/**/*.ts\"",
19
+ "check:contract": "python3 scripts/check-contract.py capability/tm.capability-index.json --baseline scripts/contract-baseline/tm.json --quiet && python3 scripts/check-contract.py capability/loadtesting.capability-index.json --baseline scripts/contract-baseline/loadtesting.json --quiet",
19
20
  "eval:index": "tsx scripts/eval-index.mts",
20
21
  "seed:live": "tsx scripts/seed-live.mts",
21
22
  "plan:tests": "tsx scripts/plan-capability-tests.mts",