@browserstack/mcp-server 1.5.0-beta.2 → 1.5.0-beta.20

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.
@@ -8,6 +8,154 @@
8
8
  * Grouping removes the collision AND the rename, so a caller sends the spec's own names.
9
9
  */
10
10
  import { InvocationError } from "./index-loader.js";
11
+ /**
12
+ * String formats worth ENFORCING, as opposed to merely publishing.
13
+ *
14
+ * A validator that rejects a correct value is worse than no validator: the caller cannot
15
+ * route around it, and the request that would have worked never leaves. So this covers only
16
+ * the formats where "wrong" is unambiguous and a legitimate value cannot trip it.
17
+ *
18
+ * tm declares eight formats. `date`, `date-time` and `uuid` are here. `email` and `uri` are
19
+ * deliberately NOT — every compact regex for either rejects addresses and URLs that servers
20
+ * accept, and being wrong in that direction blocks real work. `int64`, `float` and `binary`
21
+ * say what a number or blob is, which `type` already covers.
22
+ */
23
+ const FORMATS = {
24
+ date: {
25
+ // A real calendar date, so 2026-02-31 fails rather than being reformatted.
26
+ test: (v) => /^\d{4}-\d{2}-\d{2}$/.test(v) &&
27
+ !Number.isNaN(Date.parse(`${v}T00:00:00Z`)) &&
28
+ new Date(`${v}T00:00:00Z`).toISOString().startsWith(v),
29
+ want: "a date (YYYY-MM-DD)",
30
+ },
31
+ "date-time": {
32
+ // Permissive on purpose: offset or Z, optional fractional seconds. Tightening this
33
+ // buys nothing and starts rejecting timestamps the product would have taken.
34
+ test: (v) => /^\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:?\d{2})?$/.test(v) && !Number.isNaN(Date.parse(v)),
35
+ want: "a date-time (e.g. 2026-01-31T09:30:00Z)",
36
+ },
37
+ uuid: {
38
+ test: (v) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v),
39
+ want: "a UUID",
40
+ },
41
+ };
42
+ /**
43
+ * A declared `pattern`, compiled once, or undefined if it will not compile here.
44
+ *
45
+ * `u` because tm's patterns use `\p{L}`/`\p{N}`, which are a syntax error without it. A
46
+ * pattern this engine cannot compile is SKIPPED rather than thrown: the index is generated
47
+ * from someone else's spec, and refusing to invoke a working endpoint because its regex
48
+ * dialect differs would be our bug landing on the caller.
49
+ */
50
+ const patternCache = new Map();
51
+ function compiled(pattern) {
52
+ if (!patternCache.has(pattern)) {
53
+ let regex;
54
+ try {
55
+ regex = new RegExp(pattern, "u");
56
+ }
57
+ catch {
58
+ try {
59
+ regex = new RegExp(pattern);
60
+ }
61
+ catch {
62
+ regex = undefined;
63
+ }
64
+ }
65
+ patternCache.set(pattern, regex);
66
+ }
67
+ return patternCache.get(pattern);
68
+ }
69
+ /**
70
+ * Every constraint the parameter declares, checked against the COERCED value.
71
+ *
72
+ * Runs inside `bind`, which register.ts calls dry before the write-consent gate — so a
73
+ * value that breaks a constraint is refused before the user is asked to approve anything,
74
+ * and nothing reaches egress.
75
+ *
76
+ * `label` carries the parent name for a nested field, so the message names `test_case.name`
77
+ * rather than a bare `name` the caller would have to go looking for.
78
+ */
79
+ function checkConstraints(value, param, label = param.name) {
80
+ const fail = (what) => {
81
+ throw new InvocationError(`'${label}' ${what}`);
82
+ };
83
+ if (typeof value === "number") {
84
+ if (param.minimum !== undefined && value < param.minimum)
85
+ fail(`must be at least ${param.minimum}`);
86
+ if (param.maximum !== undefined && value > param.maximum)
87
+ fail(`must be at most ${param.maximum}`);
88
+ if (param.multipleOf !== undefined && param.multipleOf > 0) {
89
+ // Scale BOTH to integers before the modulo: 0.3 % 0.1 is 0.09999… in binary floating
90
+ // point and would reject a value the product accepts. The scale has to cover the
91
+ // value's decimals as well as the step's — scaling by the step alone rounds 0.25 to
92
+ // 3 against a step of 1, which then divides evenly and lets a bad value through.
93
+ const scale = 10 ** Math.max(decimals(param.multipleOf), decimals(value));
94
+ if (Math.round(value * scale) % Math.round(param.multipleOf * scale) !==
95
+ 0)
96
+ fail(`must be a multiple of ${param.multipleOf}`);
97
+ }
98
+ }
99
+ if (typeof value === "string") {
100
+ if (param.minLength !== undefined && value.length < param.minLength)
101
+ fail(`must be at least ${param.minLength} character(s)`);
102
+ if (param.maxLength !== undefined && value.length > param.maxLength)
103
+ fail(`must be at most ${param.maxLength} character(s)`);
104
+ if (param.pattern) {
105
+ const regex = compiled(param.pattern);
106
+ if (regex && !regex.test(value))
107
+ fail(`must match ${param.pattern}`);
108
+ }
109
+ const format = param.format ? FORMATS[param.format] : undefined;
110
+ if (format && !format.test(value))
111
+ fail(`must be ${format.want}`);
112
+ }
113
+ if (Array.isArray(value)) {
114
+ if (param.minItems !== undefined && value.length < param.minItems)
115
+ fail(`must have at least ${param.minItems} item(s)`);
116
+ if (param.maxItems !== undefined && value.length > param.maxItems)
117
+ fail(`must have at most ${param.maxItems} item(s)`);
118
+ if (param.uniqueItems) {
119
+ const seen = new Set(value.map((item) => JSON.stringify(item)));
120
+ if (seen.size !== value.length)
121
+ fail("must not contain duplicate items");
122
+ }
123
+ }
124
+ }
125
+ /** Decimal places, for scaling `multipleOf` out of floating point. */
126
+ function decimals(n) {
127
+ const text = String(n);
128
+ const dot = text.indexOf(".");
129
+ return dot < 0 ? 0 : text.length - dot - 1;
130
+ }
131
+ /**
132
+ * The declared fields one level inside an object, or inside each item of an array.
133
+ *
134
+ * Only what `fields` names is checked, and an undeclared key is left alone — `fields` is a
135
+ * projection of the shape, not a closed contract, so rejecting the rest would refuse valid
136
+ * bodies. That is the opposite of the top level, where the full parameter list IS known and
137
+ * an unknown name is an error.
138
+ */
139
+ function checkFields(value, param) {
140
+ if (!param.fields?.length)
141
+ return;
142
+ const items = Array.isArray(value) ? value : [value];
143
+ for (const item of items) {
144
+ if (typeof item !== "object" || item === null || Array.isArray(item))
145
+ continue;
146
+ const record = item;
147
+ for (const field of param.fields) {
148
+ if (!(field.name in record)) {
149
+ if (field.required)
150
+ throw new InvocationError(`missing required parameter(s): ${param.name}.${field.name}`);
151
+ continue;
152
+ }
153
+ const label = `${param.name}.${field.name}`;
154
+ const coerced = coerceType(record[field.name], field, label);
155
+ checkConstraints(coerced, field, label);
156
+ }
157
+ }
158
+ }
11
159
  /**
12
160
  * Check one argument against its declared schema, raising a caller-safe error.
13
161
  *
@@ -16,23 +164,29 @@ import { InvocationError } from "./index-loader.js";
16
164
  * rather than being encoded into a URL.
17
165
  */
18
166
  export function coerce(value, param) {
167
+ const coerced = coerceType(value, param);
168
+ checkConstraints(coerced, param);
169
+ checkFields(coerced, param);
170
+ return coerced;
171
+ }
172
+ function coerceType(value, param, label = param.name) {
19
173
  const expected = param.type;
20
174
  if (expected === "object" || expected === "array") {
21
175
  // An opaque body object is passed through as given: the spec does not describe its
22
176
  // fields, so validating or reshaping it would mean inventing a contract.
23
177
  if (expected === "object" &&
24
178
  (typeof value !== "object" || value === null || Array.isArray(value))) {
25
- throw new InvocationError(`'${param.name}' must be an object`);
179
+ throw new InvocationError(`'${label}' must be an object`);
26
180
  }
27
181
  if (expected === "array" && !Array.isArray(value)) {
28
- throw new InvocationError(`'${param.name}' must be a list`);
182
+ throw new InvocationError(`'${label}' must be a list`);
29
183
  }
30
184
  return value;
31
185
  }
32
186
  if (expected === "integer" || expected === "number") {
33
187
  const parsed = Number(String(value).trim());
34
188
  if (!Number.isFinite(parsed)) {
35
- throw new InvocationError(`'${param.name}' must be a number`);
189
+ throw new InvocationError(`'${label}' must be a number`);
36
190
  }
37
191
  return expected === "integer" ? Math.trunc(parsed) : parsed;
38
192
  }
@@ -44,13 +198,29 @@ export function coerce(value, param) {
44
198
  return true;
45
199
  if (["false", "0", "no"].includes(text))
46
200
  return false;
47
- throw new InvocationError(`'${param.name}' must be true or false`);
201
+ throw new InvocationError(`'${label}' must be true or false`);
202
+ }
203
+ // NO DECLARED TYPE: pass the value through as given.
204
+ //
205
+ // The fallthrough below is String(value), which is right for a declared string and
206
+ // silently destructive for anything the spec left untyped. create_report_v2 declares
207
+ // `mail_to` and `report_filters` with no type and the API wants objects for both, so a
208
+ // correct payload was serialised to the literal "[object Object]" and refused:
209
+ //
210
+ // The property '#/mail_to' of type string did not match the following type: object
211
+ //
212
+ // The caller sent the right thing, this layer broke it, and the server's message blames
213
+ // the caller for a string it never wrote. An untyped parameter means the spec declines to
214
+ // say what shape it is — which is a reason to leave it alone, not to guess "string".
215
+ const untyped = !expected;
216
+ if (untyped && (typeof value === "object" || typeof value === "boolean")) {
217
+ return value;
48
218
  }
49
219
  const text = String(value);
50
220
  if (param.values && param.values.length > 0) {
51
221
  const allowed = param.values.map((v) => String(v));
52
222
  if (!allowed.includes(text)) {
53
- throw new InvocationError(`'${param.name}' must be one of: ${allowed.join(", ")}`);
223
+ throw new InvocationError(`'${label}' must be one of: ${allowed.join(", ")}`);
54
224
  }
55
225
  }
56
226
  return text;
@@ -90,8 +260,33 @@ export function bind(capability, args) {
90
260
  // misspelled filter would return a larger result set that looks like a correct answer.
91
261
  const unknown = Object.keys(supplied).filter((name) => !byName.has(name));
92
262
  if (unknown.length > 0) {
263
+ // A CALLER WHO SENT THE WRAPPER WE BUILD DESERVES TO BE TOLD SO.
264
+ //
265
+ // Most of these endpoints want a nested body — Rails `params.require(:test_case)` —
266
+ // and the fields declare that as `json_path: /test_case/name`, so this layer
267
+ // assembles the wrapper itself and the caller sends the fields flat. A caller who
268
+ // wraps it by hand therefore sends a key that is never a declared param, and the
269
+ // bare message reads as "that field does not exist" when the truth is the opposite:
270
+ // it exists, and building it is our job.
271
+ //
272
+ // Worth special-casing because the guidance itself has been telling callers to wrap:
273
+ // 25 tm capabilities carried a line describing the WIRE format to a caller who never
274
+ // writes the wire. Those lines are being corrected, but an agent working from an
275
+ // older artifact, or reasoning from the Rails convention, lands here either way.
276
+ const wrappers = new Set(params
277
+ .map((param) => param.json_path)
278
+ .filter((path) => Boolean(path))
279
+ .map((path) => path.replace(/^\//, "").split("/")[0])
280
+ .filter((segment) => segment && !byName.has(segment)));
281
+ const sentAWrapper = unknown.filter((name) => wrappers.has(name));
93
282
  throw new InvocationError(`unknown ${group}: ${unknown.sort().join(", ")}. accepted: ` +
94
- `${[...byName.keys()].sort().join(", ") || "none"}`);
283
+ `${[...byName.keys()].sort().join(", ") || "none"}` +
284
+ (sentAWrapper.length > 0
285
+ ? `. ${sentAWrapper.sort().join(" and ")} ` +
286
+ `${sentAWrapper.length > 1 ? "are wrappers" : "is a wrapper"} this ` +
287
+ `surface builds for you from each field's json_path — send the fields ` +
288
+ `directly instead of nesting them`
289
+ : ""));
95
290
  }
96
291
  for (const [name, raw] of Object.entries(supplied)) {
97
292
  const param = byName.get(name);
@@ -83,6 +83,54 @@ export function authHeaders(credentials, auth = DEFAULT_AUTH) {
83
83
  throw new InvocationError(`unsupported auth scheme for this product: ` +
84
84
  `${JSON.stringify({ type: auth.type, scheme: auth.scheme })}`);
85
85
  }
86
+ /** Longest response text kept when it is not JSON. Enough for an error, not a whole page. */
87
+ const TEXT_BODY_LIMIT = 2000;
88
+ /**
89
+ * Read the response, and NEVER silently discard it.
90
+ *
91
+ * This used to parse the body only when the content-type said JSON and return `null`
92
+ * otherwise, which meant an error could arrive as `{status: 500, body: null}` — a status
93
+ * code and nothing else. That is worse than it sounds: an unhandled exception in a Rails
94
+ * app renders `text/html`, so the one case where you most need the message is exactly the
95
+ * case where the content-type is not JSON. It also made two very different situations
96
+ * indistinguishable — "the product sent no message" and "we threw the message away" — and
97
+ * a live probe of `test_case_results_v1` had to leave that ambiguity open in its findings
98
+ * because nothing downstream could tell which had happened.
99
+ *
100
+ * So: JSON is parsed as before. Anything textual is kept as a truncated string. Malformed
101
+ * JSON keeps its raw text rather than becoming `null`, because a body that fails to parse
102
+ * is itself the diagnosis. Binary is described rather than decoded — dumping PDF bytes
103
+ * into an agent's context helps nobody, but knowing a PDF arrived does.
104
+ */
105
+ async function readBody(response) {
106
+ const contentType = response.headers.get("content-type") || "";
107
+ // Anything that is not plausibly text: report what came back without decoding it.
108
+ const textual = !contentType ||
109
+ /^text\//i.test(contentType) ||
110
+ /\b(json|xml|yaml|csv|javascript|x-www-form-urlencoded)\b/i.test(contentType);
111
+ if (!textual) {
112
+ const size = response.headers.get("content-length");
113
+ return `<non-text response: ${contentType}${size ? `, ${size} bytes` : ""}>`;
114
+ }
115
+ const raw = await response.text().catch(() => "");
116
+ if (!raw.trim())
117
+ return null;
118
+ if (contentType.includes("json")) {
119
+ try {
120
+ return JSON.parse(raw);
121
+ }
122
+ catch {
123
+ // Declared JSON that is not JSON. The text is the evidence; keep it.
124
+ return truncate(raw);
125
+ }
126
+ }
127
+ return truncate(raw);
128
+ }
129
+ function truncate(text) {
130
+ return text.length <= TEXT_BODY_LIMIT
131
+ ? text
132
+ : `${text.slice(0, TEXT_BODY_LIMIT)}… [truncated, ${text.length} chars total]`;
133
+ }
86
134
  /** A fetch-based transport. Redirects are NOT followed. */
87
135
  export function fetchTransport(timeoutMs = 45_000) {
88
136
  return async (method, url, headers, query, body) => {
@@ -106,12 +154,10 @@ export function fetchTransport(timeoutMs = 45_000) {
106
154
  redirect: "manual",
107
155
  signal: controller.signal,
108
156
  });
109
- let parsed = null;
110
- const contentType = response.headers.get("content-type") || "";
111
- if (contentType.includes("json")) {
112
- parsed = await response.json().catch(() => null);
113
- }
114
- return { status: response.status, body: parsed };
157
+ return {
158
+ status: response.status,
159
+ body: await readBody(response),
160
+ };
115
161
  }
116
162
  catch {
117
163
  // Upstream detail stays out of the reply; the resolver treats status 0 as a failed call.
@@ -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
@@ -282,7 +337,42 @@ function resolveNode(product, node, seen) {
282
337
  // revisit a name already on this path.
283
338
  if (!target || seen.has(key))
284
339
  return node;
285
- return resolveNode(product, target, new Set([...seen, key]));
340
+ const resolved = resolveNode(product, target, new Set([...seen, key]));
341
+ // KEYS WRITTEN BESIDE THE REF SURVIVE IT, AND WIN.
342
+ //
343
+ // This used to return the target and drop every sibling, which silently ate 16 nodes
344
+ // in the shipped tm index: 14 authored descriptions and two `nullable: true` flags.
345
+ // The losses are exactly the caveats hardest to rediscover — "only when no step
346
+ // results were submitted", "Present only on root-folder creation", and a `nullable`
347
+ // marking the one field that comes back NULL when a search fails, which is the
348
+ // absent-versus-explicitly-null distinction a caller cannot otherwise make.
349
+ //
350
+ // It also cost real work downstream. A live probe reported one of those caveats as a
351
+ // missing conditional and it had been written all along — this resolver removed it
352
+ // between the author and the caller. Worse, the product team began splitting shared
353
+ // schemas so a caveat would have somewhere to live that survived, trading a lost
354
+ // sentence for two descriptions of one serializer that then drift apart.
355
+ //
356
+ // SIBLINGS WIN over the resolved target, not the reverse. A description written at the
357
+ // reference site is about THAT usage — "only when no step results were submitted" is
358
+ // true of one consumer of TestResult, not of TestResult everywhere — which is the
359
+ // whole reason it was written beside the ref instead of on the schema.
360
+ const siblings = {};
361
+ for (const [field, value] of Object.entries(node)) {
362
+ if (field !== "$schema" && field !== "$response")
363
+ siblings[field] = value;
364
+ }
365
+ if (Object.keys(siblings).length === 0)
366
+ return resolved;
367
+ if (typeof resolved !== "object" ||
368
+ resolved === null ||
369
+ Array.isArray(resolved)) {
370
+ return resolved;
371
+ }
372
+ return {
373
+ ...resolved,
374
+ ...resolveNode(product, siblings, seen),
375
+ };
286
376
  }
287
377
  const out = {};
288
378
  for (const [field, value] of Object.entries(node)) {
@@ -1,6 +1,12 @@
1
1
  /**
2
2
  * The tool surface: four discovery tools plus ONE invoke tool.
3
3
  *
4
+ * Discovery is deliberately two steps. `searchCapability` returns a shortlist — enough to
5
+ * CHOOSE — and `describeCapability` returns the contract for the one chosen. Parameters and
6
+ * response shapes are 86% of a full record and are needed once, not eight times: measured
7
+ * over eight queries, ~8.6k tokens a search becomes ~1.1k, and even describing every result
8
+ * still costs less than the single fat call did.
9
+ *
4
10
  * ONE invoke tool means one set of MCP annotations, so they describe the whole surface
5
11
  * honestly: it can write (not read-only) and it can never delete, because destructive
6
12
  * endpoints are refused before binding. Write consent therefore rests on `user_permission`
@@ -12,6 +18,14 @@ import { BrowserStackConfig } from "../../lib/types.js";
12
18
  import { Credentials, Transport } from "./egress.js";
13
19
  import { CapabilityRegistry } from "./index-loader.js";
14
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"];
15
29
  export interface RegistryDeps {
16
30
  registry: CapabilityRegistry;
17
31
  /**