@browserstack/mcp-server 1.5.0-beta.11 → 1.5.0-beta.12

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,13 @@ 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`);
48
202
  }
49
203
  const text = String(value);
50
204
  if (param.values && param.values.length > 0) {
51
205
  const allowed = param.values.map((v) => String(v));
52
206
  if (!allowed.includes(text)) {
53
- throw new InvocationError(`'${param.name}' must be one of: ${allowed.join(", ")}`);
207
+ throw new InvocationError(`'${label}' must be one of: ${allowed.join(", ")}`);
54
208
  }
55
209
  }
56
210
  return text;
@@ -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`
@@ -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`
@@ -91,29 +97,12 @@ export function addCapabilityRegistryTools(server, deps, config) {
91
97
  : z.string();
92
98
  /** "tm, loadtesting" — for prose that has to name them. */
93
99
  const productList = productNames.join(", ") || "none loaded";
94
- /**
95
- * One line per product, for the ONE tool whose job is routing between them.
96
- *
97
- * Only listProducts carries the summaries. They are authored prose of unbounded length —
98
- * tm's is 90 characters, loadtesting's is 470 and a tool description is static context
99
- * on every request, so repeating them across five tools would cost more than the round
100
- * trip they save. Everywhere else the names alone are what a caller needs.
101
- *
102
- * Each summary is cut to its first sentence and capped, and the whole catalog is
103
- * budgeted: past the budget the names still route, which is the part that matters.
104
- */
105
- const productCatalog = (() => {
106
- const lines = productNames.map((name) => {
107
- const summary = registry.index.products[name].summary.trim();
108
- const firstSentence = summary.split(/(?<=\.)\s/)[0] ?? summary;
109
- const trimmed = firstSentence.length > 130
110
- ? `${firstSentence.slice(0, 127).trimEnd()}…`
111
- : firstSentence;
112
- return `${name} — ${trimmed.replace(/\.$/, "")}`;
113
- });
114
- const joined = lines.join("; ");
115
- return joined.length <= 500 ? joined : productList;
116
- })();
100
+ // NO PRODUCT CATALOG IN ANY DESCRIPTION. listProducts used to restate every product's
101
+ // trimmed summary in its own description duplicating, as static context on every
102
+ // single request, the exact thing the tool returns when called. Authored summaries are
103
+ // also unbounded (loadtesting's runs to 470 characters) and change with the artifact,
104
+ // so the prose went stale on its own. The names still travel in the `product` enum,
105
+ // which is where a client can actually validate them.
117
106
  const transport = deps.transport || fetchTransport();
118
107
  const tools = {};
119
108
  /** Instrumentation in the house style, and never fatal to the call it wraps. */
@@ -125,9 +114,9 @@ export function addCapabilityRegistryTools(server, deps, config) {
125
114
  // Telemetry must not decide whether a tool call succeeds.
126
115
  }
127
116
  };
128
- tools.listProducts = server.tool("listProducts", "List the BrowserStack products this surface can reach, with a one-line summary each. " +
129
- "Start here when you do not know which product a task belongs to. " +
130
- `This build carries ${productCatalog}.`, {}, {
117
+ tools.listProducts = server.tool("listProducts", "List the BrowserStack products this surface can reach, with a one-line summary each " +
118
+ "and the entities each one models. Start here when you do not know which product a " +
119
+ "task belongs to.", {}, {
131
120
  title: "List Capability Products",
132
121
  readOnlyHint: true,
133
122
  destructiveHint: false,
@@ -141,6 +130,18 @@ export function addCapabilityRegistryTools(server, deps, config) {
141
130
  products: registry.productNames().map((name) => ({
142
131
  name,
143
132
  summary: registry.index.products[name].summary,
133
+ // THE VOCABULARY, UP FRONT. Routing is this tool's whole job, and a summary
134
+ // alone does not do it: "add a tag to xyz test" reads as either product until
135
+ // you can see that `tag` exists in one and not the other. Measured across both
136
+ // products, 139 of 147 terms resolve to exactly one — so this settles 95% of
137
+ // the question before a single search, and makes the remaining 8 (run, project,
138
+ // report, result, folder, workspace, execution, history) visibly ambiguous
139
+ // instead of silently so.
140
+ //
141
+ // ~1.9KB for both products, on a tool called once for routing. The same content
142
+ // reaches a caller reactively via searchCapability's weak-match block; this is
143
+ // the proactive half, for the agent that looks before it leaps.
144
+ entities: vocabularyOf(registry.index.products, name)[name] ?? [],
144
145
  // Provenance for logging and cache-busting only — capability resolution must
145
146
  // never depend on it.
146
147
  build_id: info[name]?.build_id,
@@ -148,27 +149,22 @@ export function addCapabilityRegistryTools(server, deps, config) {
148
149
  })),
149
150
  });
150
151
  });
151
- tools.listEntities = server.tool("listEntities", "List the entities a product models (test case, folder, test plan, …). Use it to scope " +
152
- "searchCapability, or to find the entity name describeEntity wants.", {
153
- product: productArg().describe(`Which product to list entities for: ${productList}.`),
154
- }, {
155
- title: "List Product Entities",
156
- readOnlyHint: true,
157
- destructiveHint: false,
158
- idempotentHint: true,
159
- openWorldHint: false,
160
- }, async ({ product }) => {
161
- track("listEntities");
162
- const bundle = registry.index.products[product];
163
- if (!bundle)
164
- return failed(`unknown product '${product}'`);
165
- return ok({ product, entities: Object.keys(bundle.entities).sort() });
166
- });
152
+ // listEntities WAS HERE, and is gone. It answered `{product, entities: [names]}` a
153
+ // strict subset of what listProducts now returns for every product, and with the
154
+ // aliases missing. Keeping it would have meant two tools for one question, and a tool
155
+ // definition costs context on every request whether or not it is called, which is the
156
+ // entire reason this surface is five generic tools instead of 173 specific ones.
157
+ //
158
+ // One consequence to watch: listProducts now carries every product's vocabulary rather
159
+ // than one product's on request, so it grows with the number of products — roughly 1KB
160
+ // each. That is the right trade while routing is the problem it solves (you cannot
161
+ // choose between products by looking at one of them), but past a dozen products it may
162
+ // need a names-only default with aliases on request.
167
163
  tools.describeEntity = server.tool("describeEntity", "Describe one entity: what it is, what identifies it, what it relates to, and the " +
168
164
  "vocabulary the product uses for it. Read this before filtering or writing, because " +
169
165
  "ids and field values usually have to be resolved first.", {
170
166
  product: productArg().describe(`Which product the entity belongs to: ${productList}.`),
171
- entity: z.string().describe("Entity name from listEntities."),
167
+ entity: z.string().describe("Entity name, as listProducts returns it."),
172
168
  }, {
173
169
  title: "Describe Entity",
174
170
  readOnlyHint: true,
@@ -196,20 +192,21 @@ export function addCapabilityRegistryTools(server, deps, config) {
196
192
  "using its vocabulary, or call describeEntity on it for the fuller picture. That one " +
197
193
  "extra round trip is far cheaper than invoking the wrong capability. " +
198
194
  "Narrowing with `product` or `entity` sharpens results further. " +
199
- "Each result carries the capability's `name` the handle you pass to " +
200
- "invokeCapability — plus its `method` and `path` (use those two only when a result " +
201
- "has no `name`), and its parameters grouped into path_params / query / body under " +
202
- "the spec's own names. Pass them straight back, no renaming. `intent` says what it does, " +
203
- "`mode` tells you whether it writes, `product` says which product owns it, and " +
204
- "`responses` describes what a successful call returns, fully expanded. Results are " +
205
- "ranked and capped, and `truncated` says when more matched. Search before invoking.", {
195
+ "THIS IS A SHORTLIST, NOT A CONTRACT. Each result carries only what you need to " +
196
+ "CHOOSE: `name` (the handle), `product` (which product owns it results can span " +
197
+ "products), `mode` (whether it writes), `intent` and `guidance` (what it does and " +
198
+ "what goes wrong), and `method`/`path` for products that publish no name yet. " +
199
+ "It does NOT carry parameters or response shapes. Once you have picked one, call " +
200
+ "describeCapability for its full contract, then invokeCapability. Fetching the " +
201
+ "contract only for the one you chose is the difference between ~1k and ~8.6k tokens " +
202
+ "a search. Results are ranked and capped, and `truncated` says when more matched.", {
206
203
  query: z
207
204
  .string()
208
205
  .describe("What you are trying to do, in plain language."),
209
206
  entity: z
210
207
  .string()
211
208
  .optional()
212
- .describe("Restrict to one entity (see listEntities)."),
209
+ .describe("Restrict to one entity (listProducts names them)."),
213
210
  product: productArg()
214
211
  .optional()
215
212
  .describe(`Restrict to one product: ${productList}. Omit to search all of them.`),
@@ -218,21 +215,14 @@ export function addCapabilityRegistryTools(server, deps, config) {
218
215
  .optional()
219
216
  .describe("Restrict to reads or writes. Omit to let the query decide."),
220
217
  limit: z.number().optional().describe("Max results (default 8)."),
221
- include_responses: z
222
- .enum(["success", "all", "none"])
223
- .optional()
224
- .describe("Which declared responses to expand: 'success' (default, the 2xx shape), 'all' " +
225
- "(adds the error shapes — several times larger, and near-identical across " +
226
- "endpoints), or 'none'."),
227
218
  }, {
228
219
  title: "Search Capabilities",
229
220
  readOnlyHint: true,
230
221
  destructiveHint: false,
231
222
  idempotentHint: true,
232
223
  openWorldHint: false,
233
- }, async ({ query, entity, product, mode, limit, include_responses }) => {
224
+ }, async ({ query, entity, product, mode, limit }) => {
234
225
  track("searchCapability");
235
- const selection = (include_responses || "success");
236
226
  const { hits, weak, top_matched, ...rest } = searchCapabilities(registry.index.products, query, {
237
227
  entity,
238
228
  product,
@@ -241,23 +231,28 @@ export function addCapabilityRegistryTools(server, deps, config) {
241
231
  });
242
232
  return ok({
243
233
  build_id: registry.buildId,
244
- // Dereferenced HERE rather than in the artifact: the tables store each response and
245
- // schema once and the capabilities name them, so the file stays a third of the size
246
- // it would be inlined. Expanding on the way out means the caller never has to
247
- // resolve a `{"$schema": "…"}` itself, and never sees one.
248
- capabilities: hits.map(({ product: owner, capability }) => {
249
- const responses = resolveResponses(registry.index.products[owner], capability, selection);
250
- // The raw field is dropped, not merely overwritten: it holds `{"$response": …}`
251
- // references, and spreading the capability would leak them straight through
252
- // whenever the resolved value is absent.
253
- const { responses: unresolved, ...rest } = capability;
254
- void unresolved;
255
- return {
256
- ...rest,
257
- product: owner,
258
- ...(responses ? { responses } : {}),
259
- };
260
- }),
234
+ // A SHORTLIST: only what choosing requires. Parameters and response shapes are 86%
235
+ // of a full record and are needed for exactly ONE of the eight the one the caller
236
+ // picks so they move to describeCapability. Measured over eight queries: 8.6k
237
+ // tokens a search becomes ~1k, and even describing all eight results still costs
238
+ // slightly less than today.
239
+ //
240
+ // `product` is here because results span products and the caller cannot otherwise
241
+ // tell a Load Testing row from a Test Management one. `method`/`path` are here
242
+ // because Load Testing publishes no names at all — without them its rows would be
243
+ // unaddressable, which is worse than verbose.
244
+ capabilities: hits.map(({ product: owner, capability }) => ({
245
+ ...(capability.name ? { name: capability.name } : {}),
246
+ product: owner,
247
+ method: capability.method,
248
+ path: capability.path,
249
+ mode: capability.mode,
250
+ entity: capability.entity,
251
+ ...(capability.intent ? { intent: capability.intent } : {}),
252
+ ...(capability.guidance?.length
253
+ ? { guidance: capability.guidance }
254
+ : {}),
255
+ })),
261
256
  ...rest,
262
257
  // WHEN THE MATCH IS WEAK, HAND OVER THE VOCABULARY.
263
258
  //
@@ -281,7 +276,79 @@ export function addCapabilityRegistryTools(server, deps, config) {
281
276
  : {}),
282
277
  });
283
278
  });
284
- tools.invokeCapability = server.tool("invokeCapability", "Call a capability returned by searchCapability or describeEntity. Pass `name` exactly " +
279
+ tools.describeCapability = server.tool("describeCapability", "The full contract for ONE capability you picked from searchCapability: its " +
280
+ "parameters grouped into path_params / query / body under the spec's own names, " +
281
+ "what it returns, and its declared response shapes fully expanded. Call this after " +
282
+ "search and before invokeCapability — search deliberately omits all of it, because " +
283
+ "it is only needed for the one capability you actually call. " +
284
+ "Identify it by `name`, exactly as search returned it; only when a result carries " +
285
+ "no `name` (some products publish none yet) pass `method` and `path` instead. " +
286
+ "Every parameter lists its type, whether it is required, its allowed values where " +
287
+ "the set is closed, and any limits the product declares — obey those before calling " +
288
+ "rather than discovering them from a rejected request.", {
289
+ name: z
290
+ .string()
291
+ .optional()
292
+ .describe("The capability's published name, exactly as searchCapability returned it."),
293
+ method: z
294
+ .string()
295
+ .optional()
296
+ .describe("HTTP method — only for capabilities returned without a `name`."),
297
+ path: z
298
+ .string()
299
+ .optional()
300
+ .describe("Path with {placeholders} intact — only for capabilities returned without a `name`."),
301
+ product: productArg()
302
+ .optional()
303
+ .describe(`Which product owns it (${productList}). searchCapability returns it on every ` +
304
+ "result; required only when two products share a name or a path."),
305
+ include_responses: z
306
+ .enum(["success", "all", "none"])
307
+ .optional()
308
+ .describe("Which declared responses to expand: 'success' (default, the 2xx shape), 'all' " +
309
+ "(adds the error shapes — several times larger, and near-identical across " +
310
+ "endpoints), or 'none'."),
311
+ }, {
312
+ title: "Describe Capability",
313
+ readOnlyHint: true,
314
+ destructiveHint: false,
315
+ idempotentHint: true,
316
+ openWorldHint: false,
317
+ }, async (input) => {
318
+ track("describeCapability");
319
+ try {
320
+ // The same two handles as invokeCapability, resolved the same way, so a name that
321
+ // describes is a name that invokes. Divergence here would be its own bug class.
322
+ if (!input.name && !(input.method && input.path)) {
323
+ return failed("pass `name` — or, for a capability returned without one, both `method` and " +
324
+ "`path`, exactly as searchCapability returned them");
325
+ }
326
+ const { product: owner, capability } = input.name
327
+ ? registry.byNameLookup(input.name, input.product)
328
+ : registry.byEndpointLookup(input.method, input.path, input.product);
329
+ const selection = (input.include_responses ||
330
+ "success");
331
+ const responses = resolveResponses(registry.index.products[owner], capability, selection);
332
+ // Dropped rather than overwritten: the raw field holds `{"$response": …}` pointers,
333
+ // and spreading the capability would leak them through whenever the resolved value
334
+ // is absent.
335
+ const { responses: unresolved, ...contract } = capability;
336
+ void unresolved;
337
+ return ok({
338
+ build_id: registry.buildId,
339
+ product: owner,
340
+ ...contract,
341
+ ...(responses ? { responses } : {}),
342
+ });
343
+ }
344
+ catch (error) {
345
+ if (error instanceof InvocationError)
346
+ return failed(error.message);
347
+ logger.error("describeCapability failed: %s", error instanceof Error ? error.message : String(error));
348
+ return failed("that capability could not be described");
349
+ }
350
+ });
351
+ tools.invokeCapability = server.tool("invokeCapability", "Call a capability whose contract you have from describeCapability. Pass `name` exactly " +
285
352
  "as given — that is the handle. Only when a result carries no `name` (some products " +
286
353
  "do not publish them yet) pass `method` and `path` instead, exactly as returned. " +
287
354
  "Arguments go in path_params / query / body under the spec's own names. One call " +
@@ -66,12 +66,13 @@ export interface SearchResult {
66
66
  * separates them is how little the match is worth.
67
67
  */
68
68
  top_matched: number;
69
+ /** `top_matched` as a fraction of a perfect match — the corpus-independent form. */
70
+ coverage: number;
69
71
  weak: boolean;
70
72
  }
71
73
  /** One entity's caller-facing vocabulary: what it is called, and what else it is called. */
72
74
  export interface VocabularyEntry {
73
75
  entity: string;
74
- title?: string;
75
76
  aliases?: string[];
76
77
  }
77
78
  /**
@@ -83,10 +84,15 @@ export interface VocabularyEntry {
83
84
  * language model, and given tm's entity list it maps bucket -> folder without effort. It
84
85
  * just cannot guess the list unprompted.
85
86
  *
86
- * Aliases only, deliberately. They are the vocabulary map 19 entities in ~1.5KB, against a
87
- * response that is routinely 38KB. The entity `key_facts` are richer prose but ten times the
88
- * size, and a caller who needs them can ask describeEntity once it knows which entity to ask
87
+ * Aliases only, and `title` dropped as a near-duplicate of `entity` ("Test run" next to
88
+ * `test_run` buys nothing). The entity `key_facts` are richer prose but ten times the size,
89
+ * and a caller who needs them can ask describeEntity once it knows which entity to ask
89
90
  * about — which is exactly what this hands over.
91
+ *
92
+ * Its share of the response grew when search became a shortlist: 3.2KB against a 38KB full
93
+ * search was 8%, against a 6.8KB shortlist it is nearly half. The absolute cost did not
94
+ * change and is still under a thousand tokens — far less than one wrong invoke — so this
95
+ * is budgeted in bytes rather than as a fraction of a baseline that now moves.
90
96
  */
91
97
  export declare function vocabularyOf(products: Record<string, ProductIndex>, only?: string): Record<string, VocabularyEntry[]>;
92
98
  export declare function searchCapabilities(products: Record<string, ProductIndex>, query?: string, options?: {