@browserstack/mcp-server 1.5.0-beta.16 → 1.5.0-beta.18
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.
- package/capability/loadtesting.capability-index.json +48 -0
- package/capability/tm.capability-index.json +41722 -12206
- package/dist/tools/capability-registry/bind.js +42 -1
- package/dist/tools/capability-registry/egress.js +52 -6
- package/dist/tools/capability-registry/index-loader.js +36 -1
- package/dist/tools/capability-registry/search.js +17 -1
- package/package.json +7 -2
|
@@ -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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
3
|
+
"version": "1.5.0-beta.18",
|
|
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,12 @@
|
|
|
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
|
+
"check:contract": "python3 scripts/check-contract.py capability/tm.capability-index.json --baseline scripts/contract-baseline/tm.json --quiet && python3 scripts/check-contract.py capability/loadtesting.capability-index.json --baseline scripts/contract-baseline/loadtesting.json --quiet",
|
|
20
|
+
"eval:index": "tsx scripts/eval-index.mts",
|
|
21
|
+
"seed:live": "tsx scripts/seed-live.mts",
|
|
22
|
+
"plan:tests": "tsx scripts/plan-capability-tests.mts",
|
|
23
|
+
"replay:live": "tsx scripts/replay-live.mts"
|
|
19
24
|
},
|
|
20
25
|
"bin": {
|
|
21
26
|
"browserstack-mcp-server": "dist/index.js"
|