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

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.
@@ -200,6 +200,22 @@ function coerceType(value, param, label = param.name) {
200
200
  return false;
201
201
  throw new InvocationError(`'${label}' must be true or false`);
202
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;
218
+ }
203
219
  const text = String(value);
204
220
  if (param.values && param.values.length > 0) {
205
221
  const allowed = param.values.map((v) => String(v));
@@ -244,8 +260,33 @@ export function bind(capability, args) {
244
260
  // misspelled filter would return a larger result set that looks like a correct answer.
245
261
  const unknown = Object.keys(supplied).filter((name) => !byName.has(name));
246
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));
247
282
  throw new InvocationError(`unknown ${group}: ${unknown.sort().join(", ")}. accepted: ` +
248
- `${[...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
+ : ""));
249
290
  }
250
291
  for (const [name, raw] of Object.entries(supplied)) {
251
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.
@@ -337,7 +337,42 @@ function resolveNode(product, node, seen) {
337
337
  // revisit a name already on this path.
338
338
  if (!target || seen.has(key))
339
339
  return node;
340
- 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
+ };
341
376
  }
342
377
  const out = {};
343
378
  for (const [field, value] of Object.entries(node)) {
@@ -462,8 +462,24 @@ function score(capability, wanted, weights, aliases, hint, plural) {
462
462
  * that work at 0.44–1.94. The absolute score cannot separate those (misses reach 2.9,
463
463
  * good queries drop to 1.9); this does, with the gap in the same place in all three
464
464
  * corpora, which is the property that was missing.
465
+ *
466
+ * RECALIBRATED AT v1.14, and the reason matters more than the number. The ratio is
467
+ * corpus-relative and still is — "make a new bucket for my tests" scores 0.259 against tm
468
+ * alone and 0.258 against both products, which is the property this was built for. What
469
+ * moved is neither corpus size nor query: it is how much TEXT each capability carries.
470
+ * v1.13 gave all 242 entries guidance and tripled the length of `intent`, so the numerator
471
+ * — the best hit's term score — roughly doubled, while the denominator depends only on the
472
+ * rarity of the query's own terms and did not. Both bands shifted up together: genuine
473
+ * misses now land at 0.00–0.46 and working queries at 0.24–4.16.
474
+ *
475
+ * At the old 0.25 the hand-off had silently stopped firing — "bucket", which is nobody's
476
+ * word for a folder, came in at 0.259 and read as a confident answer. Re-measured over 173
477
+ * served eval cases against five queries the product has no words for, 0.50 catches 5 of 5
478
+ * while flagging 3 of 173 served queries (1.7%), which is the same trade the original cut
479
+ * made. Expect to move it again the next time the artifact's text volume changes; the
480
+ * threshold tracks prose per capability, not the number of capabilities.
465
481
  */
466
- const WEAK_COVERAGE = 0.25;
482
+ const WEAK_COVERAGE = 0.5;
467
483
  /** The heaviest field, and so the yardstick a perfect match is measured against. */
468
484
  const IDENTITY_WEIGHT = 6;
469
485
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@browserstack/mcp-server",
3
- "version": "1.5.0-beta.16",
3
+ "version": "1.5.0-beta.17",
4
4
  "description": "BrowserStack's Official MCP Server",
5
5
  "mcpName": "io.github.browserstack/mcp-server",
6
6
  "main": "dist/index.js",
@@ -15,7 +15,11 @@
15
15
  "dev": "tsx watch --clear-screen=false src/index.ts",
16
16
  "test": "vitest run",
17
17
  "lint": "eslint . --ext .ts",
18
- "format": "prettier --write \"src/**/*.ts\""
18
+ "format": "prettier --write \"src/**/*.ts\"",
19
+ "eval:index": "tsx scripts/eval-index.mts",
20
+ "seed:live": "tsx scripts/seed-live.mts",
21
+ "plan:tests": "tsx scripts/plan-capability-tests.mts",
22
+ "replay:live": "tsx scripts/replay-live.mts"
19
23
  },
20
24
  "bin": {
21
25
  "browserstack-mcp-server": "dist/index.js"