@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.
- package/capability/loadtesting.capability-index.json +7 -1
- package/capability/tm.capability-index.json +181 -8
- package/dist/tools/capability-registry/index-loader.d.ts +15 -1
- package/dist/tools/capability-registry/index-loader.js +56 -1
- package/dist/tools/capability-registry/register.js +43 -24
- package/dist/tools/capability-registry/search.d.ts +4 -3
- package/dist/tools/capability-registry/search.js +77 -3
- package/dist/tools/capability-registry/types.d.ts +11 -0
- package/package.json +1 -1
|
@@ -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
|
|
@@ -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
|
|
197
|
-
"
|
|
198
|
-
"
|
|
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.
|
|
261
|
-
"given
|
|
262
|
-
"
|
|
263
|
-
"
|
|
264
|
-
"
|
|
265
|
-
"
|
|
266
|
-
"
|
|
267
|
-
"
|
|
268
|
-
"
|
|
269
|
-
"
|
|
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
|
-
.
|
|
279
|
+
.optional()
|
|
280
|
+
.describe("HTTP method — only for capabilities returned without a `name`."),
|
|
273
281
|
path: z
|
|
274
282
|
.string()
|
|
275
|
-
.
|
|
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
|
|
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
|
|
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("
|
|
320
|
+
track("invokeCapability");
|
|
312
321
|
try {
|
|
313
|
-
|
|
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(`${
|
|
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("
|
|
349
|
-
return failed("that
|
|
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
|
|
40
|
-
*
|
|
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
|
|
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
|
|
169
|
-
*
|
|
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
|
-
/**
|
|
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;
|