@browserstack/mcp-server 1.5.0-beta.5 → 1.5.0-beta.7

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.
@@ -46,6 +46,8 @@ export declare class CapabilityRegistry {
46
46
  readonly provenance: Record<string, Provenance>;
47
47
  /** product -> "METHOD /path" -> capability */
48
48
  private readonly byEndpoint;
49
+ /** product -> capability name -> capability. Empty for products that publish no names. */
50
+ private readonly byName;
49
51
  constructor(index: RegistryIndex, provenance?: Record<string, Provenance>);
50
52
  static fromFile(file: string): CapabilityRegistry;
51
53
  /**
@@ -63,7 +65,19 @@ export declare class CapabilityRegistry {
63
65
  /** Per-product `{build_id, version}`, for logging and cache-busting only. */
64
66
  buildInfo(): Record<string, Provenance>;
65
67
  /**
66
- * Find a capability by the endpoint it exposes — the published handle.
68
+ * Find a capability by its published name — the preferred handle.
69
+ *
70
+ * Names are unique within a product but not across products, so an ambiguous name is
71
+ * reported rather than resolved by load order. A name that exists in no index is
72
+ * `unknown_capability`: a distinct outcome from a name that exists elsewhere, because the
73
+ * caller's next move differs — search again, versus pass `product`.
74
+ */
75
+ byNameLookup(name: string, product?: string): {
76
+ product: string;
77
+ capability: Capability;
78
+ };
79
+ /**
80
+ * Find a capability by the endpoint it exposes — the handle for unnamed products.
67
81
  *
68
82
  * The endpoint is what searchCapability returns, so it is the only thing a caller can
69
83
  * hold. `unknown_endpoint` is a defined outcome rather than a generic failure: a caller
@@ -107,6 +107,8 @@ export class CapabilityRegistry {
107
107
  provenance;
108
108
  /** product -> "METHOD /path" -> capability */
109
109
  byEndpoint = new Map();
110
+ /** product -> capability name -> capability. Empty for products that publish no names. */
111
+ byName = new Map();
110
112
  constructor(index, provenance = {}) {
111
113
  if (index?.schema_version !== SUPPORTED_SCHEMA_VERSION) {
112
114
  throw new IndexError(`unsupported index schema_version ${index?.schema_version}; this build reads ` +
@@ -119,10 +121,25 @@ export class CapabilityRegistry {
119
121
  this.provenance = provenance;
120
122
  for (const [product, bundle] of Object.entries(index.products)) {
121
123
  const lookup = new Map();
124
+ const names = new Map();
122
125
  for (const capability of bundle.capabilities) {
123
126
  lookup.set(endpointKey(capability.method, capability.path), capability);
127
+ if (!capability.name)
128
+ continue;
129
+ const clash = names.get(capability.name);
130
+ if (clash) {
131
+ // A duplicate name makes one of the two permanently unreachable, and which one
132
+ // wins would depend on array order. The export gates this, but a hand-edited or
133
+ // stale artifact must not load and then silently drop an endpoint.
134
+ throw new IndexError(`${product}: capability name '${capability.name}' is used by both ` +
135
+ `${endpointKey(clash.method, clash.path)} and ` +
136
+ `${endpointKey(capability.method, capability.path)}; names must be unique ` +
137
+ `within a product`);
138
+ }
139
+ names.set(capability.name, capability);
124
140
  }
125
141
  this.byEndpoint.set(product, lookup);
142
+ this.byName.set(product, names);
126
143
  }
127
144
  }
128
145
  static fromFile(file) {
@@ -183,7 +200,45 @@ export class CapabilityRegistry {
183
200
  return this.provenance;
184
201
  }
185
202
  /**
186
- * Find a capability by the endpoint it exposes — the published handle.
203
+ * Find a capability by its published name — the preferred handle.
204
+ *
205
+ * Names are unique within a product but not across products, so an ambiguous name is
206
+ * reported rather than resolved by load order. A name that exists in no index is
207
+ * `unknown_capability`: a distinct outcome from a name that exists elsewhere, because the
208
+ * caller's next move differs — search again, versus pass `product`.
209
+ */
210
+ byNameLookup(name, product) {
211
+ const matches = [];
212
+ for (const [owner, lookup] of this.byName) {
213
+ if (product && owner !== product)
214
+ continue;
215
+ const capability = lookup.get(name);
216
+ if (capability)
217
+ matches.push({ product: owner, capability });
218
+ }
219
+ if (matches.length === 0) {
220
+ // Say whether names are published at all for the product asked about: "no such name"
221
+ // and "this product does not name its capabilities" need different fixes.
222
+ const unnamed = [...this.byName]
223
+ .filter(([owner, lookup]) => (!product || owner === product) && lookup.size === 0)
224
+ .map(([owner]) => owner);
225
+ const hint = unnamed.length
226
+ ? ` ${unnamed.sort().join(", ")} ${unnamed.length > 1 ? "publish" : "publishes"} ` +
227
+ `no capability names yet — call those by \`method\` and \`path\` instead.`
228
+ : " Search again — names come from searchCapability and describeEntity.";
229
+ throw new InvocationError(`unknown_capability: ${name}.${hint}`);
230
+ }
231
+ if (matches.length > 1 && !product) {
232
+ const owners = matches
233
+ .map((m) => m.product)
234
+ .sort()
235
+ .join(", ");
236
+ throw new InvocationError(`capability name '${name}' exists in several products (${owners}); pass product`);
237
+ }
238
+ return matches[0];
239
+ }
240
+ /**
241
+ * Find a capability by the endpoint it exposes — the handle for unnamed products.
187
242
  *
188
243
  * The endpoint is what searchCapability returns, so it is the only thing a caller can
189
244
  * hold. `unknown_endpoint` is a defined outcome rather than a generic failure: a caller
@@ -193,9 +193,10 @@ export function addCapabilityRegistryTools(server, deps, config) {
193
193
  "nothing that fits, do not just rephrase it: call listEntities for the product and " +
194
194
  "describeEntity on the closest entity, then search again using the vocabulary they " +
195
195
  "return. Narrowing with `product` or `entity` sharpens results further. " +
196
- "Each result carries the endpoint's `method` and `path` plus " +
197
- "its parameters grouped into path_params / query / body under the spec's own names " +
198
- "pass them straight back to invokeEndpoint, no renaming. `intent` says what it does, " +
196
+ "Each result carries the capability's `name` the handle you pass to " +
197
+ "invokeCapability plus its `method` and `path` (use those two only when a result " +
198
+ "has no `name`), and its parameters grouped into path_params / query / body under " +
199
+ "the spec's own names. Pass them straight back, no renaming. `intent` says what it does, " +
199
200
  "`mode` tells you whether it writes, `product` says which product owns it, and " +
200
201
  "`responses` describes what a successful call returns, fully expanded. Results are " +
201
202
  "ranked and capped, and `truncated` says when more matched. Search before invoking.", {
@@ -257,22 +258,30 @@ export function addCapabilityRegistryTools(server, deps, config) {
257
258
  ...rest,
258
259
  });
259
260
  });
260
- tools.invokeEndpoint = server.tool("invokeEndpoint", "Call an endpoint returned by searchCapability. Pass `method` and `path` exactly as " +
261
- "given, with arguments grouped into path_params / query / body under the spec's own " +
262
- "names. One call makes exactly one request and returns the product's own response " +
263
- "untouched; when `completed` is false there is another page, which you fetch by " +
264
- "sending the endpoint's own page parameter. If the endpoint's mode is 'write' you " +
265
- "MUST ask the user first, then " +
266
- "resend with user_permission='granted' and a change_summary; both are recorded. " +
267
- "Endpoints whose mode is 'destructive' (deletes) are refused outright archiving, " +
268
- "closing and merging are ordinary writes and DO run, so read the mode and intent " +
269
- "before confirming with the user.", {
261
+ tools.invokeCapability = server.tool("invokeCapability", "Call a capability returned by searchCapability or describeEntity. Pass `name` exactly " +
262
+ "as given that is the handle. Only when a result carries no `name` (some products " +
263
+ "do not publish them yet) pass `method` and `path` instead, exactly as returned. " +
264
+ "Arguments go in path_params / query / body under the spec's own names. One call " +
265
+ "makes exactly one request and returns the product's own response untouched; when " +
266
+ "`completed` is false there is another page, which you fetch by sending the " +
267
+ "capability's own page parameter. If the mode is 'write' you MUST ask the user " +
268
+ "first, then resend with user_permission='granted' and a change_summary; both are " +
269
+ "recorded. Capabilities whose mode is 'destructive' (deletes) are refused outright " +
270
+ "archiving, closing and merging are ordinary writes and DO run, so read the mode and " +
271
+ "intent before confirming with the user.", {
272
+ name: z
273
+ .string()
274
+ .optional()
275
+ .describe("The capability's published name, exactly as returned (e.g. 'create_test_run_v1'). " +
276
+ "Preferred over method/path."),
270
277
  method: z
271
278
  .string()
272
- .describe("HTTP method, exactly as searchCapability returned it."),
279
+ .optional()
280
+ .describe("HTTP method — only for capabilities returned without a `name`."),
273
281
  path: z
274
282
  .string()
275
- .describe("Path with {placeholders} intact, exactly as returned."),
283
+ .optional()
284
+ .describe("Path with {placeholders} intact — only for capabilities returned without a `name`."),
276
285
  path_params: z
277
286
  .record(z.string(), z.any())
278
287
  .optional()
@@ -287,8 +296,8 @@ export function addCapabilityRegistryTools(server, deps, config) {
287
296
  .describe("Body fields, under the spec's names."),
288
297
  product: productArg()
289
298
  .optional()
290
- .describe(`Which product owns the endpoint (${productList}). searchCapability returns it ` +
291
- "on every result; required only when two products share a path."),
299
+ .describe(`Which product owns the capability (${productList}). searchCapability returns it ` +
300
+ "on every result; required only when two products share a name or a path."),
292
301
  user_permission: z
293
302
  .enum(PERMISSION_VALUES)
294
303
  .optional()
@@ -298,7 +307,7 @@ export function addCapabilityRegistryTools(server, deps, config) {
298
307
  .optional()
299
308
  .describe("What will change. Required for writes."),
300
309
  }, {
301
- title: "Invoke Endpoint",
310
+ title: "Invoke Capability",
302
311
  // Not read-only: this is the one tool that writes. Never destructive, because
303
312
  // destructive endpoints are refused before binding — the refusal is enforced here,
304
313
  // not merely hinted at. Not idempotent: it creates, clones and starts runs. Closed
@@ -308,9 +317,20 @@ export function addCapabilityRegistryTools(server, deps, config) {
308
317
  idempotentHint: false,
309
318
  openWorldHint: false,
310
319
  }, async (input) => {
311
- track("invokeEndpoint");
320
+ track("invokeCapability");
312
321
  try {
313
- const { product, capability } = registry.byEndpointLookup(input.method, input.path, input.product);
322
+ // Either handle resolves to the same capability. `name` wins when both are sent,
323
+ // rather than cross-checking them: a caller pasting a stale path alongside a good
324
+ // name should still reach the right operation, which is the point of naming.
325
+ if (!input.name && !(input.method && input.path)) {
326
+ return failed("pass `name` — or, for a capability returned without one, both `method` and " +
327
+ "`path`, exactly as searchCapability returned them");
328
+ }
329
+ const { product, capability } = input.name
330
+ ? registry.byNameLookup(input.name, input.product)
331
+ : registry.byEndpointLookup(input.method, input.path, input.product);
332
+ /** What to call it in errors — the handle the caller actually used. */
333
+ const handle = capability.name || `${capability.method} ${capability.path}`;
314
334
  const args = {
315
335
  path_params: input.path_params,
316
336
  query: input.query,
@@ -318,8 +338,7 @@ export function addCapabilityRegistryTools(server, deps, config) {
318
338
  };
319
339
  if (capability.mode === "destructive") {
320
340
  // Refused before binding, so consent is never sought for something that cannot run.
321
- return failed(`${input.method} ${input.path} is a destructive operation and is not available ` +
322
- `through this surface`);
341
+ return failed(`${handle} is a destructive operation and is not available through this surface`);
323
342
  }
324
343
  if (capability.mode === "write") {
325
344
  const permission = input.user_permission || "not_asked";
@@ -345,8 +364,8 @@ export function addCapabilityRegistryTools(server, deps, config) {
345
364
  catch (error) {
346
365
  if (error instanceof InvocationError)
347
366
  return failed(error.message);
348
- logger.error("invokeEndpoint failed: %s", error instanceof Error ? error.message : String(error));
349
- return failed("that endpoint could not be invoked");
367
+ logger.error("invokeCapability failed: %s", error instanceof Error ? error.message : String(error));
368
+ return failed("that capability could not be invoked");
350
369
  }
351
370
  });
352
371
  return tools;
@@ -36,8 +36,9 @@ export declare function wantsCollection(query: string | undefined): boolean;
36
36
  *
37
37
  * Pagination is the reliable signal — a paged operation is a listing by construction. The
38
38
  * plural terminal path segment is a weaker fallback for unpaged collections. (The Python
39
- * side used the capability NAME here; the artifact publishes no name, and the path's own
40
- * terminal noun carries the same signal because operationIds were derived from it.)
39
+ * side used the capability NAME here. tm now publishes one, but its verbs are not
40
+ * consistent the endpoint that lists folders is `get_root_folders_v1` so the path's
41
+ * terminal noun remains the better signal.)
41
42
  */
42
43
  export declare function isCollection(capability: Capability): boolean;
43
44
  /**
@@ -45,7 +46,7 @@ export declare function isCollection(capability: Capability): boolean;
45
46
  *
46
47
  * Attribution is not decoration: the response tables are per product, so dereferencing a
47
48
  * hit's schemas needs to know whose tables to read. It is also what lets a caller pass
48
- * `product` to invokeEndpoint when two products share an endpoint — until now search
49
+ * `product` to invokeCapability when two products share an endpoint — until now search
49
50
  * ranked across products and then threw away the only thing that could disambiguate them.
50
51
  */
51
52
  export interface SearchHit {
@@ -165,8 +165,9 @@ export function wantsCollection(query) {
165
165
  *
166
166
  * Pagination is the reliable signal — a paged operation is a listing by construction. The
167
167
  * plural terminal path segment is a weaker fallback for unpaged collections. (The Python
168
- * side used the capability NAME here; the artifact publishes no name, and the path's own
169
- * terminal noun carries the same signal because operationIds were derived from it.)
168
+ * side used the capability NAME here. tm now publishes one, but its verbs are not
169
+ * consistent the endpoint that lists folders is `get_root_folders_v1` so the path's
170
+ * terminal noun remains the better signal.)
170
171
  */
171
172
  export function isCollection(capability) {
172
173
  if (capability.paginated)
@@ -195,7 +196,49 @@ function parameterText(capability) {
195
196
  }
196
197
  return parts.join(" ");
197
198
  }
198
- /** Path words stand in for the capability name as the identity haystack. */
199
+ /**
200
+ * Path words are the identity haystack. The published `name` is deliberately NOT scored.
201
+ *
202
+ * That is a measured result, not an oversight. tm now names every capability, and adding
203
+ * the name to the ranking was tried three ways against tests/fixtures/search-eval.json,
204
+ * which the pre-names index serves 18/18:
205
+ *
206
+ * folded into this field, weight 6 17/18
207
+ * its own field, weights 1 / 2 / 3 / 4 / 6 17 / 17 / 17 / 17 / 16
208
+ * only the words the path lacks, 1..6 17 / 16 / 15 / 15 / 14
209
+ *
210
+ * Every variant loses, for two reasons. The nouns in a name are the route restated — the
211
+ * name is snake-cased from the operationId, itself derived from the path — so scoring them
212
+ * again rewards verbose names for repeating themselves: `get_test_cases_for_v1_test_run`
213
+ * displaced `create_test_result_for_test_case` on "record a pass or fail for a test case in
214
+ * a run", putting a read above the write that answers it. And what a name adds beyond the
215
+ * route is mostly its verb, which tm applies inconsistently (`get_root_folders_v1` lists,
216
+ * `list_folder_test_cases_v1` also lists), so the verb is noise as often as signal.
217
+ *
218
+ * The query this was meant to fix, "list all projects", only went 7 -> 4 even where it
219
+ * helped: `projects` is in 156 of 173 paths as a scope prefix, so rarity correctly values it
220
+ * near zero and no amount of name weighting recovers it. That one is fixed instead by the
221
+ * terminal-segment bonus in `score`, which tells "is that thing" from "is scoped by it".
222
+ *
223
+ * Revisit when a product ships a consistent verb convention — then the verb becomes signal.
224
+ */
225
+ /**
226
+ * The terminal path segment — the thing this endpoint is actually ABOUT.
227
+ *
228
+ * A REST path mixes two different things: the resources it is SCOPED BY, and the resource it
229
+ * ADDRESSES. Only the last segment is the latter. See the bonus in `score` for why that
230
+ * distinction matters and why it is applied flat rather than weighted.
231
+ *
232
+ * Trailing placeholders are skipped, so `/test-cases/{id}` is still about test cases.
233
+ * Action tails (`close`, `edit`, `delete`) are kept rather than skipped: for "close a test
234
+ * run" the tail IS the most specific thing the caller said.
235
+ */
236
+ function resourceText(capability) {
237
+ const segments = capability.path
238
+ .split("/")
239
+ .filter((s) => s && !s.startsWith("{") && s !== "api");
240
+ return (segments[segments.length - 1] || "").replace(/[-_]/g, " ");
241
+ }
199
242
  function identityText(capability) {
200
243
  return capability.path
201
244
  .split("/")
@@ -225,6 +268,13 @@ function rarity(documents, forms) {
225
268
  const total = documents.length || 1;
226
269
  return Math.log((total + 1) / (df + 1)) / Math.log(total + 1);
227
270
  }
271
+ /**
272
+ * Sized to sit alongside the mode (+6) and cardinality (+8) constants, not to dwarf them.
273
+ * The pinned eval is unchanged at every value from 2 to 14 — the bonus only ever fires on
274
+ * queries it does not cover — so this was chosen on the wider sweep: at 10, "close a test
275
+ * run" starts pulling `close_exploratory_session` into second place on the tail match alone.
276
+ */
277
+ const RESOURCE_BONUS = 6;
228
278
  function score(capability, wanted, weights, aliases, hint, plural) {
229
279
  if (wanted.length === 0)
230
280
  return { matched: 1, ranked: 1 };
@@ -273,6 +323,30 @@ function score(capability, wanted, weights, aliases, hint, plural) {
273
323
  }
274
324
  }
275
325
  const matched = ranked;
326
+ // THE ENDPOINT IS THAT THING, not merely scoped by it.
327
+ //
328
+ // A REST path mixes the resources it is SCOPED BY with the one it ADDRESSES. `projects` is
329
+ // in 156 of tm's 173 paths but is the terminal segment in 3, so whole-corpus rarity —
330
+ // correctly — values it near nothing, and every project-scoped listing scored the same as
331
+ // the projects listing itself. The top 8 for "list all projects" spanned 15.9 to 14.8,
332
+ // where +8 collection and +6 mode already account for 14: the term signal was ~1 point of
333
+ // noise and the right answer sat 7th.
334
+ //
335
+ // Flat, and deliberately NOT rarity-scaled. Rarity would reintroduce the same problem in
336
+ // reverse — a rare scope noun outranking the real target, which is exactly how a weighted
337
+ // version of this put `/projects/{id}/folders` above `/folder/{id}/test-cases` for "tc list
338
+ // for a folder". This asks one yes/no question instead: is the caller's own word the last
339
+ // thing in the path?
340
+ //
341
+ // Equality is against the QUERY's forms, never the haystack's — the same one-directional
342
+ // rule as containment. `projects` is in forms("projects"), so the projects listing hits;
343
+ // `folders` is not in forms("folder"), so a folder-scoped query does not drag in the
344
+ // folders listing.
345
+ const tail = terms(resourceText(capability));
346
+ if (tail.length &&
347
+ wanted.some((forms) => tail.every((word) => forms.includes(word)))) {
348
+ ranked += RESOURCE_BONUS;
349
+ }
276
350
  if (hint && capability.mode !== hint)
277
351
  ranked -= 20;
278
352
  else if (hint && capability.mode === hint)
@@ -67,6 +67,17 @@ export interface ResponseDoc extends ComponentRef {
67
67
  }
68
68
  /** A capability, keyed by the endpoint it exposes. There is deliberately no name. */
69
69
  export interface Capability {
70
+ /**
71
+ * The caller-facing handle — the snake-cased `operationId`, unique within its product.
72
+ *
73
+ * This is what `invokeCapability` takes, and what `entities[*].capabilities[]` lists. It
74
+ * is stable where a route is not: `/edit` becoming `/edit-v2` must not break a caller
75
+ * holding a handle.
76
+ *
77
+ * OPTIONAL because not every product publishes it yet — loadtesting's index carries none.
78
+ * For those, the endpoint remains the handle, so nothing may assume this is present.
79
+ */
80
+ name?: string;
70
81
  method: string;
71
82
  path: string;
72
83
  mode: Mode;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@browserstack/mcp-server",
3
- "version": "1.5.0-beta.5",
3
+ "version": "1.5.0-beta.7",
4
4
  "description": "BrowserStack's Official MCP Server",
5
5
  "mcpName": "io.github.browserstack/mcp-server",
6
6
  "main": "dist/index.js",