@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.
- package/capability/loadtesting.capability-index.json +135 -29
- package/capability/tm.capability-index.json +42368 -12057
- package/dist/tools/capability-registry/bind.js +201 -6
- package/dist/tools/capability-registry/egress.js +52 -6
- package/dist/tools/capability-registry/index-loader.d.ts +15 -1
- package/dist/tools/capability-registry/index-loader.js +92 -2
- package/dist/tools/capability-registry/register.d.ts +14 -0
- package/dist/tools/capability-registry/register.js +367 -117
- package/dist/tools/capability-registry/search.d.ts +108 -3
- package/dist/tools/capability-registry/search.js +381 -14
- package/dist/tools/capability-registry/types.d.ts +80 -6
- package/package.json +7 -2
|
@@ -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(`'${
|
|
179
|
+
throw new InvocationError(`'${label}' must be an object`);
|
|
26
180
|
}
|
|
27
181
|
if (expected === "array" && !Array.isArray(value)) {
|
|
28
|
-
throw new InvocationError(`'${
|
|
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(`'${
|
|
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(`'${
|
|
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(`'${
|
|
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
|
-
|
|
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.
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
/**
|