@browserstack/mcp-server 1.5.1 → 2.0.0-beta.1

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.
Files changed (38) hide show
  1. package/README.md +54 -1
  2. package/capability/tm.capability-index.json +50420 -0
  3. package/capability/tra.capability-index.json +52096 -0
  4. package/dist/lib/central-oauth.d.ts +133 -0
  5. package/dist/lib/central-oauth.js +246 -0
  6. package/dist/lib/instrumentation.d.ts +14 -1
  7. package/dist/lib/instrumentation.js +8 -1
  8. package/dist/server-factory.js +5 -0
  9. package/dist/tools/ask-browserstack/central-oauth.d.ts +25 -46
  10. package/dist/tools/ask-browserstack/central-oauth.js +55 -177
  11. package/dist/tools/capability-registry/bind.d.ts +28 -0
  12. package/dist/tools/capability-registry/bind.js +407 -0
  13. package/dist/tools/capability-registry/config.d.ts +62 -0
  14. package/dist/tools/capability-registry/config.js +218 -0
  15. package/dist/tools/capability-registry/discovery.d.ts +44 -0
  16. package/dist/tools/capability-registry/discovery.js +101 -0
  17. package/dist/tools/capability-registry/egress.d.ts +51 -0
  18. package/dist/tools/capability-registry/egress.js +197 -0
  19. package/dist/tools/capability-registry/index-loader.d.ts +162 -0
  20. package/dist/tools/capability-registry/index-loader.js +523 -0
  21. package/dist/tools/capability-registry/oauth.d.ts +63 -0
  22. package/dist/tools/capability-registry/oauth.js +176 -0
  23. package/dist/tools/capability-registry/redact.d.ts +34 -0
  24. package/dist/tools/capability-registry/redact.js +98 -0
  25. package/dist/tools/capability-registry/register.d.ts +48 -0
  26. package/dist/tools/capability-registry/register.js +956 -0
  27. package/dist/tools/capability-registry/resolve.d.ts +38 -0
  28. package/dist/tools/capability-registry/resolve.js +104 -0
  29. package/dist/tools/capability-registry/routing-token.d.ts +25 -0
  30. package/dist/tools/capability-registry/routing-token.js +87 -0
  31. package/dist/tools/capability-registry/search.d.ts +197 -0
  32. package/dist/tools/capability-registry/search.js +907 -0
  33. package/dist/tools/capability-registry/types.d.ts +341 -0
  34. package/dist/tools/capability-registry/types.js +33 -0
  35. package/dist/tools/testmanagement.js +5 -5
  36. package/dist/tools/tool-handoff.d.ts +15 -12
  37. package/dist/tools/tool-handoff.js +21 -18
  38. package/package.json +10 -3
@@ -0,0 +1,956 @@
1
+ /**
2
+ * The tool surface: four discovery tools plus ONE invoke tool.
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
+ *
10
+ * ONE invoke tool means one set of MCP annotations, so they describe the whole surface
11
+ * honestly: it can write (not read-only) and it can never delete, because destructive
12
+ * endpoints are refused before binding. Write consent therefore rests on `user_permission`
13
+ * enforced HERE rather than on a client-side hint — which is the one thing a separate
14
+ * read/write tool pair was buying.
15
+ */
16
+ import { signRouting, readRouting } from "./routing-token.js";
17
+ import { z } from "zod";
18
+ import logger from "../../logger.js";
19
+ import { trackMCP } from "../../lib/instrumentation.js";
20
+ import { indexPaths, isEnabled, resolveBaseUrl } from "./config.js";
21
+ import { redact } from "./redact.js";
22
+ import { fetchTransport } from "./egress.js";
23
+ import { CapabilityRegistry, InvocationError, resolveResponses, } from "./index-loader.js";
24
+ import { invoke } from "./resolve.js";
25
+ import { ambiguousProducts, productAnchors, isBrowseQuery, searchCapabilities, singular, terms, vocabularyOf, } from "./search.js";
26
+ export const PERMISSION_VALUES = ["not_asked", "granted", "denied"];
27
+ /**
28
+ * The search-side twin of `user_permission`: did a human choose the product, or did you?
29
+ *
30
+ * No `denied`. A refused write is a thing the caller must not do; a refused product choice
31
+ * is not a state — the user either named one, in which case you search it, or has not been
32
+ * asked, in which case there is nothing to search yet.
33
+ */
34
+ export const PRODUCT_CHOICE_VALUES = ["not_asked", "user_confirmed"];
35
+ /**
36
+ * The tool-adder the server factory calls.
37
+ *
38
+ * Registers NOTHING when the artifact is absent or unreadable, rather than throwing: a
39
+ * missing index is a packaging problem, and taking the whole MCP server down with it would
40
+ * remove every other product's tools too. The reason is logged so it is not silent.
41
+ */
42
+ export function addCapabilityRegistryToolsFromConfig(server, config) {
43
+ if (!isEnabled()) {
44
+ logger.info("capability registry disabled by CAPABILITY_REGISTRY_DISABLED");
45
+ return {};
46
+ }
47
+ const files = indexPaths();
48
+ if (files.length === 0) {
49
+ logger.warn("capability registry index not found; its tools are not registered. Set " +
50
+ "CAPABILITY_REGISTRY_INDEX_DIR, or ship capabilities/<product>.capability-index.json " +
51
+ "at the package root.");
52
+ return {};
53
+ }
54
+ let registry;
55
+ try {
56
+ registry = CapabilityRegistry.fromFiles(files);
57
+ }
58
+ catch (error) {
59
+ // One unreadable file fails the whole load on purpose (see `fromFiles`), so this is
60
+ // the only place that decides the surface is absent, and it says why.
61
+ logger.error("capability registry index unusable (%s): %s", files.join(", "), error instanceof Error ? error.message : String(error));
62
+ return {};
63
+ }
64
+ const loaded = registry.buildInfo();
65
+ logger.info("capability registry loaded: %d product(s) — %s", registry.productNames().length, registry
66
+ .productNames()
67
+ .map((name) => `${name} build ${loaded[name]?.build_id || "?"}` +
68
+ (loaded[name]?.version ? ` v${loaded[name].version}` : ""))
69
+ .join(", "));
70
+ // WITHHELD CAPABILITIES ARE LOGGED, because they are otherwise undetectable from
71
+ // outside: the flag's whole job is to make them invisible, so the count at startup is
72
+ // the only place an operator can see that the surface is smaller than the artifact.
73
+ const withheld = registry.disabledCounts();
74
+ if (Object.keys(withheld).length) {
75
+ logger.info("capability registry withholding disabled capabilities: %s", Object.entries(withheld)
76
+ .sort(([a], [b]) => a.localeCompare(b))
77
+ .map(([name, count]) => `${name} ${count}`)
78
+ .join(", "));
79
+ }
80
+ return addCapabilityRegistryTools(server, {
81
+ registry,
82
+ // The whole product bundle, not just its host: a region-sharded product declares
83
+ // several candidates and the probe needs an endpoint from its own capabilities.
84
+ baseUrlFor: (product) => resolveBaseUrl(product, config, registry.index.products[product]),
85
+ // Read per call, not captured: the remote server rebuilds config per session, so a
86
+ // captured credential would outlive the session it belongs to.
87
+ credentialsFor: () => ({
88
+ username: config["browserstack-username"],
89
+ accessKey: config["browserstack-access-key"],
90
+ }),
91
+ }, config);
92
+ }
93
+ function ok(payload) {
94
+ return { content: [{ type: "text", text: JSON.stringify(payload) }] };
95
+ }
96
+ function failed(message, extra) {
97
+ return {
98
+ content: [
99
+ {
100
+ type: "text",
101
+ text: JSON.stringify({ ok: false, error: message, ...extra }),
102
+ },
103
+ ],
104
+ isError: true,
105
+ };
106
+ }
107
+ export function addCapabilityRegistryTools(server, deps, config) {
108
+ const { registry } = deps;
109
+ const productNames = registry.productNames();
110
+ /**
111
+ * The `product` argument, typed to what this build actually carries.
112
+ *
113
+ * An enum rather than a free string, so the accepted values travel in the SCHEMA the
114
+ * client validates against — the model sees them without spending a `listProducts` call,
115
+ * and a typo is rejected before the handler runs instead of coming back as a tool error.
116
+ * The set is fixed for the session because the index is read once at registration.
117
+ */
118
+ const productArg = () => productNames.length > 0
119
+ ? z.enum(productNames)
120
+ : z.string();
121
+ /** "tm, loadtesting" — for prose that has to name them. */
122
+ const productList = productNames.join(", ") || "none loaded";
123
+ // NO PRODUCT CATALOG IN ANY DESCRIPTION. listProducts used to restate every product's
124
+ // trimmed summary in its own description — duplicating, as static context on every
125
+ // single request, the exact thing the tool returns when called. Authored summaries are
126
+ // also unbounded (loadtesting's runs to 470 characters) and change with the artifact,
127
+ // so the prose went stale on its own. The names still travel in the `product` enum,
128
+ // which is where a client can actually validate them.
129
+ const transport = deps.transport || fetchTransport();
130
+ const tools = {};
131
+ /** Instrumentation in the house style, and never fatal to the call it wraps. */
132
+ const track = (name, extras) => {
133
+ try {
134
+ trackMCP(name, server.server.getClientVersion(), undefined, config, extras);
135
+ }
136
+ catch {
137
+ // Telemetry must not decide whether a tool call succeeds.
138
+ }
139
+ };
140
+ /**
141
+ * An InvocationError's reason, from the prefix the registry itself writes. These are our
142
+ * own messages from bind.ts / index-loader.ts, not a product's prose.
143
+ *
144
+ * EVERY BRANCH HERE IS A STRING MATCH AGAINST ANOTHER FILE'S WORDING, which is exactly
145
+ * as brittle as it sounds — `no_base_url` matched "no base URL is configured" while
146
+ * config.ts throws "no host is configured", so that reason never once fired. The tests
147
+ * below pin each branch against the message its source actually produces; if you reword
148
+ * a refusal, one of them will tell you.
149
+ */
150
+ const invocationReason = (message) => {
151
+ if (message.startsWith("unknown_capability:"))
152
+ return "unknown_capability";
153
+ if (message.startsWith("unknown_endpoint:"))
154
+ return "unknown_endpoint";
155
+ // The outcome this PR turns on: 46 of 244 capabilities are withheld, so this is the
156
+ // likeliest refusal of all, and it was landing in the generic bucket.
157
+ if (message.startsWith("capability_disabled:"))
158
+ return "capability_disabled";
159
+ // Auth failed before the product was ever called, so it is not the product's 401 and
160
+ // must not be counted as one. oauth.ts prefixes every one of these.
161
+ if (message.startsWith("token_mint_failed:"))
162
+ return "token_mint_failed";
163
+ if (message.startsWith("missing required parameter"))
164
+ return "missing_parameter";
165
+ if (/is not a usable path value/.test(message))
166
+ return "bad_path_value";
167
+ if (/^'[^']+' must be /.test(message))
168
+ return "bad_parameter_type";
169
+ if (/^'[^']+' must match /.test(message))
170
+ return "bad_parameter_pattern";
171
+ // config.ts's wording, not a paraphrase of it.
172
+ if (message.includes("no host is configured"))
173
+ return "no_base_url";
174
+ return "invocation_error";
175
+ };
176
+ /**
177
+ * A refusal or a failure, recorded and returned in one step.
178
+ *
179
+ * Every error path here returns through `failed()`, which produced no telemetry at all:
180
+ * BigQuery showed zero registry failures across 1,069 calls in 30 days. `refusal_reason`
181
+ * distinguishes what we refused before any network call from what the product rejected.
182
+ */
183
+ const refuse = (tool, reason, message, extra, extras) => {
184
+ try {
185
+ trackMCP(tool, server.server.getClientVersion(), new Error(message), config, { refusal_reason: reason, ...(extras ?? {}) });
186
+ }
187
+ catch {
188
+ // Telemetry must not decide whether a tool call succeeds.
189
+ }
190
+ return failed(message, extra);
191
+ };
192
+ tools.listProducts = server.tool("listProducts", "List the BrowserStack products this surface can reach: what each one does, the " +
193
+ "entities it models, and a line saying what each entity is. START HERE — " +
194
+ "searchCapability needs a product, and this is what tells you which one. If two " +
195
+ "products could both fit the task, ask the user rather than choosing for them. " +
196
+ "ALWAYS pass the user's request, in their own words, as `query`: the answer then " +
197
+ "says outright whether the products clash over the words in it. WHEN IT REPORTS A " +
198
+ "CLASH — two products claiming the same word for different things — ASK THE USER " +
199
+ "WHICH PRODUCT THEY MEAN AND WAIT FOR THEIR ANSWER BEFORE SEARCHING OR INVOKING " +
200
+ "ANYTHING. The response carries the question to put to them and what the shared " +
201
+ "word means in each product. Choosing for them, or searching each product in turn " +
202
+ "and merging the results, is the failure this call exists to prevent.", {
203
+ query: z
204
+ .string()
205
+ .optional()
206
+ .describe("The user's request, in their own words. With it the response carries a " +
207
+ "`routing` block saying whether the products share the vocabulary this " +
208
+ "request uses — and, when they do, the question to put to the user before " +
209
+ "searching either product."),
210
+ }, {
211
+ title: "List Capability Products",
212
+ readOnlyHint: true,
213
+ destructiveHint: false,
214
+ idempotentHint: true,
215
+ openWorldHint: false,
216
+ }, async ({ query }) => {
217
+ track("listProducts");
218
+ // THE CLASH, STATED RATHER THAN LEFT TO BE NOTICED. The entity lists below carry
219
+ // the evidence — `report` under two products — but reading two lists and spotting
220
+ // the overlap is work an agent skips when it already has a product in mind. So the
221
+ // overlap is computed here and put at the top, with the question already written:
222
+ // the cheap failure is picking one silently, and this is the call that precedes it.
223
+ const ambiguity = query
224
+ ? ambiguousProducts(registry.index.products, query)
225
+ : { products: [], terms: [] };
226
+ // A CLASH ANNOUNCED HERE BINDS THE NEXT SEARCH. Reporting it and then letting the
227
+ // very next call through was the gap the measured runs walked through: the agent
228
+ // read the clash, searched one product and then the other, and asked only
229
+ // afterwards. The gate downstream judges the caller's paraphrase when no user words
230
+ // are quoted, and a paraphrase carries none of the shared words that caused this —
231
+ // so the refusal never fired. Opening a token here makes the question outlive the
232
+ // call that raised it, and `user_words` is what closes it.
233
+ // THE VERDICT TRAVELS WITH THE CALLER. Signed here, read back by the search — which
234
+ // is how the search can know what this call concluded about the whole request
235
+ // without the server keeping a note between the two.
236
+ const listingToken = query
237
+ ? signRouting(ambiguity.products.length > 1
238
+ ? { verdict: "clash", terms: ambiguity.terms }
239
+ : ambiguity.unknown
240
+ ? { verdict: "blank" }
241
+ : { verdict: "settled", product: ambiguity.settled })
242
+ : undefined;
243
+ const routing = ambiguity.products.length > 1
244
+ ? {
245
+ verdict: "products_clash",
246
+ note: `These products clash over ${ambiguity.terms.map((t) => `'${t}'`).join(", ")} — ` +
247
+ `the same word means something different in ${ambiguity.products.join(" and ")}. ` +
248
+ "ASK THE USER which they mean before searching or invoking anything; do not " +
249
+ "search each product in turn and merge the results. Searching before you " +
250
+ "ask will be refused: send the user's answer in `user_words` with this " +
251
+ "`routing_token`.",
252
+ routing_token: listingToken,
253
+ shared_terms: ambiguity.terms,
254
+ products: ambiguity.products,
255
+ question: `Which product do you mean — ${ambiguity.products.join(" or ")}?`,
256
+ senses: ambiguity.products.map((name) => ({
257
+ product: name,
258
+ means: ambiguity.terms
259
+ .map((term) => {
260
+ const entities = registry.index.products[name]?.entities ?? {};
261
+ const key = Object.keys(entities).find((candidate) => [
262
+ candidate,
263
+ ...(entities[candidate].aliases ?? []),
264
+ ].some((word) => terms(word).map(singular).join(" ") === term));
265
+ return key
266
+ ? {
267
+ term,
268
+ entity: key,
269
+ description: entities[key].description,
270
+ }
271
+ : undefined;
272
+ })
273
+ .filter(Boolean),
274
+ })),
275
+ }
276
+ : query && ambiguity.unknown
277
+ ? {
278
+ verdict: "no_vocabulary",
279
+ note: "Nothing in this request names a product, or any word that belongs to " +
280
+ "one — so there is no evidence here about which product is meant. This " +
281
+ "is the LEAST settled a request can be, not the most. ASK THE USER " +
282
+ "which product they mean before searching or invoking anything. " +
283
+ "Searching before you ask will be refused: send their answer in " +
284
+ "`user_words` with this `routing_token`.",
285
+ routing_token: listingToken,
286
+ question: `Which product do you mean — ${registry
287
+ .productNames()
288
+ .join(" or ")}?`,
289
+ }
290
+ : query
291
+ ? {
292
+ verdict: "no_clash",
293
+ note: ambiguity.settled
294
+ ? `This request names words only ${ambiguity.settled} claims` +
295
+ (ambiguity.because?.length
296
+ ? ` (${ambiguity.because.map((t) => `'${t}'`).join(", ")})`
297
+ : "") +
298
+ `, so it settles on ${ambiguity.settled} without asking. SEARCH ` +
299
+ `${ambiguity.settled}; the other products do not answer this request.`
300
+ : "No word in this request is claimed by more than one product, so the " +
301
+ "entity lists below settle it without asking.",
302
+ routing_token: listingToken,
303
+ ...(ambiguity.settled ? { product: ambiguity.settled } : {}),
304
+ ...(ambiguity.because?.length
305
+ ? { deciding_terms: ambiguity.because }
306
+ : {}),
307
+ }
308
+ : undefined;
309
+ return ok({
310
+ ...(routing ? { routing } : {}),
311
+ products: registry.productNames().map((name) => ({
312
+ name,
313
+ summary: registry.index.products[name].summary,
314
+ // THE ENTITIES AND WHAT EACH ONE IS. Routing is this tool's whole job, and a
315
+ // product summary alone does not do it: "add a tag to xyz test" reads as
316
+ // either product until you can see that `tag` exists in one and not the other.
317
+ // The one-line description is what makes each name mean something — `version`
318
+ // alone does not say whether it versions a test case or a project — so an
319
+ // agent can choose here instead of calling describeEntity once per entity to
320
+ // find out, which for tm is 19 calls at ~1.4KB apiece.
321
+ //
322
+ // NAME AND DESCRIPTION ONLY. The aliases used to travel here too, and they are
323
+ // the wrong half for this job: they answer "what else is this called", which
324
+ // matters when a search has already failed on vocabulary, not when choosing a
325
+ // product. They still reach the caller at exactly that moment, in
326
+ // searchCapability's weak-match block, where the full vocabulary is the answer
327
+ // rather than 2.5KB of speculative context on every routing call.
328
+ entities: (vocabularyOf(registry.index.products, name)[name] ?? []).map(({ entity, description }) => ({
329
+ entity,
330
+ ...(description ? { description } : {}),
331
+ })),
332
+ // NO build_id OR version. They are provenance — for our logs and for cache
333
+ // busting — and capability resolution must never depend on them, which means
334
+ // no caller has anything to do with them. They rode on the one call an agent
335
+ // makes before it knows anything, costing context to say nothing actionable.
336
+ // Still on searchCapability's response, where a support question about which
337
+ // index answered can actually be traced to a result.
338
+ })),
339
+ });
340
+ });
341
+ // listEntities WAS HERE, and is gone. It answered `{product, entities: [names]}` — a
342
+ // strict subset of what listProducts now returns for every product, and with the
343
+ // aliases missing. Keeping it would have meant two tools for one question, and a tool
344
+ // definition costs context on every request whether or not it is called, which is the
345
+ // entire reason this surface is five generic tools instead of 173 specific ones.
346
+ //
347
+ // One consequence to watch: listProducts now carries every product's vocabulary rather
348
+ // than one product's on request, so it grows with the number of products — roughly 1KB
349
+ // each. That is the right trade while routing is the problem it solves (you cannot
350
+ // choose between products by looking at one of them), but past a dozen products it may
351
+ // need a names-only default with aliases on request.
352
+ tools.describeEntity = server.tool("describeEntity", "Describe one entity: what it is, what identifies it, what it relates to, and the " +
353
+ "vocabulary the product uses for it. Read this before filtering or writing, because " +
354
+ "ids and field values usually have to be resolved first.", {
355
+ product: productArg().describe(`Which product the entity belongs to: ${productList}.`),
356
+ entity: z.string().describe("Entity name, as listProducts returns it."),
357
+ }, {
358
+ title: "Describe Entity",
359
+ readOnlyHint: true,
360
+ destructiveHint: false,
361
+ idempotentHint: true,
362
+ openWorldHint: false,
363
+ }, async ({ product, entity }) => {
364
+ track("describeEntity");
365
+ const bundle = registry.index.products[product];
366
+ if (!bundle)
367
+ return refuse("describeEntity", "unknown_product", `unknown product '${product}'`, undefined, { product });
368
+ const doc = bundle.entities[entity];
369
+ if (!doc) {
370
+ return failed(`unknown entity '${entity}' in ${product}; known: ${Object.keys(bundle.entities).sort().join(", ")}`);
371
+ }
372
+ return ok({ product, entity, ...doc });
373
+ });
374
+ tools.searchCapability = server.tool("searchCapability", "Find endpoints this surface can call, by plain language, within ONE product. " +
375
+ "CALL listProducts FIRST, passing the user's request as its `query`: it names the " +
376
+ "products, says whether they clash over the words in that request, and gives you " +
377
+ "the question to ask when they do. You need it anyway — `product` here takes a name " +
378
+ "exactly as listProducts spells it. Where two products use the same word for " +
379
+ "different things, ask the USER and wait for an answer rather than picking. " +
380
+ "Search matches the product's OWN words, not synonyms. When your words are not the " +
381
+ "product's, the response says so: `weak_match: true` with a `suggested_vocabulary` " +
382
+ "map of the product's entities and their aliases. Results are still returned, but " +
383
+ "treat them as unconfirmed — pick the closest entity from that map and search again " +
384
+ "using its vocabulary, or call describeEntity on it for the fuller picture. That one " +
385
+ "extra round trip is far cheaper than invoking the wrong capability. " +
386
+ "Narrowing further with `entity` sharpens results. " +
387
+ "THIS IS A SHORTLIST, NOT A CONTRACT. Each result carries only what you need to " +
388
+ "CHOOSE: `name` (the handle), `product`, `mode` (whether it writes), `intent` and " +
389
+ "`guidance` (what it does and what goes wrong), and `method`/`path` for products " +
390
+ "that publish no name yet. " +
391
+ "It does NOT carry parameters or response shapes. Once you have picked one, call " +
392
+ "describeCapability for its full contract, then invokeCapability. Fetching the " +
393
+ "contract only for the one you chose is the difference between ~1k and ~8.6k tokens " +
394
+ "a search. Results are ranked and capped. " +
395
+ "TO SEE EVERYTHING rather than search, pass query='*' — that lists the product " +
396
+ "(honouring `entity` and `mode`) in a stable order instead of ranking it, which is " +
397
+ "what you want when the task is 'what can this product do' rather than a lookup. " +
398
+ "TO PAGE, send `offset`: every response carries `offset` and, when more matched, " +
399
+ "`next_offset` — resend the SAME query with that value. `next_offset` is absent on " +
400
+ "the last page, so walk until it stops rather than comparing counts.", {
401
+ query: z
402
+ .string()
403
+ .describe("What you are trying to do, in plain language. Pass '*' (or 'all') to BROWSE " +
404
+ "instead of search: every capability in the product, in a stable order, " +
405
+ "paginated — use it when you want to see the surface rather than find one thing."),
406
+ entity: z
407
+ .string()
408
+ .optional()
409
+ .describe("Restrict to one entity (listProducts names them)."),
410
+ // REQUIRED, and the reason is the tool that is NOT being called. listProducts
411
+ // carries the routing data — each product's purpose, its entities, and what every
412
+ // entity means — but nothing obliged an agent to read it: a search that worked
413
+ // without naming a product meant the routing step could always be skipped, and an
414
+ // optional step in front of a working one is a step that does not happen. Requiring
415
+ // the argument makes the ordering structural instead of advisory.
416
+ //
417
+ // It costs one listProducts call on queries that were already unambiguous — 139 of
418
+ // 147 vocabulary terms resolve to a single product — and buys the eight that are
419
+ // not (run, project, report, result, folder, workspace, execution, history), where
420
+ // the old behaviour was to silently pick whichever product ranked higher. A wasted
421
+ // round trip against a silent wrong-product answer is not a close trade.
422
+ // A FREE STRING, NOT AN ENUM, AND DELIBERATELY SO — this is the only argument on
423
+ // this surface that is. An enum publishes the product names in the schema, which
424
+ // every client shows the model before it calls anything, so a model that has never
425
+ // called listProducts already knows `tm` and `tra` exist and picks one. Measured:
426
+ // on the requests that still routed silently, listProducts was never called at all
427
+ // — there was nothing left to learn from it. Withholding the names makes the
428
+ // discovery call the only way to obtain one, and discovery is where the clash gets
429
+ // reported. The cost is losing client-side validation of a typo; an unknown name is
430
+ // refused here instead, by a message that names listProducts.
431
+ product: z
432
+ .string()
433
+ .describe("Which product to search, named exactly as listProducts spells it. Call " +
434
+ "listProducts with the user's request to get the name — and to find out " +
435
+ "whether the products clash over it, in which case ask the user first."),
436
+ // `product_choice` USED TO LIVE HERE, AND WAS ITS OWN DEFEAT.
437
+ //
438
+ // A required enum of 'user_confirmed' | 'not_asked' publishes, in the schema every
439
+ // client shows the model before it calls anything, the exact string that opens the
440
+ // gate. Measured on the prompts that still routed silently: the caller wrote
441
+ // `user_confirmed` on every single one, with no user input behind any of them. A
442
+ // flag the caller fills in about itself can always be filled in; there is no wording
443
+ // of it that makes it true. What replaced it is a token this server mints and the
444
+ // caller cannot guess, plus `user_words`, which is a claim the index can test.
445
+ routing_token: z
446
+ .string()
447
+ .optional()
448
+ .describe("The `routing_token` from the last listProducts call or product_ambiguous " +
449
+ "refusal — it carries what that step concluded about the user's request, " +
450
+ "signed. Always pass the most recent one. When it reports a clash, send the " +
451
+ "user's answer in `user_words` alongside it; neither works alone."),
452
+ user_words: z
453
+ .string()
454
+ .optional()
455
+ .describe("The USER'S OWN words, verbatim, that settle which product this is — either " +
456
+ "the product's name or a term only one product uses ('quality gate', 'test " +
457
+ "plan'). Quote them; do not paraphrase, and do not write words the user did " +
458
+ "not say. When a query could mean either product this is what lifts the " +
459
+ "refusal, because it is checked against the index rather than taken on trust."),
460
+ mode: z
461
+ .enum(["read", "write", "destructive"])
462
+ .optional()
463
+ .describe("Restrict to reads or writes. Omit to let the query decide."),
464
+ limit: z.number().optional().describe("Max results (default 8)."),
465
+ offset: z
466
+ .number()
467
+ .optional()
468
+ .describe("Skip this many results — send the `next_offset` from the previous response " +
469
+ "with the SAME query to get the next page. Omit for the first page."),
470
+ }, {
471
+ title: "Search Capabilities",
472
+ readOnlyHint: true,
473
+ destructiveHint: false,
474
+ idempotentHint: true,
475
+ openWorldHint: false,
476
+ }, async ({ query, entity, product, routing_token, user_words, mode, limit, offset, }) => {
477
+ // THE GATE. Nothing here can tell whether a human was asked — same as the write
478
+ // gate, which also takes the caller's word. What it can do is refuse to answer a
479
+ // question that is genuinely the user's, so that answering it anyway takes a
480
+ // deliberate claim instead of silence.
481
+ //
482
+ // Only when the query itself cannot settle the choice: every recognised word is one
483
+ // both products claim. 27 of the 198 eval queries, against 135 if constituent words
484
+ // counted. `entity` is an explicit narrowing, so a caller that named one has already
485
+ // been specific enough and is not asked again.
486
+ // The ambiguity gate asks the USER which product a word belongs to. A browse query
487
+ // contains no word to be ambiguous about — `*` names nothing — so there is nothing
488
+ // to ask, and asking anyway would block the one query whose whole purpose is to
489
+ // show what a product contains.
490
+ // `product_choice` is the caller describing itself, and a caller that wants to
491
+ // proceed says user_confirmed — measured at 103 of 183 ambiguous prompts with no
492
+ // user input behind it, which skipped this gate entirely. So under the flag the
493
+ // ambiguity check runs whatever the flag says, and what lifts it is `user_words`:
494
+ // the user's own text, checked here against the index. Quoting the conversation is
495
+ // a claim the server can test; a boolean is not.
496
+ // `entity` USED TO BYPASS THIS TOO, and had no business doing so. An entity narrows
497
+ // WITHIN a product; it says nothing about WHICH product, and the four names both
498
+ // products carry — project, report, test_run, comment — are precisely the ones the
499
+ // clash is about. `entity: "report"` alongside a guessed product is the silent pick
500
+ // this gate exists to stop, stated more confidently, so it skipped the check at the
501
+ // exact moment the check mattered.
502
+ // `product` is a free string now, so an unknown name reaches the handler. Refuse it
503
+ // where the caller can act on it: naming listProducts, which is where the real names
504
+ // come from, rather than returning an empty search that reads as "nothing matched".
505
+ if (!registry.index.products[product]) {
506
+ return refuse("searchCapability", "unknown_product", `No product named '${product}'. Call listProducts — passing the user's request ` +
507
+ "as `query` — for the names, and for whether the products clash over it.", {}, { product });
508
+ }
509
+ if (!isBrowseQuery(query)) {
510
+ // JUDGE THE USER'S WORDS, NOT THE AGENT'S REWRITE.
511
+ //
512
+ // `query` is the caller's paraphrase into product vocabulary, so the caller
513
+ // decides, through its own phrasing, whether this check fires: "what did the last
514
+ // report say?" became "list saved reports", and `saved report` is tra-only, so the
515
+ // shared word the user actually said stopped being examined. It is also the escape
516
+ // from a refusal — reword and try again, which is what the measured runs did. The
517
+ // user's text is the one input the caller cannot rewrite without quoting something
518
+ // different, and `productAnchors` checks what it quotes against this index.
519
+ const subject = user_words?.trim() ? user_words : query;
520
+ const ambiguity = ambiguousProducts(registry.index.products, subject);
521
+ // ALWAYS, NOT ONLY UNDER THE FLAG. With the self-reported flag gone, quoting the
522
+ // user is the only way past a clash, so the check that reads the quote has to run
523
+ // on both paths — otherwise the default path refuses with no way out at all. The
524
+ // flag now governs the stricter refusals (a blank request, a clash still open),
525
+ // not whether the user's own words count.
526
+ const anchor = productAnchors(registry.index.products, user_words);
527
+ // WHAT THE ROUTING STEP CONCLUDED, READ BACK OFF THE CALLER. The token is signed
528
+ // here and verified by recomputation, so the server keeps no note between the two
529
+ // calls — and the caller cannot write one, edit the product inside it, or hold one
530
+ // without having made the routing call that issues it.
531
+ const routed = readRouting(routing_token);
532
+ // A clash the routing step already reported is only cleared by the user's own
533
+ // words: the token shows the question was put, `user_words` says what came back.
534
+ // Neither works alone — a token on its own is just proof of having been refused.
535
+ const answered = routed?.verdict !== "settled" &&
536
+ anchor.products.length === 1 &&
537
+ anchor.products[0] === product;
538
+ // One product anchored, and it is the one being searched: the user settled it.
539
+ // A routing step that already settled on this product counts the same way — it
540
+ // read the user's whole request, which is more than the fragment quoted here.
541
+ const settled = (anchor.products.length === 1 && anchor.products[0] === product) ||
542
+ (routed?.verdict === "settled" && routed.product === product);
543
+ // NOTHING TO ASK WHEN THERE IS NOTHING TO CHOOSE BETWEEN. A registry serving one
544
+ // product cannot route a request to the wrong one, so every refusal below is a
545
+ // question with a single possible answer — pure obstruction.
546
+ const routable = registry.productNames().length > 1;
547
+ // A CLASH THE CALLER IS CARRYING IS STILL OPEN UNTIL IT IS ANSWERED, whatever this
548
+ // call's wording looks like. It has to outlive a REWORDING of the request — the
549
+ // escape it exists to close — while leaving a genuinely different request alone:
550
+ // "bulk delete test cases" names a thing only one product has, and blocking it
551
+ // because something earlier was ambiguous is obstruction, not routing.
552
+ const reopened = routable &&
553
+ !settled &&
554
+ !answered &&
555
+ ambiguity.settled !== product &&
556
+ (routed?.verdict === "clash" || routed?.verdict === "blank");
557
+ // THE WORDS POINT SOMEWHERE ELSE, WHICH IS EVIDENCE, NOT NOISE. A request whose
558
+ // own vocabulary settles on one product, sent as a search of the OTHER one, is the
559
+ // silent wrong pick this gate exists to catch — and it used to pass, because the
560
+ // checks above only ask whether the request settles, never whether it settles on
561
+ // what is being asked for. `product` is the caller's conclusion; the words are the
562
+ // evidence, and when they disagree the evidence is the thing to go on. The refusal
563
+ // already had the sentence for it ("the words you quoted point at X, not Y") and
564
+ // nothing could reach it.
565
+ const mismatch = routable && !!ambiguity.settled && ambiguity.settled !== product;
566
+ // NOTHING RECOGNISED IS NOT PERMISSION TO PICK. A request carrying no product's
567
+ // vocabulary is the least settled kind there is, and it used to pass this gate
568
+ // untouched because there was no shared word in it to object to.
569
+ const blank = routable && ambiguity.unknown && !settled;
570
+ if (routable &&
571
+ (ambiguity.products.length > 1 || reopened || blank || mismatch) &&
572
+ !settled &&
573
+ !answered) {
574
+ const token = signRouting(ambiguity.products.length > 1
575
+ ? { verdict: "clash", terms: ambiguity.terms }
576
+ : mismatch
577
+ ? { verdict: "settled", product: ambiguity.settled }
578
+ : { verdict: "blank" });
579
+ // WHAT TO ASK, NOT JUST THAT ASKING IS NEEDED. Sending the caller back to
580
+ // listProducts costs a round trip and still leaves it composing a question out
581
+ // of nothing — so it tends to guess instead, which is the behaviour being
582
+ // stopped. What makes the choice answerable is what each product CALLS the
583
+ // shared word and what it means there.
584
+ const choices = ambiguity.products.length
585
+ ? ambiguity.products
586
+ : mismatch
587
+ ? [ambiguity.settled, product]
588
+ : registry.productNames();
589
+ const options = choices.map((name) => {
590
+ const entities = registry.index.products[name]?.entities ?? {};
591
+ const senses = ambiguity.terms
592
+ .map((term) => {
593
+ const key = Object.keys(entities).find((candidate) => [
594
+ candidate,
595
+ ...(entities[candidate].aliases ?? []),
596
+ ].some((word) => terms(word).map(singular).join(" ") === term));
597
+ const doc = key ? entities[key] : undefined;
598
+ return doc
599
+ ? { term, entity: key, means: doc.description }
600
+ : undefined;
601
+ })
602
+ .filter(Boolean);
603
+ return {
604
+ product: name,
605
+ summary: registry.index.products[name]?.summary,
606
+ ...(senses.length ? { shared_terms: senses } : {}),
607
+ };
608
+ });
609
+ const named = anchor.products.length > 0;
610
+ return refuse("searchCapability", "product_ambiguous", (blank
611
+ ? `Nothing in '${subject}' names a product, or any word that belongs to ` +
612
+ "one, so there is no evidence here about which one is meant — that is " +
613
+ "the least settled a request can be, not the most. "
614
+ : mismatch
615
+ ? `'${subject}' names words only ${ambiguity.settled} claims` +
616
+ (ambiguity.because?.length
617
+ ? ` (${ambiguity.because.map((t) => `'${t}'`).join(", ")})`
618
+ : "") +
619
+ `, so the request points at ${ambiguity.settled}, not ${product}. ` +
620
+ "Search the product the words name, or — if the user meant " +
621
+ `${product} — get them to say so and quote them. `
622
+ : `'${subject}' could mean ${ambiguity.products.join(" or ")} — ` +
623
+ `${ambiguity.terms.map((t) => `'${t}'`).join(", ")} ` +
624
+ `${ambiguity.terms.length === 1 ? "belongs" : "belong"} to both. `) +
625
+ (named
626
+ ? `The words you quoted (${anchor.terms.map((t) => `'${t}'`).join(", ")}) ` +
627
+ `point at ${anchor.products.join(" and ")}, not ${product}. `
628
+ : user_words
629
+ ? "Nothing in the words you quoted names a product or a term only one " +
630
+ "product uses. "
631
+ : "Send `user_words` — the user's OWN words naming the product, or a " +
632
+ "term only one product uses — or ask them. ") +
633
+ "Put the choice to the USER in their own terms, wait for an answer, then " +
634
+ "resend with their words in `user_words` AND `routing_token` set to the " +
635
+ "token below. Rewording this query will not get past here. Do NOT search " +
636
+ "each product in turn and merge the results.", {
637
+ routing_token: token,
638
+ clarify: {
639
+ question: `Which product do you mean — ${choices.join(" or ")}?`,
640
+ shared: ambiguity.terms,
641
+ options,
642
+ },
643
+ }, { product: ambiguity.products.join("+") || "none" });
644
+ }
645
+ }
646
+ const { hits, weak, top_matched, ...rest } = searchCapabilities(registry.index.products, query, {
647
+ entity,
648
+ product,
649
+ mode: mode,
650
+ limit,
651
+ offset,
652
+ });
653
+ // Whether the search FOUND anything useful: zero results, or a weak match (the
654
+ // caller's words are not the product's), is a miss worth counting even though the
655
+ // call succeeded.
656
+ track("searchCapability", {
657
+ product,
658
+ entity,
659
+ search_query: redact(query),
660
+ results_returned: hits.length,
661
+ total_matched: rest.total_matched,
662
+ truncated: rest.truncated,
663
+ weak_match: weak,
664
+ coverage: Math.round(rest.coverage * 100) / 100,
665
+ });
666
+ return ok({
667
+ // NO build_id. It is provenance — for our logs and for cache busting — and
668
+ // resolution must never depend on it, which is exactly why no caller has anything
669
+ // to do with it. It was also the WHOLE registry's id, so a search scoped to one
670
+ // product still announced every other product's build: metadata about builds the
671
+ // caller did not ask about and cannot act on. The startup log already records
672
+ // what loaded, which is where a question about a stale index gets answered.
673
+ //
674
+ // A SHORTLIST: only what choosing requires. Parameters and response shapes are 86%
675
+ // of a full record and are needed for exactly ONE of the eight — the one the caller
676
+ // picks — so they move to describeCapability. Measured over eight queries: 8.6k
677
+ // tokens a search becomes ~1k, and even describing all eight results still costs
678
+ // slightly less than today.
679
+ //
680
+ // NO ROUTE, AND NO PRODUCT. Both were justified and both justifications expired.
681
+ //
682
+ // `method`/`path` were unconditional because Load Testing published no names, so
683
+ // its rows would otherwise have been unaddressable. It now names all 20, as tm
684
+ // names all 173 — every row on this surface is reachable by name. Publishing the
685
+ // route anyway contradicts the premise the whole registry rests on: an agent
686
+ // addresses a capability by a handle that outlives the route. They remain as a
687
+ // fallback for a product that ships unnamed capabilities, emitted only for the
688
+ // rows that actually need them, which today is none.
689
+ //
690
+ // `product` was here because results could span products. They cannot: `product`
691
+ // is a required argument, so every row is the product the caller named.
692
+ capabilities: hits.map(({ capability }) => ({
693
+ ...(capability.name
694
+ ? { name: capability.name }
695
+ : { method: capability.method, path: capability.path }),
696
+ mode: capability.mode,
697
+ entity: capability.entity,
698
+ ...(capability.intent ? { intent: capability.intent } : {}),
699
+ ...(capability.guidance?.length
700
+ ? { guidance: capability.guidance }
701
+ : {}),
702
+ })),
703
+ ...rest,
704
+ // WHEN THE MATCH IS WEAK, HAND OVER THE VOCABULARY.
705
+ //
706
+ // A vocabulary miss returns a full page of confident-looking hits — nothing in the
707
+ // shape of the response says the caller's word does not exist in this product. This
708
+ // is that signal, plus the fix in the same round trip: the caller is a model, and it
709
+ // maps "bucket" to "folder" instantly once it can see the entity list.
710
+ //
711
+ // Additive, never a replacement — the results are still there, and the caller is
712
+ // told to re-search rather than to trust this. That is what makes a generous
713
+ // threshold safe.
714
+ ...(weak
715
+ ? {
716
+ weak_match: true,
717
+ suggested_vocabulary: vocabularyOf(registry.index.products, product),
718
+ hint: "Nothing matched the product's own words strongly (best term score " +
719
+ `${top_matched.toFixed(1)}). The results below may not answer the ` +
720
+ "question. Re-search using a term from suggested_vocabulary, or call " +
721
+ "describeEntity on the closest entity for its full vocabulary.",
722
+ }
723
+ : {}),
724
+ });
725
+ });
726
+ tools.describeCapability = server.tool("describeCapability", "The full contract for ONE capability you picked from searchCapability: its " +
727
+ "parameters grouped into path_params / query / body under the spec's own names, " +
728
+ "what it returns, and its declared response shapes fully expanded. Call this after " +
729
+ "search and before invokeCapability — search deliberately omits all of it, because " +
730
+ "it is only needed for the one capability you actually call. " +
731
+ "Identify it by `name`, exactly as search returned it; only when a result carries " +
732
+ "no `name` (some products publish none yet) pass `method` and `path` instead. " +
733
+ "Every parameter lists its type, whether it is required, its allowed values where " +
734
+ "the set is closed, and any limits the product declares — obey those before calling " +
735
+ "rather than discovering them from a rejected request.", {
736
+ name: z
737
+ .string()
738
+ .optional()
739
+ .describe("The capability's published name, exactly as searchCapability returned it."),
740
+ method: z
741
+ .string()
742
+ .optional()
743
+ .describe("HTTP method — only for capabilities returned without a `name`."),
744
+ path: z
745
+ .string()
746
+ .optional()
747
+ .describe("Path with {placeholders} intact — only for capabilities returned without a `name`."),
748
+ product: productArg()
749
+ .optional()
750
+ .describe(`Which product owns it (${productList}). searchCapability returns it on every ` +
751
+ "result; required only when two products share a name or a path."),
752
+ include_responses: z
753
+ .enum(["success", "all", "none"])
754
+ .optional()
755
+ .describe("Which declared responses to expand: 'success' (default, the 2xx shape), 'all' " +
756
+ "(adds the error shapes — several times larger, and near-identical across " +
757
+ "endpoints), or 'none'."),
758
+ }, {
759
+ title: "Describe Capability",
760
+ readOnlyHint: true,
761
+ destructiveHint: false,
762
+ idempotentHint: true,
763
+ openWorldHint: false,
764
+ }, async (input) => {
765
+ // Hoisted so the catch below can attribute a failure to the capability it was for.
766
+ let identity;
767
+ try {
768
+ // The same two handles as invokeCapability, resolved the same way, so a name that
769
+ // describes is a name that invokes. Divergence here would be its own bug class.
770
+ if (!input.name && !(input.method && input.path)) {
771
+ return refuse("describeCapability", "no_handle", "pass `name` — or, for a capability returned without one, both `method` and " +
772
+ "`path`, exactly as searchCapability returned them");
773
+ }
774
+ const { product: owner, capability } = input.name
775
+ ? registry.byNameLookup(input.name, input.product)
776
+ : registry.byEndpointLookup(input.method, input.path, input.product);
777
+ identity = {
778
+ capability: capability.name,
779
+ capability_method: capability.method,
780
+ capability_path: capability.path,
781
+ capability_mode: capability.mode,
782
+ product: owner,
783
+ };
784
+ track("describeCapability", identity);
785
+ const selection = (input.include_responses ||
786
+ "success");
787
+ const responses = resolveResponses(registry.index.products[owner], capability, selection);
788
+ // Dropped rather than overwritten: the raw field holds `{"$response": …}` pointers,
789
+ // and spreading the capability would leak them through whenever the resolved value
790
+ // is absent.
791
+ const { responses: unresolved, method, path, ...contract } = capability;
792
+ void unresolved;
793
+ return ok({
794
+ // No build_id here either, same reason. `product` stays: describeCapability
795
+ // resolves by NAME and its product argument is optional, so the answer has to
796
+ // say whose contract came back.
797
+ product: owner,
798
+ // NO ROUTE, for the same reason the shortlist has none. A named capability is
799
+ // invoked by its name; the route is how WE reach the product, not something
800
+ // the caller acts on, and a contract that shows both invites the caller to
801
+ // hold the half that breaks when `/edit` becomes `/edit-v2`. The parameters
802
+ // below still carry `path_params`, so the caller knows what to supply — it
803
+ // just never sees the template they are substituted into.
804
+ //
805
+ // Emitted only when there is no name to use instead, which is what
806
+ // invokeCapability falls back to for a product that publishes none. No shipped
807
+ // product is in that state today.
808
+ ...(capability.name ? {} : { method, path }),
809
+ ...contract,
810
+ ...(responses ? { responses } : {}),
811
+ });
812
+ }
813
+ catch (error) {
814
+ if (error instanceof InvocationError)
815
+ return refuse("describeCapability", invocationReason(error.message), error.message, undefined, identity);
816
+ logger.error("describeCapability failed: %s", error instanceof Error ? error.message : String(error));
817
+ return refuse("describeCapability", "unexpected_error", "that capability could not be described", undefined, identity);
818
+ }
819
+ });
820
+ tools.invokeCapability = server.tool("invokeCapability", "Call a capability whose contract you have from describeCapability. Pass `name` exactly " +
821
+ "as given — that is the handle. Only when a result carries no `name` (some products " +
822
+ "do not publish them yet) pass `method` and `path` instead, exactly as returned. " +
823
+ "Arguments go in path_params / query / body under the spec's own names. One call " +
824
+ "makes exactly one request and returns the product's own response untouched; when " +
825
+ "`completed` is false there is another page, which you fetch by sending the " +
826
+ "capability's own page parameter. If the mode is 'write' you MUST ask the user " +
827
+ "first, then resend with user_permission='granted' and a change_summary; both are " +
828
+ "recorded. Capabilities whose mode is 'destructive' (deletes) are refused outright — " +
829
+ "archiving, closing and merging are ordinary writes and DO run, so read the mode and " +
830
+ "intent before confirming with the user.", {
831
+ name: z
832
+ .string()
833
+ .optional()
834
+ .describe("The capability's published name, exactly as returned (e.g. 'create_test_run_by_integer_id'). " +
835
+ "Preferred over method/path."),
836
+ method: z
837
+ .string()
838
+ .optional()
839
+ .describe("HTTP method — only for capabilities returned without a `name`."),
840
+ path: z
841
+ .string()
842
+ .optional()
843
+ .describe("Path with {placeholders} intact — only for capabilities returned without a `name`."),
844
+ path_params: z
845
+ .record(z.string(), z.any())
846
+ .optional()
847
+ .describe("Values for the {placeholders}."),
848
+ query: z
849
+ .record(z.string(), z.any())
850
+ .optional()
851
+ .describe("Query parameters."),
852
+ body: z
853
+ .record(z.string(), z.any())
854
+ .optional()
855
+ .describe("Body fields, under the spec's names."),
856
+ product: productArg()
857
+ .optional()
858
+ .describe(`Which product owns the capability (${productList}). searchCapability returns it ` +
859
+ "on every result; required only when two products share a name or a path."),
860
+ user_permission: z
861
+ .enum(PERMISSION_VALUES)
862
+ .optional()
863
+ .describe("Set to 'granted' only after the user has confirmed a write."),
864
+ change_summary: z
865
+ .string()
866
+ .optional()
867
+ .describe("What will change. Required for writes."),
868
+ }, {
869
+ title: "Invoke Capability",
870
+ // Not read-only: this is the one tool that writes. Never destructive, because
871
+ // destructive endpoints are refused before binding — the refusal is enforced here,
872
+ // not merely hinted at. Not idempotent: it creates, clones and starts runs. Closed
873
+ // world: it reaches BrowserStack products the index describes, nothing else.
874
+ readOnlyHint: false,
875
+ destructiveHint: false,
876
+ idempotentHint: false,
877
+ openWorldHint: false,
878
+ }, async (input) => {
879
+ // Hoisted so the catch below can attribute a failure to the capability it was for.
880
+ // Left undefined until resolution succeeds, which is the only point it is known —
881
+ // a refusal before that genuinely has no capability to name.
882
+ let identity;
883
+ try {
884
+ // Either handle resolves to the same capability. `name` wins when both are sent,
885
+ // rather than cross-checking them: a caller pasting a stale path alongside a good
886
+ // name should still reach the right operation, which is the point of naming.
887
+ if (!input.name && !(input.method && input.path)) {
888
+ return refuse("invokeCapability", "no_handle", "pass `name` — or, for a capability returned without one, both `method` and " +
889
+ "`path`, exactly as searchCapability returned them");
890
+ }
891
+ const { product, capability } = input.name
892
+ ? registry.byNameLookup(input.name, input.product)
893
+ : registry.byEndpointLookup(input.method, input.path, input.product);
894
+ /** What to call it in errors — the handle the caller actually used. */
895
+ const handle = capability.name || `${capability.method} ${capability.path}`;
896
+ /**
897
+ * What was asked for. Recorded after resolution, not at entry, because only here is
898
+ * it known. `capability` is absent for a product that publishes no names, where
899
+ * method+path are the handle.
900
+ */
901
+ identity = {
902
+ capability: capability.name,
903
+ capability_method: capability.method,
904
+ capability_path: capability.path,
905
+ capability_mode: capability.mode,
906
+ product,
907
+ };
908
+ const args = {
909
+ path_params: input.path_params,
910
+ query: input.query,
911
+ body: input.body,
912
+ };
913
+ if (capability.mode === "destructive") {
914
+ // Refused before binding, so consent is never sought for something that cannot run.
915
+ return refuse("invokeCapability", "destructive_blocked", `${handle} is a destructive operation and is not available through this surface`, undefined, identity);
916
+ }
917
+ if (capability.mode === "write") {
918
+ const permission = input.user_permission || "not_asked";
919
+ // PARAMETERS ARE VALIDATED BEFORE PERMISSION IS DEMANDED. The gate used to run
920
+ // first, so a caller with a typo'd parameter was told "ask the user to confirm this
921
+ // change", went back to the human for approval, and only then learned the parameter
922
+ // was wrong. A dry bind costs nothing and cannot mutate.
923
+ const { bind } = await import("./bind.js");
924
+ bind(capability, args);
925
+ if (permission !== "granted") {
926
+ // Catches the careless path, not the adversarial one: the model fills this field
927
+ // in, so it is an audit record and a speed bump, never authorisation.
928
+ return refuse("invokeCapability", "permission_not_granted", "refused: this endpoint changes data — ask the user to confirm, then retry " +
929
+ "with user_permission='granted' and a change_summary", undefined, identity);
930
+ }
931
+ if (!(input.change_summary || "").trim()) {
932
+ return refuse("invokeCapability", "change_summary_missing", "change_summary is required: state what will change", undefined, identity);
933
+ }
934
+ }
935
+ const result = await invoke(capability, args, await deps.baseUrlFor(product), deps.credentialsFor(), transport, registry.index.products[product]?.auth, product);
936
+ // The one row for a completed invoke. invoke() returns a 4xx/5xx rather than
937
+ // throwing, so `success` keeps its tool-level meaning and these two fields carry
938
+ // whether the product call worked. Status 0 means it could not be reached.
939
+ track("invokeCapability", {
940
+ ...identity,
941
+ upstream_ok: result.ok,
942
+ upstream_status: result.http_response.status,
943
+ change_summary: redact(input.change_summary),
944
+ });
945
+ return ok(result);
946
+ }
947
+ catch (error) {
948
+ if (error instanceof InvocationError)
949
+ return refuse("invokeCapability", invocationReason(error.message), error.message, undefined, identity);
950
+ logger.error("invokeCapability failed: %s", error instanceof Error ? error.message : String(error));
951
+ return refuse("invokeCapability", "unexpected_error", "that capability could not be invoked", undefined, identity);
952
+ }
953
+ });
954
+ return tools;
955
+ }
956
+ export default addCapabilityRegistryToolsFromConfig;