@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
|
|
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
|
-
|
|
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:
|
|
655
|
-
|
|
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.
|
|
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",
|