@browserstack/mcp-server 1.5.0-beta.15 → 1.5.0-beta.16

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.
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "version": "1.5",
4
- "build_id": "bd02ae3b_2026-09-10T11:51:51Z",
3
+ "version": "1.6",
4
+ "build_id": "2d3d82f8_2026-09-11T09:37:38Z",
5
5
  "tm": {
6
- "summary": "BrowserStack Test Management API (v1 the SSO/OAuth app API, session-authenticated).",
6
+ "summary": "BrowserStack Test Management: author and organise test cases, plan and track their execution, and report on the results. Everything is scoped to a project, which owns the folders, cases, runs, plans and reports beneath it. Search and filter cases; create and update cases, runs and plans; record results; bulk edit, move, copy, archive and delete; restructure folders; manage tags and custom-field definitions; assign, clone and close runs; review AI duplicate recommendations; read the activity feed and a case's version history; and create, schedule and download reports. Cases are authored and their outcomes recorded here, but tests are not RUN here: execution happens in your own runner or another BrowserStack product. Renaming and deleting a project are not exposed. Destructive capabilities exist and always need explicit human approval.",
7
7
  "capabilities": [
8
8
  {
9
9
  "name": "assign_test_run_owner",
@@ -18556,7 +18556,7 @@
18556
18556
  },
18557
18557
  "selection": {
18558
18558
  "type": "object",
18559
- "description": "the primary project's persisted folder-structured test-case selection (with select_all / per-folder flags + unique_test_case_count), in the same shape as the test-run edit/selection endpoint. `{}` when the report has no saved selection for the primary project. Used by the Edit dialog to re-check saved test cases (including folder/project-level select_all, which the flat ids cannot represent).",
18559
+ "description": "The primary project's persisted folder-structured test-case selection (with select_all / per-folder flags + unique_test_case_count), in the same shape as the test-run edit/selection endpoint. `{}` when the report has no saved selection for the primary project. Used by the Edit dialog to re-check saved test cases (including folder/project-level select_all, which the flat ids cannot represent).",
18560
18560
  "example": {
18561
18561
  "select_all": false,
18562
18562
  "folders": {
@@ -18573,7 +18573,7 @@
18573
18573
  },
18574
18574
  "shared_selection": {
18575
18575
  "type": "object",
18576
- "description": "per-project folder-structured selection for the report's non-primary projects, keyed by project id ({ \"<project_id>\": <selection> }). Present only for cross-project Test Case Activity reports; `{}` otherwise.",
18576
+ "description": "Per-project folder-structured selection for the report's non-primary projects, keyed by project id ({ \"<project_id>\": <selection> }). Present only for cross-project Test Case Activity reports; `{}` otherwise.",
18577
18577
  "example": {
18578
18578
  "10209": {
18579
18579
  "select_all": true,
@@ -19455,6 +19455,7 @@
19455
19455
  "activity": {
19456
19456
  "entity": "activity",
19457
19457
  "title": "Activity feed (audit trail)",
19458
+ "description": "Group-wide audit trail of who changed what, on which entity, and when.",
19458
19459
  "aliases": [
19459
19460
  "activity",
19460
19461
  "activities",
@@ -19500,6 +19501,7 @@
19500
19501
  "attachment": {
19501
19502
  "entity": "attachment",
19502
19503
  "title": "Attachments",
19504
+ "description": "A file hanging off a parent test case or test result.",
19503
19505
  "aliases": [
19504
19506
  "attachment",
19505
19507
  "file",
@@ -19535,6 +19537,7 @@
19535
19537
  "configuration": {
19536
19538
  "entity": "configuration",
19537
19539
  "title": "Configurations",
19540
+ "description": "An operating system, browser or device combination that a test run executes against.",
19538
19541
  "aliases": [
19539
19542
  "config",
19540
19543
  "configuration",
@@ -19561,6 +19564,7 @@
19561
19564
  "custom_field": {
19562
19565
  "entity": "custom_field",
19563
19566
  "title": "Custom fields & system fields",
19567
+ "description": "A user-defined field on test cases, results or plans, alongside the built-in system fields.",
19564
19568
  "aliases": [
19565
19569
  "custom field",
19566
19570
  "custom fields",
@@ -19612,6 +19616,7 @@
19612
19616
  "dataset": {
19613
19617
  "entity": "dataset",
19614
19618
  "title": "Dataset",
19619
+ "description": "A named table of variables and rows that drives data-driven execution of a test case.",
19615
19620
  "aliases": [
19616
19621
  "datasets",
19617
19622
  "data-driven-data",
@@ -19648,6 +19653,7 @@
19648
19653
  "duplicate": {
19649
19654
  "entity": "duplicate",
19650
19655
  "title": "Duplicate recommendation (AI dedupe)",
19656
+ "description": "A machine-suggested pair of near-identical test cases, for a human to review and merge.",
19651
19657
  "aliases": [
19652
19658
  "duplicate",
19653
19659
  "duplicates",
@@ -19687,6 +19693,7 @@
19687
19693
  "exploratory_session": {
19688
19694
  "entity": "exploratory_session",
19689
19695
  "title": "Exploratory session",
19696
+ "description": "An unscripted, charter-driven testing session and the observations recorded during it.",
19690
19697
  "aliases": [
19691
19698
  "session",
19692
19699
  "exploratory",
@@ -19739,6 +19746,7 @@
19739
19746
  "filter": {
19740
19747
  "entity": "filter",
19741
19748
  "title": "Saved filter view",
19749
+ "description": "A saved, named set of filter conditions a user can reapply to a list view.",
19742
19750
  "aliases": [
19743
19751
  "filter",
19744
19752
  "filters",
@@ -19779,6 +19787,7 @@
19779
19787
  "folder": {
19780
19788
  "entity": "folder",
19781
19789
  "title": "Folder",
19790
+ "description": "A container organising test cases inside a project; folders nest to form a tree.",
19782
19791
  "aliases": [
19783
19792
  "dir",
19784
19793
  "directory"
@@ -19819,6 +19828,7 @@
19819
19828
  "project": {
19820
19829
  "entity": "project",
19821
19830
  "title": "Project",
19831
+ "description": "The top-level container that owns every folder, test case, run, plan and report.",
19822
19832
  "aliases": [
19823
19833
  "pr",
19824
19834
  "workspace"
@@ -19875,6 +19885,7 @@
19875
19885
  "report": {
19876
19886
  "entity": "report",
19877
19887
  "title": "Report",
19888
+ "description": "A saved, optionally scheduled analytics view over a project's test data.",
19878
19889
  "aliases": [
19879
19890
  "reports",
19880
19891
  "scheduled-report",
@@ -19931,6 +19942,7 @@
19931
19942
  "result": {
19932
19943
  "entity": "result",
19933
19944
  "title": "Result",
19945
+ "description": "The recorded outcome of one test case in one run: its status, and who set it.",
19934
19946
  "aliases": [
19935
19947
  "outcome",
19936
19948
  "execution-result",
@@ -19968,6 +19980,7 @@
19968
19980
  "shared_step": {
19969
19981
  "entity": "shared_step",
19970
19982
  "title": "Shared step",
19983
+ "description": "A reusable block of test steps that many test cases embed by reference.",
19971
19984
  "aliases": [
19972
19985
  "shared step",
19973
19986
  "shared steps",
@@ -19999,6 +20012,7 @@
19999
20012
  "tag": {
20000
20013
  "entity": "tag",
20001
20014
  "title": "Tag",
20015
+ "description": "A free-text label on a test case, run or plan, used to group and filter them.",
20002
20016
  "aliases": [
20003
20017
  "tags",
20004
20018
  "label",
@@ -20040,6 +20054,7 @@
20040
20054
  "test_case": {
20041
20055
  "entity": "test_case",
20042
20056
  "title": "Test case",
20057
+ "description": "The unit of test logic: title, preconditions, steps and expected results.",
20043
20058
  "aliases": [
20044
20059
  "tc",
20045
20060
  "case",
@@ -20111,6 +20126,7 @@
20111
20126
  "test_plan": {
20112
20127
  "entity": "test_plan",
20113
20128
  "title": "Test plan (and sub-plan)",
20129
+ "description": "A container grouping several test runs, typically for one release or milestone.",
20114
20130
  "aliases": [
20115
20131
  "tp",
20116
20132
  "plan",
@@ -20154,7 +20170,7 @@
20154
20170
  "The read detail has NO data wrapper: the plan sits under a top-level test_plan key, and test_plan.identifier is the TP-NNN / STP-NNN.",
20155
20171
  "Sub-plans are HIDDEN by default in the list. Without include_sub_plans=true you see only top-level plans, so a sub-plan the user named will look missing.",
20156
20172
  "name IS REQUIRED ON EVERY PLAN UPDATE, including one that only touches test_runs. Omit it and the call is rejected with 400 {success: false, error: 'Invalid Update Params'} however valid the rest of the body is — the server's edit branch is gated on name being present. Read the plan first and echo its current name back unchanged. Only a pure plan_status transition and a reviewers-only edit are exempt. Verified live: {test_plan: {test_runs: [198]}} 400s; adding name makes the identical call succeed.",
20157
- "Run ids in test_runs are the INTEGER uuid from a run read — NOT the run's id field, which is the TR-NNN string on every run endpoint (list, plan's run list, single detail alike). Identifiers are forwarded to the backing service uncoerced and fail upstream (observed 502 Upstream backing-service error). Take uuid, never id.",
20173
+ "Run ids in test_runs are the INTEGER uuid from a run read — NOT the run's id field, which is the TR-NNN string on every run endpoint (list, plan's run list, single detail alike). Identifiers are forwarded to the backing service uncoerced and fail upstream (observed 502 Upstream service error). Take uuid, never id.",
20158
20174
  "Linking a run MOVES it: a run belongs to at most one plan, so linking silently unlinks it from the plan that held it, with nothing in the 200 to say so. Read the runs' current membership first and tell the user which plans will lose runs. Verified live.",
20159
20175
  "test_runs accepts an id array OR a bulk-selection object {select_all, de_selected_ids, search_query}. select_all means 'the newest active runs', NOT 'everything matching' — and `filters` is accepted but SILENTLY IGNORED, so a filtered select_all links the whole recent set. Narrow with search_query (name/identifier substring) or send explicit ids. Verified live.",
20160
20176
  "bulk-archive / bulk-retrieve go ASYNC above the bulk threshold and return {success, async: true, unique_id} — the plans are NOT changed yet. Re-read to confirm; see async-jobs.",
@@ -20165,6 +20181,7 @@
20165
20181
  "test_run": {
20166
20182
  "entity": "test_run",
20167
20183
  "title": "Test run",
20184
+ "description": "One execution cycle over a fixed set of test cases.",
20168
20185
  "aliases": [
20169
20186
  "tr",
20170
20187
  "run",
@@ -20221,6 +20238,7 @@
20221
20238
  "user": {
20222
20239
  "entity": "user",
20223
20240
  "title": "User",
20241
+ "description": "A member with access to a project: who you assign a case to, or set as an owner.",
20224
20242
  "aliases": [
20225
20243
  "users",
20226
20244
  "member",
@@ -20257,6 +20275,7 @@
20257
20275
  "version": {
20258
20276
  "entity": "version",
20259
20277
  "title": "Test case version",
20278
+ "description": "A point-in-time snapshot of a test case, for viewing or restoring an earlier revision.",
20260
20279
  "aliases": [
20261
20280
  "history",
20262
20281
  "histories",
@@ -18,6 +18,14 @@ import { BrowserStackConfig } from "../../lib/types.js";
18
18
  import { Credentials, Transport } from "./egress.js";
19
19
  import { CapabilityRegistry } from "./index-loader.js";
20
20
  export declare const PERMISSION_VALUES: readonly ["not_asked", "granted", "denied"];
21
+ /**
22
+ * The search-side twin of `user_permission`: did a human choose the product, or did you?
23
+ *
24
+ * No `denied`. A refused write is a thing the caller must not do; a refused product choice
25
+ * is not a state — the user either named one, in which case you search it, or has not been
26
+ * asked, in which case there is nothing to search yet.
27
+ */
28
+ export declare const PRODUCT_CHOICE_VALUES: readonly ["not_asked", "user_confirmed"];
21
29
  export interface RegistryDeps {
22
30
  registry: CapabilityRegistry;
23
31
  /**
@@ -20,8 +20,16 @@ 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 { searchCapabilities, vocabularyOf } from "./search.js";
23
+ import { ambiguousProducts, searchCapabilities, singular, terms, vocabularyOf, } from "./search.js";
24
24
  export const PERMISSION_VALUES = ["not_asked", "granted", "denied"];
25
+ /**
26
+ * The search-side twin of `user_permission`: did a human choose the product, or did you?
27
+ *
28
+ * No `denied`. A refused write is a thing the caller must not do; a refused product choice
29
+ * is not a state — the user either named one, in which case you search it, or has not been
30
+ * asked, in which case there is nothing to search yet.
31
+ */
32
+ export const PRODUCT_CHOICE_VALUES = ["not_asked", "user_confirmed"];
25
33
  /**
26
34
  * The tool-adder the server factory calls.
27
35
  *
@@ -73,10 +81,13 @@ export function addCapabilityRegistryToolsFromConfig(server, config) {
73
81
  function ok(payload) {
74
82
  return { content: [{ type: "text", text: JSON.stringify(payload) }] };
75
83
  }
76
- function failed(message) {
84
+ function failed(message, extra) {
77
85
  return {
78
86
  content: [
79
- { type: "text", text: JSON.stringify({ ok: false, error: message }) },
87
+ {
88
+ type: "text",
89
+ text: JSON.stringify({ ok: false, error: message, ...extra }),
90
+ },
80
91
  ],
81
92
  isError: true,
82
93
  };
@@ -114,9 +125,10 @@ export function addCapabilityRegistryTools(server, deps, config) {
114
125
  // Telemetry must not decide whether a tool call succeeds.
115
126
  }
116
127
  };
117
- tools.listProducts = server.tool("listProducts", "List the BrowserStack products this surface can reach, with a one-line summary each " +
118
- "and the entities each one models. Start here when you do not know which product a " +
119
- "task belongs to.", {}, {
128
+ tools.listProducts = server.tool("listProducts", "List the BrowserStack products this surface can reach: what each one does, the " +
129
+ "entities it models, and a line saying what each entity is. START HERE " +
130
+ "searchCapability needs a product, and this is what tells you which one. If two " +
131
+ "products could both fit the task, ask the user rather than choosing for them.", {}, {
120
132
  title: "List Capability Products",
121
133
  readOnlyHint: true,
122
134
  destructiveHint: false,
@@ -124,28 +136,34 @@ export function addCapabilityRegistryTools(server, deps, config) {
124
136
  openWorldHint: false,
125
137
  }, async () => {
126
138
  track("listProducts");
127
- const info = registry.buildInfo();
128
139
  return ok({
129
- build_id: registry.buildId,
130
140
  products: registry.productNames().map((name) => ({
131
141
  name,
132
142
  summary: registry.index.products[name].summary,
133
- // THE VOCABULARY, UP FRONT. Routing is this tool's whole job, and a summary
134
- // alone does not do it: "add a tag to xyz test" reads as either product until
135
- // you can see that `tag` exists in one and not the other. Measured across both
136
- // products, 139 of 147 terms resolve to exactly oneso this settles 95% of
137
- // the question before a single search, and makes the remaining 8 (run, project,
138
- // report, result, folder, workspace, execution, history) visibly ambiguous
139
- // instead of silently so.
143
+ // THE ENTITIES AND WHAT EACH ONE IS. Routing is this tool's whole job, and a
144
+ // product summary alone does not do it: "add a tag to xyz test" reads as
145
+ // either product until you can see that `tag` exists in one and not the other.
146
+ // The one-line description is what makes each name mean something `version`
147
+ // alone does not say whether it versions a test case or a project — so an
148
+ // agent can choose here instead of calling describeEntity once per entity to
149
+ // find out, which for tm is 19 calls at ~1.4KB apiece.
140
150
  //
141
- // ~1.9KB for both products, on a tool called once for routing. The same content
142
- // reaches a caller reactively via searchCapability's weak-match block; this is
143
- // the proactive half, for the agent that looks before it leaps.
144
- entities: vocabularyOf(registry.index.products, name)[name] ?? [],
145
- // Provenance for logging and cache-busting only capability resolution must
146
- // never depend on it.
147
- build_id: info[name]?.build_id,
148
- ...(info[name]?.version ? { version: info[name].version } : {}),
151
+ // NAME AND DESCRIPTION ONLY. The aliases used to travel here too, and they are
152
+ // the wrong half for this job: they answer "what else is this called", which
153
+ // matters when a search has already failed on vocabulary, not when choosing a
154
+ // product. They still reach the caller at exactly that moment, in
155
+ // searchCapability's weak-match block, where the full vocabulary is the answer
156
+ // rather than 2.5KB of speculative context on every routing call.
157
+ entities: (vocabularyOf(registry.index.products, name)[name] ?? []).map(({ entity, description }) => ({
158
+ entity,
159
+ ...(description ? { description } : {}),
160
+ })),
161
+ // NO build_id OR version. They are provenance — for our logs and for cache
162
+ // busting — and capability resolution must never depend on them, which means
163
+ // no caller has anything to do with them. They rode on the one call an agent
164
+ // makes before it knows anything, costing context to say nothing actionable.
165
+ // Still on searchCapability's response, where a support question about which
166
+ // index answered can actually be traced to a result.
149
167
  })),
150
168
  });
151
169
  });
@@ -182,20 +200,22 @@ export function addCapabilityRegistryTools(server, deps, config) {
182
200
  }
183
201
  return ok({ product, entity, ...doc });
184
202
  });
185
- tools.searchCapability = server.tool("searchCapability", "Find endpoints this surface can call, by plain language, optionally narrowed to one " +
186
- "entity, product or mode. " +
187
- `Currently loaded products: ${productList} call listProducts for what each does. ` +
203
+ tools.searchCapability = server.tool("searchCapability", "Find endpoints this surface can call, by plain language, within ONE product. " +
204
+ `You must say which: ${productList}. If the task does not name it unambiguously, ` +
205
+ "call listProducts firstit returns what each product does, the entities each " +
206
+ "models, and what every entity means, which is what settles the choice. Where two " +
207
+ "products use the same word for different things, ask the user rather than picking. " +
188
208
  "Search matches the product's OWN words, not synonyms. When your words are not the " +
189
209
  "product's, the response says so: `weak_match: true` with a `suggested_vocabulary` " +
190
210
  "map of the product's entities and their aliases. Results are still returned, but " +
191
211
  "treat them as unconfirmed — pick the closest entity from that map and search again " +
192
212
  "using its vocabulary, or call describeEntity on it for the fuller picture. That one " +
193
213
  "extra round trip is far cheaper than invoking the wrong capability. " +
194
- "Narrowing with `product` or `entity` sharpens results further. " +
214
+ "Narrowing further with `entity` sharpens results. " +
195
215
  "THIS IS A SHORTLIST, NOT A CONTRACT. Each result carries only what you need to " +
196
- "CHOOSE: `name` (the handle), `product` (which product owns it results can span " +
197
- "products), `mode` (whether it writes), `intent` and `guidance` (what it does and " +
198
- "what goes wrong), and `method`/`path` for products that publish no name yet. " +
216
+ "CHOOSE: `name` (the handle), `product`, `mode` (whether it writes), `intent` and " +
217
+ "`guidance` (what it does and what goes wrong), and `method`/`path` for products " +
218
+ "that publish no name yet. " +
199
219
  "It does NOT carry parameters or response shapes. Once you have picked one, call " +
200
220
  "describeCapability for its full contract, then invokeCapability. Fetching the " +
201
221
  "contract only for the one you chose is the difference between ~1k and ~8.6k tokens " +
@@ -207,9 +227,31 @@ export function addCapabilityRegistryTools(server, deps, config) {
207
227
  .string()
208
228
  .optional()
209
229
  .describe("Restrict to one entity (listProducts names them)."),
210
- product: productArg()
211
- .optional()
212
- .describe(`Restrict to one product: ${productList}. Omit to search all of them.`),
230
+ // REQUIRED, and the reason is the tool that is NOT being called. listProducts
231
+ // carries the routing data — each product's purpose, its entities, and what every
232
+ // entity means but nothing obliged an agent to read it: a search that worked
233
+ // without naming a product meant the routing step could always be skipped, and an
234
+ // optional step in front of a working one is a step that does not happen. Requiring
235
+ // the argument makes the ordering structural instead of advisory.
236
+ //
237
+ // It costs one listProducts call on queries that were already unambiguous — 139 of
238
+ // 147 vocabulary terms resolve to a single product — and buys the eight that are
239
+ // not (run, project, report, result, folder, workspace, execution, history), where
240
+ // the old behaviour was to silently pick whichever product ranked higher. A wasted
241
+ // round trip against a silent wrong-product answer is not a close trade.
242
+ product: productArg().describe(`Which product to search: ${productList}. Call listProducts if the task does ` +
243
+ `not make it obvious, and ask the user when two products could both fit.`),
244
+ // REQUIRED, and self-reported, exactly like `user_permission` on a write. The
245
+ // server cannot see whether you asked anyone; what it can do is refuse to answer a
246
+ // question only the user can settle, and make claiming otherwise an explicit act
247
+ // rather than an omission.
248
+ product_choice: z
249
+ .enum(PRODUCT_CHOICE_VALUES)
250
+ .describe("'user_confirmed' only when the user named the product, or the task names it " +
251
+ "unmistakably. 'not_asked' otherwise — then a query that could mean either " +
252
+ "product is refused and told what to ask, instead of being answered for the " +
253
+ "wrong one. Never search each product in turn and merge the results: that is " +
254
+ "the guess this argument exists to prevent."),
213
255
  mode: z
214
256
  .enum(["read", "write", "destructive"])
215
257
  .optional()
@@ -221,8 +263,61 @@ export function addCapabilityRegistryTools(server, deps, config) {
221
263
  destructiveHint: false,
222
264
  idempotentHint: true,
223
265
  openWorldHint: false,
224
- }, async ({ query, entity, product, mode, limit }) => {
266
+ }, async ({ query, entity, product, product_choice, mode, limit }) => {
225
267
  track("searchCapability");
268
+ // THE GATE. Nothing here can tell whether a human was asked — same as the write
269
+ // gate, which also takes the caller's word. What it can do is refuse to answer a
270
+ // question that is genuinely the user's, so that answering it anyway takes a
271
+ // deliberate claim instead of silence.
272
+ //
273
+ // Only when the query itself cannot settle the choice: every recognised word is one
274
+ // both products claim. 27 of the 198 eval queries, against 135 if constituent words
275
+ // counted. `entity` is an explicit narrowing, so a caller that named one has already
276
+ // been specific enough and is not asked again.
277
+ if (product_choice !== "user_confirmed" && !entity) {
278
+ const ambiguity = ambiguousProducts(registry.index.products, query);
279
+ if (ambiguity.products.length > 1) {
280
+ // EVERYTHING NEEDED TO ASK, IN THE REFUSAL. Telling the agent to go and call
281
+ // listProducts costs a round trip and still leaves it composing a question out
282
+ // of nothing — so it tends to guess instead, which is the behaviour being
283
+ // stopped. What makes the choice answerable is what each product calls the
284
+ // shared word and what it means THERE: tm's project owns folders and test
285
+ // cases, Load Testing's does not.
286
+ const options = ambiguity.products.map((name) => {
287
+ const entities = registry.index.products[name]?.entities ?? {};
288
+ const senses = ambiguity.terms
289
+ .map((term) => {
290
+ const key = Object.keys(entities).find((candidate) => [
291
+ candidate,
292
+ ...(entities[candidate].aliases ?? []),
293
+ ].some((word) => terms(word).map(singular).join(" ") === term));
294
+ const doc = key ? entities[key] : undefined;
295
+ return doc
296
+ ? { term, entity: key, means: doc.description }
297
+ : undefined;
298
+ })
299
+ .filter(Boolean);
300
+ return {
301
+ product: name,
302
+ summary: registry.index.products[name]?.summary,
303
+ ...(senses.length ? { shared_terms: senses } : {}),
304
+ };
305
+ });
306
+ return failed(`'${query}' could mean ${ambiguity.products.join(" or ")} — ` +
307
+ `${ambiguity.terms.map((t) => `'${t}'`).join(", ")} ` +
308
+ `${ambiguity.terms.length === 1 ? "belongs" : "belong"} to both. ` +
309
+ "Put the choice in `clarify` to the USER in their own terms, wait for an " +
310
+ "answer, then resend with product_choice='user_confirmed'. Do NOT search " +
311
+ "each product in turn and merge the results — that answers the question " +
312
+ "instead of asking it.", {
313
+ clarify: {
314
+ question: `Which product do you mean — ${ambiguity.products.join(" or ")}?`,
315
+ shared: ambiguity.terms,
316
+ options,
317
+ },
318
+ });
319
+ }
320
+ }
226
321
  const { hits, weak, top_matched, ...rest } = searchCapabilities(registry.index.products, query, {
227
322
  entity,
228
323
  product,
@@ -230,22 +325,35 @@ export function addCapabilityRegistryTools(server, deps, config) {
230
325
  limit,
231
326
  });
232
327
  return ok({
233
- build_id: registry.buildId,
328
+ // NO build_id. It is provenance — for our logs and for cache busting — and
329
+ // resolution must never depend on it, which is exactly why no caller has anything
330
+ // to do with it. It was also the WHOLE registry's id, so a search scoped to one
331
+ // product still announced every other product's build: metadata about builds the
332
+ // caller did not ask about and cannot act on. The startup log already records
333
+ // what loaded, which is where a question about a stale index gets answered.
334
+ //
234
335
  // A SHORTLIST: only what choosing requires. Parameters and response shapes are 86%
235
336
  // of a full record and are needed for exactly ONE of the eight — the one the caller
236
337
  // picks — so they move to describeCapability. Measured over eight queries: 8.6k
237
338
  // tokens a search becomes ~1k, and even describing all eight results still costs
238
339
  // slightly less than today.
239
340
  //
240
- // `product` is here because results span products and the caller cannot otherwise
241
- // tell a Load Testing row from a Test Management one. `method`/`path` are here
242
- // because Load Testing publishes no names at all — without them its rows would be
243
- // unaddressable, which is worse than verbose.
244
- capabilities: hits.map(({ product: owner, capability }) => ({
245
- ...(capability.name ? { name: capability.name } : {}),
246
- product: owner,
247
- method: capability.method,
248
- path: capability.path,
341
+ // NO ROUTE, AND NO PRODUCT. Both were justified and both justifications expired.
342
+ //
343
+ // `method`/`path` were unconditional because Load Testing published no names, so
344
+ // its rows would otherwise have been unaddressable. It now names all 20, as tm
345
+ // names all 173 every row on this surface is reachable by name. Publishing the
346
+ // route anyway contradicts the premise the whole registry rests on: an agent
347
+ // addresses a capability by a handle that outlives the route. They remain as a
348
+ // fallback for a product that ships unnamed capabilities, emitted only for the
349
+ // rows that actually need them, which today is none.
350
+ //
351
+ // `product` was here because results could span products. They cannot: `product`
352
+ // is a required argument, so every row is the product the caller named.
353
+ capabilities: hits.map(({ capability }) => ({
354
+ ...(capability.name
355
+ ? { name: capability.name }
356
+ : { method: capability.method, path: capability.path }),
249
357
  mode: capability.mode,
250
358
  entity: capability.entity,
251
359
  ...(capability.intent ? { intent: capability.intent } : {}),
@@ -332,11 +440,24 @@ export function addCapabilityRegistryTools(server, deps, config) {
332
440
  // Dropped rather than overwritten: the raw field holds `{"$response": …}` pointers,
333
441
  // and spreading the capability would leak them through whenever the resolved value
334
442
  // is absent.
335
- const { responses: unresolved, ...contract } = capability;
443
+ const { responses: unresolved, method, path, ...contract } = capability;
336
444
  void unresolved;
337
445
  return ok({
338
- build_id: registry.buildId,
446
+ // No build_id here either, same reason. `product` stays: describeCapability
447
+ // resolves by NAME and its product argument is optional, so the answer has to
448
+ // say whose contract came back.
339
449
  product: owner,
450
+ // NO ROUTE, for the same reason the shortlist has none. A named capability is
451
+ // invoked by its name; the route is how WE reach the product, not something
452
+ // the caller acts on, and a contract that shows both invites the caller to
453
+ // hold the half that breaks when `/edit` becomes `/edit-v2`. The parameters
454
+ // below still carry `path_params`, so the caller knows what to supply — it
455
+ // just never sees the template they are substituted into.
456
+ //
457
+ // Emitted only when there is no name to use instead, which is what
458
+ // invokeCapability falls back to for a product that publishes none. No shipped
459
+ // product is in that state today.
460
+ ...(capability.name ? {} : { method, path }),
340
461
  ...contract,
341
462
  ...(responses ? { responses } : {}),
342
463
  });
@@ -29,6 +29,21 @@ export declare function terms(text: string | undefined): string[];
29
29
  * for no additional match.
30
30
  */
31
31
  export declare function termForms(term: string): string[];
32
+ /**
33
+ * ONE canonical spelling of a word, so two spellings of a noun cannot look like two terms.
34
+ *
35
+ * `termForms` is deliberately generous — it offers every candidate and lets substring
36
+ * containment sort them out. Canonicalising needs the opposite: exactly one answer, and
37
+ * the right one. Taking the SHORTEST candidate is what a first cut did, and it folded
38
+ * `cases` to `cas`, so the vocabulary entry `test case` matched no query containing "test
39
+ * cases" — the phrase it exists for. The ambiguity check then fell through to `test`
40
+ * alone, reaching the right verdict by the wrong route.
41
+ *
42
+ * Strip `es` only after a sibilant, where English actually inserts it (`boxes`,
43
+ * `batches`, `statuses`). Otherwise strip the single `s`, which is right for the `-e`
44
+ * plurals this vocabulary is full of: case, suite, phase, template.
45
+ */
46
+ export declare function singular(word: string): string;
32
47
  export declare function modeHint(query: string | undefined): "" | Mode;
33
48
  export declare function wantsCollection(query: string | undefined): boolean;
34
49
  /**
@@ -70,9 +85,36 @@ export interface SearchResult {
70
85
  coverage: number;
71
86
  weak: boolean;
72
87
  }
88
+ /** Which products a query could plausibly be about, when more than one could. */
89
+ export interface ProductAmbiguity {
90
+ /** The products that claim the query's words. Empty when the query settles itself. */
91
+ products: string[];
92
+ /** The shared vocabulary that caused it — what to put in front of the user. */
93
+ terms: string[];
94
+ }
95
+ /**
96
+ * Products whose OWN vocabulary the query hits, when no word in the query settles it.
97
+ *
98
+ * Requiring `product` made every CALL unambiguous and did nothing about the agent making
99
+ * two of them: "list all projects" was answered by searching tm, then Load Testing, then
100
+ * merging — a question about which product the user meant, answered by guessing both.
101
+ * Tool-description prose asking the agent to check with the user is a suggestion it is
102
+ * free to decline, and declining is cheaper than interrupting someone.
103
+ *
104
+ * WHOLE VOCABULARY ENTRIES, never their constituent words. Splitting them makes almost
105
+ * everything look shared: Load Testing's "load test" and tm's "test case" both yield
106
+ * `test`, which flagged 135 of the 198 eval queries. Matching entries whole flags 27.
107
+ *
108
+ * A UNIQUE TERM SETTLES IT. "create a test case in a folder" contains `folder`, which
109
+ * both products claim, and `test case`, which only tm does — so the query has already
110
+ * answered the question and there is nothing to ask. Only queries whose every recognised
111
+ * word is shared are genuinely undecided.
112
+ */
113
+ export declare function ambiguousProducts(products: Record<string, ProductIndex>, query: string | undefined): ProductAmbiguity;
73
114
  /** One entity's caller-facing vocabulary: what it is called, and what else it is called. */
74
115
  export interface VocabularyEntry {
75
116
  entity: string;
117
+ description?: string;
76
118
  aliases?: string[];
77
119
  }
78
120
  /**
@@ -84,10 +126,14 @@ export interface VocabularyEntry {
84
126
  * language model, and given tm's entity list it maps bucket -> folder without effort. It
85
127
  * just cannot guess the list unprompted.
86
128
  *
87
- * Aliases only, and `title` dropped as a near-duplicate of `entity` ("Test run" next to
88
- * `test_run` buys nothing). The entity `key_facts` are richer prose but ten times the size,
89
- * and a caller who needs them can ask describeEntity once it knows which entity to ask
90
- * aboutwhich is exactly what this hands over.
129
+ * Aliases and a one-line `description`; `title` stays dropped as a near-duplicate of
130
+ * `entity` ("Test run" next to `test_run` buys nothing). Aliases alone route but do not
131
+ * DEFINE `result -> outcome, execution-result, test-result` never says whether that is
132
+ * the per-case verdict inside a run or a run-level rollup so an agent holding names and
133
+ * aliases has exactly one way to find out, which is describeEntity once per entity. For
134
+ * tm that is 19 calls at ~1.4KB each, ~27KB, to answer what ~1.6KB of description answers
135
+ * here for every entity at once. The `key_facts` remain out: ten times the size, and the
136
+ * caller can ask describeEntity for the one entity it settles on.
91
137
  *
92
138
  * Its share of the response grew when search became a shortlist: 3.2KB against a 38KB full
93
139
  * search was 8%, against a 6.8KB shortlist it is nearly half. The absolute cost did not
@@ -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(" ");
@@ -436,46 +466,68 @@ function score(capability, wanted, weights, aliases, hint, plural) {
436
466
  const WEAK_COVERAGE = 0.25;
437
467
  /** The heaviest field, and so the yardstick a perfect match is measured against. */
438
468
  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
469
  /**
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.
470
+ * Products whose OWN vocabulary the query hits, when no word in the query settles it.
471
+ *
472
+ * Requiring `product` made every CALL unambiguous and did nothing about the agent making
473
+ * two of them: "list all projects" was answered by searching tm, then Load Testing, then
474
+ * merging a question about which product the user meant, answered by guessing both.
475
+ * Tool-description prose asking the agent to check with the user is a suggestion it is
476
+ * free to decline, and declining is cheaper than interrupting someone.
477
+ *
478
+ * WHOLE VOCABULARY ENTRIES, never their constituent words. Splitting them makes almost
479
+ * everything look shared: Load Testing's "load test" and tm's "test case" both yield
480
+ * `test`, which flagged 135 of the 198 eval queries. Matching entries whole flags 27.
481
+ *
482
+ * A UNIQUE TERM SETTLES IT. "create a test case in a folder" contains `folder`, which
483
+ * both products claim, and `test case`, which only tm does — so the query has already
484
+ * answered the question and there is nothing to ask. Only queries whose every recognised
485
+ * word is shared are genuinely undecided.
455
486
  */
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);
487
+ export function ambiguousProducts(products, query) {
488
+ const none = { products: [], terms: [] };
489
+ if (terms(query).length === 0)
490
+ return none;
491
+ /**
492
+ * One spelling per word, so a plural cannot masquerade as a different term.
493
+ *
494
+ * Both halves need it. On the QUERY side, the entry is `project` and the user types
495
+ * "list all projects" the reported case, which exact containment missed. On the
496
+ * VOCABULARY side the same fold is what keeps the answer honest: tm lists both `report`
497
+ * and `reports` as aliases, and treating them as two entries made `reports` look
498
+ * tm-exclusive, so "show me the report" read as settled when both products claim it.
499
+ */
500
+ const fold = (text) => terms(text).map(singular).join(" ");
501
+ const asked_ = ` ${fold(query || "")} `;
502
+ const owners = new Map();
503
+ for (const [product, entries] of Object.entries(vocabularyOf(products))) {
504
+ for (const entry of entries) {
505
+ for (const word of [entry.entity, ...(entry.aliases ?? [])]) {
506
+ const key = fold(String(word));
507
+ if (!key)
508
+ continue;
509
+ if (!owners.has(key))
510
+ owners.set(key, new Set());
511
+ owners.get(key).add(product);
512
+ }
470
513
  }
471
514
  }
472
- for (const hit of scored) {
473
- if (taken.size >= limit)
474
- break;
475
- taken.add(hit);
515
+ const shared = new Map();
516
+ for (const [key, claimants] of owners) {
517
+ if (!asked_.includes(` ${key} `))
518
+ continue;
519
+ // A term only one product claims decides the query outright.
520
+ if (claimants.size < 2)
521
+ return none;
522
+ shared.set(key, claimants);
476
523
  }
477
- // Emit in the original ranked order: the floor decides WHO appears, never in what order.
478
- return scored.filter((hit) => taken.has(hit));
524
+ if (shared.size === 0)
525
+ return none;
526
+ const claimed = new Set();
527
+ for (const claimants of shared.values())
528
+ for (const product of claimants)
529
+ claimed.add(product);
530
+ return { products: [...claimed].sort(), terms: [...shared.keys()].sort() };
479
531
  }
480
532
  /**
481
533
  * The product's vocabulary, for a caller whose words are not the product's words.
@@ -486,10 +538,14 @@ function withEveryProductRepresented(scored, limit) {
486
538
  * language model, and given tm's entity list it maps bucket -> folder without effort. It
487
539
  * just cannot guess the list unprompted.
488
540
  *
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.
541
+ * Aliases and a one-line `description`; `title` stays dropped as a near-duplicate of
542
+ * `entity` ("Test run" next to `test_run` buys nothing). Aliases alone route but do not
543
+ * DEFINE `result -> outcome, execution-result, test-result` never says whether that is
544
+ * the per-case verdict inside a run or a run-level rollup so an agent holding names and
545
+ * aliases has exactly one way to find out, which is describeEntity once per entity. For
546
+ * tm that is 19 calls at ~1.4KB each, ~27KB, to answer what ~1.6KB of description answers
547
+ * here for every entity at once. The `key_facts` remain out: ten times the size, and the
548
+ * caller can ask describeEntity for the one entity it settles on.
493
549
  *
494
550
  * Its share of the response grew when search became a shortlist: 3.2KB against a 38KB full
495
551
  * search was 8%, against a 6.8KB shortlist it is nearly half. The absolute cost did not
@@ -504,8 +560,12 @@ export function vocabularyOf(products, only) {
504
560
  const entries = [];
505
561
  for (const [entity, doc] of Object.entries(bundle.entities || {})) {
506
562
  const aliases = (doc.aliases || []);
563
+ const description = doc.description;
507
564
  entries.push({
508
565
  entity,
566
+ // Before the aliases, because it is what the reader needs first: what the thing
567
+ // is, then what else it is called.
568
+ ...(description ? { description } : {}),
509
569
  ...(aliases.length ? { aliases } : {}),
510
570
  });
511
571
  }
@@ -575,7 +635,9 @@ export function searchCapabilities(products, query, options = {}) {
575
635
  const perfect = weights.reduce((sum, w) => sum + IDENTITY_WEIGHT * w, 0);
576
636
  const coverage = perfect > 0 ? topMatched / perfect : 1;
577
637
  return {
578
- hits: withEveryProductRepresented(scored, limit).map(({ product, capability }) => ({ product, capability })),
638
+ hits: scored
639
+ .slice(0, limit)
640
+ .map(({ product, capability }) => ({ product, capability })),
579
641
  truncated: scored.length > limit,
580
642
  total_matched: scored.length,
581
643
  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.16",
4
4
  "description": "BrowserStack's Official MCP Server",
5
5
  "mcpName": "io.github.browserstack/mcp-server",
6
6
  "main": "dist/index.js",