@browserstack/mcp-server 2.1.0 → 2.2.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 (40) hide show
  1. package/capability/loadtesting.capability-index.json +2978 -0
  2. package/capability/tm.capability-index.json +2 -1
  3. package/capability/tra.capability-index.json +52074 -0
  4. package/dist/config.d.ts +2 -27
  5. package/dist/config.js +6 -19
  6. package/dist/lib/central-oauth.d.ts +133 -0
  7. package/dist/lib/central-oauth.js +246 -0
  8. package/dist/lib/utils.js +0 -8
  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/ask-browserstack/config.d.ts +14 -0
  12. package/dist/tools/ask-browserstack/config.js +16 -0
  13. package/dist/tools/ask-browserstack/register.d.ts +2 -3
  14. package/dist/tools/ask-browserstack/register.js +72 -219
  15. package/dist/tools/ask-browserstack/relay.js +17 -11
  16. package/dist/tools/ask-browserstack/stream.d.ts +5 -27
  17. package/dist/tools/ask-browserstack/stream.js +14 -25
  18. package/dist/tools/ask-browserstack/types.d.ts +3 -54
  19. package/dist/tools/ask-browserstack/types.js +1 -11
  20. package/dist/tools/automate-utils/fetch-screenshots.js +15 -17
  21. package/dist/tools/capability-registry/bind.js +43 -7
  22. package/dist/tools/capability-registry/discovery.js +3 -1
  23. package/dist/tools/capability-registry/egress.d.ts +12 -5
  24. package/dist/tools/capability-registry/egress.js +24 -1
  25. package/dist/tools/capability-registry/oauth.d.ts +63 -0
  26. package/dist/tools/capability-registry/oauth.js +176 -0
  27. package/dist/tools/capability-registry/register.js +289 -41
  28. package/dist/tools/capability-registry/resolve.d.ts +1 -1
  29. package/dist/tools/capability-registry/resolve.js +24 -3
  30. package/dist/tools/capability-registry/routing-token.d.ts +25 -0
  31. package/dist/tools/capability-registry/routing-token.js +87 -0
  32. package/dist/tools/capability-registry/search.d.ts +28 -1
  33. package/dist/tools/capability-registry/search.js +202 -4
  34. package/dist/tools/capability-registry/types.d.ts +42 -3
  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 +2 -1
  39. package/dist/tools/ask-browserstack/deferred.d.ts +0 -74
  40. package/dist/tools/ask-browserstack/deferred.js +0 -242
@@ -13,6 +13,7 @@
13
13
  * enforced HERE rather than on a client-side hint — which is the one thing a separate
14
14
  * read/write tool pair was buying.
15
15
  */
16
+ import { signRouting, readRouting } from "./routing-token.js";
16
17
  import { z } from "zod";
17
18
  import logger from "../../logger.js";
18
19
  import { trackMCP } from "../../lib/instrumentation.js";
@@ -21,7 +22,7 @@ import { redact } from "./redact.js";
21
22
  import { fetchTransport } from "./egress.js";
22
23
  import { CapabilityRegistry, InvocationError, resolveResponses, } from "./index-loader.js";
23
24
  import { invoke } from "./resolve.js";
24
- import { ambiguousProducts, isBrowseQuery, searchCapabilities, singular, terms, vocabularyOf, } from "./search.js";
25
+ import { ambiguousProducts, productAnchors, isBrowseQuery, searchCapabilities, singular, terms, vocabularyOf, } from "./search.js";
25
26
  export const PERMISSION_VALUES = ["not_asked", "granted", "denied"];
26
27
  /**
27
28
  * The search-side twin of `user_permission`: did a human choose the product, or did you?
@@ -155,6 +156,10 @@ export function addCapabilityRegistryTools(server, deps, config) {
155
156
  // likeliest refusal of all, and it was landing in the generic bucket.
156
157
  if (message.startsWith("capability_disabled:"))
157
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";
158
163
  if (message.startsWith("missing required parameter"))
159
164
  return "missing_parameter";
160
165
  if (/is not a usable path value/.test(message))
@@ -187,15 +192,122 @@ export function addCapabilityRegistryTools(server, deps, config) {
187
192
  tools.listProducts = server.tool("listProducts", "List the BrowserStack products this surface can reach: what each one does, the " +
188
193
  "entities it models, and a line saying what each entity is. START HERE — " +
189
194
  "searchCapability needs a product, and this is what tells you which one. If two " +
190
- "products could both fit the task, ask the user rather than choosing for them.", {}, {
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
+ }, {
191
211
  title: "List Capability Products",
192
212
  readOnlyHint: true,
193
213
  destructiveHint: false,
194
214
  idempotentHint: true,
195
215
  openWorldHint: false,
196
- }, async () => {
216
+ }, async ({ query }) => {
197
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;
198
309
  return ok({
310
+ ...(routing ? { routing } : {}),
199
311
  products: registry.productNames().map((name) => ({
200
312
  name,
201
313
  summary: registry.index.products[name].summary,
@@ -260,10 +372,11 @@ export function addCapabilityRegistryTools(server, deps, config) {
260
372
  return ok({ product, entity, ...doc });
261
373
  });
262
374
  tools.searchCapability = server.tool("searchCapability", "Find endpoints this surface can call, by plain language, within ONE product. " +
263
- `You must say which: ${productList}. If the task does not name it unambiguously, ` +
264
- "call listProducts first — it returns what each product does, the entities each " +
265
- "models, and what every entity means, which is what settles the choice. Where two " +
266
- "products use the same word for different things, ask the user rather than picking. " +
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. " +
267
380
  "Search matches the product's OWN words, not synonyms. When your words are not the " +
268
381
  "product's, the response says so: `weak_match: true` with a `suggested_vocabulary` " +
269
382
  "map of the product's entities and their aliases. Results are still returned, but " +
@@ -306,19 +419,44 @@ export function addCapabilityRegistryTools(server, deps, config) {
306
419
  // not (run, project, report, result, folder, workspace, execution, history), where
307
420
  // the old behaviour was to silently pick whichever product ranked higher. A wasted
308
421
  // round trip against a silent wrong-product answer is not a close trade.
309
- product: productArg().describe(`Which product to search: ${productList}. Call listProducts if the task does ` +
310
- `not make it obvious, and ask the user when two products could both fit.`),
311
- // REQUIRED, and self-reported, exactly like `user_permission` on a write. The
312
- // server cannot see whether you asked anyone; what it can do is refuse to answer a
313
- // question only the user can settle, and make claiming otherwise an explicit act
314
- // rather than an omission.
315
- product_choice: z
316
- .enum(PRODUCT_CHOICE_VALUES)
317
- .describe("'user_confirmed' only when the user named the product, or the task names it " +
318
- "unmistakably. 'not_asked' otherwise — then a query that could mean either " +
319
- "product is refused and told what to ask, instead of being answered for the " +
320
- "wrong one. Never search each product in turn and merge the results: that is " +
321
- "the guess this argument exists to prevent."),
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."),
322
460
  mode: z
323
461
  .enum(["read", "write", "destructive"])
324
462
  .optional()
@@ -335,7 +473,7 @@ export function addCapabilityRegistryTools(server, deps, config) {
335
473
  destructiveHint: false,
336
474
  idempotentHint: true,
337
475
  openWorldHint: false,
338
- }, async ({ query, entity, product, product_choice, mode, limit, offset }) => {
476
+ }, async ({ query, entity, product, routing_token, user_words, mode, limit, offset, }) => {
339
477
  // THE GATE. Nothing here can tell whether a human was asked — same as the write
340
478
  // gate, which also takes the caller's word. What it can do is refuse to answer a
341
479
  // question that is genuinely the user's, so that answering it anyway takes a
@@ -349,18 +487,106 @@ export function addCapabilityRegistryTools(server, deps, config) {
349
487
  // contains no word to be ambiguous about — `*` names nothing — so there is nothing
350
488
  // to ask, and asking anyway would block the one query whose whole purpose is to
351
489
  // show what a product contains.
352
- if (product_choice !== "user_confirmed" &&
353
- !entity &&
354
- !isBrowseQuery(query)) {
355
- const ambiguity = ambiguousProducts(registry.index.products, query);
356
- if (ambiguity.products.length > 1) {
357
- // EVERYTHING NEEDED TO ASK, IN THE REFUSAL. Telling the agent to go and call
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
358
580
  // listProducts costs a round trip and still leaves it composing a question out
359
581
  // of nothing — so it tends to guess instead, which is the behaviour being
360
- // stopped. What makes the choice answerable is what each product calls the
361
- // shared word and what it means THERE: tm's project owns folders and test
362
- // cases, Load Testing's does not.
363
- const options = ambiguity.products.map((name) => {
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) => {
364
590
  const entities = registry.index.products[name]?.entities ?? {};
365
591
  const senses = ambiguity.terms
366
592
  .map((term) => {
@@ -380,19 +606,41 @@ export function addCapabilityRegistryTools(server, deps, config) {
380
606
  ...(senses.length ? { shared_terms: senses } : {}),
381
607
  };
382
608
  });
383
- return refuse("searchCapability", "product_ambiguous", `'${query}' could mean ${ambiguity.products.join(" or ")} — ` +
384
- `${ambiguity.terms.map((t) => `'${t}'`).join(", ")} ` +
385
- `${ambiguity.terms.length === 1 ? "belongs" : "belong"} to both. ` +
386
- "Put the choice in `clarify` to the USER in their own terms, wait for an " +
387
- "answer, then resend with product_choice='user_confirmed'. Do NOT search " +
388
- "each product in turn and merge the results — that answers the question " +
389
- "instead of asking it.", {
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,
390
638
  clarify: {
391
- question: `Which product do you mean — ${ambiguity.products.join(" or ")}?`,
639
+ question: `Which product do you mean — ${choices.join(" or ")}?`,
392
640
  shared: ambiguity.terms,
393
641
  options,
394
642
  },
395
- }, { product: ambiguity.products.join("+") });
643
+ }, { product: ambiguity.products.join("+") || "none" });
396
644
  }
397
645
  }
398
646
  const { hits, weak, top_matched, ...rest } = searchCapabilities(registry.index.products, query, {
@@ -684,7 +932,7 @@ export function addCapabilityRegistryTools(server, deps, config) {
684
932
  return refuse("invokeCapability", "change_summary_missing", "change_summary is required: state what will change", undefined, identity);
685
933
  }
686
934
  }
687
- const result = await invoke(capability, args, await deps.baseUrlFor(product), deps.credentialsFor(), transport, registry.index.products[product]?.auth);
935
+ const result = await invoke(capability, args, await deps.baseUrlFor(product), deps.credentialsFor(), transport, registry.index.products[product]?.auth, product);
688
936
  // The one row for a completed invoke. invoke() returns a 4xx/5xx rather than
689
937
  // throwing, so `success` keeps its tool-level meaning and these two fields carry
690
938
  // whether the product call worked. Status 0 means it could not be reached.
@@ -35,4 +35,4 @@ export interface InvokeResult {
35
35
  error?: string;
36
36
  };
37
37
  }
38
- export declare function invoke(capability: Capability, args: GroupedArguments, baseUrl: string, credentials: Credentials, transport: Transport, auth?: AuthScheme): Promise<InvokeResult>;
38
+ export declare function invoke(capability: Capability, args: GroupedArguments, baseUrl: string, credentials: Credentials, transport: Transport, auth?: AuthScheme, product?: string): Promise<InvokeResult>;
@@ -12,8 +12,10 @@
12
12
  * page-size parameter are published for paginated endpoints (see `project.py::_is_public`).
13
13
  * Hiding them made sense only while this module walked the pages itself.
14
14
  */
15
+ import { invalidateToken } from "../../lib/central-oauth.js";
15
16
  import { bind } from "./bind.js";
16
17
  import { authHeaders } from "./egress.js";
18
+ import { resolveScope, resolveTokenUrl } from "./oauth.js";
17
19
  import { InvocationError } from "./index-loader.js";
18
20
  function hasNextPage(body) {
19
21
  if (typeof body !== "object" || body === null || Array.isArray(body))
@@ -61,13 +63,32 @@ function assertTransportSafe(baseUrl) {
61
63
  `carries your BrowserStack access key, so the base URL must be https (or a local ` +
62
64
  `host for development). Check CAPABILITY_REGISTRY_BASE_URL_* for a typo.`);
63
65
  }
64
- export async function invoke(capability, args, baseUrl, credentials, transport, auth) {
66
+ export async function invoke(capability, args, baseUrl, credentials, transport, auth, product) {
65
67
  if (!baseUrl)
66
68
  throw new InvocationError("no base URL is configured for that product");
67
69
  assertTransportSafe(baseUrl);
68
70
  const bound = bind(capability, args);
69
- const headers = authHeaders(credentials, auth);
70
- const response = await transport(capability.method, `${baseUrl.replace(/\/$/, "")}${bound.path}`, headers, bound.query, bound.body);
71
+ // Before binding would be wrong and after sending is too late: a scheme that mints a
72
+ // token must not spend a round trip on a call that `bind` was going to reject anyway.
73
+ const url = `${baseUrl.replace(/\/$/, "")}${bound.path}`;
74
+ const send = async () => transport(capability.method, url, await authHeaders(credentials, auth, product), bound.query, bound.body);
75
+ let response = await send();
76
+ /**
77
+ * One replay, for a minted token the product rejected.
78
+ *
79
+ * The expiry skirt covers clock skew; it does not cover a token revoked mid-life, and a
80
+ * cached one can outlive its welcome by up to its whole lifetime. Discarding it and
81
+ * retrying once turns that from a failed call into a slow one.
82
+ *
83
+ * SAFE TO REPLAY EVEN FOR A WRITE: a 401 means the request was never authorised, so
84
+ * nothing was changed. Once only — with genuinely bad credentials the second attempt
85
+ * fails identically, and a loop here would hammer the token endpoint on every wrong
86
+ * password.
87
+ */
88
+ if (response.status === 401 && auth?.type === "oauth2") {
89
+ invalidateToken(resolveTokenUrl(product ?? "this product", auth), resolveScope(auth), credentials);
90
+ response = await send();
91
+ }
71
92
  const ok = response.status >= 200 && response.status < 300;
72
93
  return {
73
94
  ok,
@@ -0,0 +1,25 @@
1
+ /** What a routing call concluded about the user's request. */
2
+ export interface RoutingVerdict {
3
+ /** `clash`: several products claim it. `blank`: no product's words at all. `settled`: one. */
4
+ verdict: "clash" | "blank" | "settled";
5
+ /** The product it settled on, for `settled`. */
6
+ product?: string;
7
+ /** The shared words, for `clash` — what the user has to be asked about. */
8
+ terms?: string[];
9
+ /** Issued-at, epoch seconds. */
10
+ iat: number;
11
+ }
12
+ /** Tokens outlive a turn, not a sitting. Long enough to answer a question, not to bank. */
13
+ export declare const TOKEN_TTL_SECONDS: number;
14
+ /** Sign a verdict into an opaque token the caller hands back. */
15
+ export declare function signRouting(verdict: Omit<RoutingVerdict, "iat"> & {
16
+ iat?: number;
17
+ }): string;
18
+ /**
19
+ * Read a token back, or `undefined` if it was not issued here, was edited, or has expired.
20
+ *
21
+ * Every failure returns the same nothing on purpose: a caller learning WHY its token was
22
+ * rejected learns how to build a better one, and there is no legitimate caller that needs
23
+ * to know the difference.
24
+ */
25
+ export declare function readRouting(token: string | undefined, now?: number): RoutingVerdict | undefined;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The routing step's verdict, signed so it can be handed back instead of remembered.
3
+ *
4
+ * The gate needs to know what the LAST routing call concluded — whether the request
5
+ * clashed, or settled, and on what. That is a fact about the conversation, and a tool call
6
+ * carries no conversation, so the server kept it: a set of unspent tokens and the listing's
7
+ * last verdict, both living for the life of the connection. Under stdio that is one process
8
+ * per client and harmless; it stops being harmless the moment one process serves several
9
+ * sessions, where a single shared `settledByListing` lets one client's listing satisfy
10
+ * another client's gate.
11
+ *
12
+ * So the verdict travels with the caller instead. It is signed with a per-process secret,
13
+ * which is what keeps it honest: the caller cannot mint one, cannot edit the product inside
14
+ * it, and cannot produce one at all without having made the routing call that issues it.
15
+ * Verification is recomputation — there is nothing to store, nothing to grow, and nothing
16
+ * to leak between sessions.
17
+ *
18
+ * WHAT THIS CANNOT DO, stated plainly: it proves a routing call happened and what it said.
19
+ * It cannot prove a human answered. Nothing a caller transmits can. A caller that reworded
20
+ * its request and ran discovery again on the new words holds a genuine token for a verdict
21
+ * it manufactured — closing THAT needs memory of the first question, which is the thing
22
+ * being removed here. `user_words`, checked against the index, remains the part that
23
+ * carries what the user actually said.
24
+ */
25
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
26
+ /**
27
+ * Per process, and deliberately not persisted.
28
+ *
29
+ * A restart invalidates every outstanding token, which is the right behaviour: a new
30
+ * process is a new conversation, and a verdict from the last one says nothing about this
31
+ * one. It also means nothing has to be configured to run the server.
32
+ */
33
+ const SECRET = randomBytes(32);
34
+ /** Tokens outlive a turn, not a sitting. Long enough to answer a question, not to bank. */
35
+ export const TOKEN_TTL_SECONDS = 60 * 30;
36
+ const b64 = (b) => b.toString("base64url");
37
+ function seal(payload) {
38
+ return createHmac("sha256", SECRET).update(payload).digest("base64url");
39
+ }
40
+ /** Sign a verdict into an opaque token the caller hands back. */
41
+ export function signRouting(verdict) {
42
+ const body = {
43
+ ...verdict,
44
+ iat: verdict.iat ?? Math.floor(Date.now() / 1000),
45
+ };
46
+ const payload = b64(Buffer.from(JSON.stringify(body)));
47
+ return `${payload}.${seal(payload)}`;
48
+ }
49
+ /**
50
+ * Read a token back, or `undefined` if it was not issued here, was edited, or has expired.
51
+ *
52
+ * Every failure returns the same nothing on purpose: a caller learning WHY its token was
53
+ * rejected learns how to build a better one, and there is no legitimate caller that needs
54
+ * to know the difference.
55
+ */
56
+ export function readRouting(token, now = Math.floor(Date.now() / 1000)) {
57
+ if (!token)
58
+ return undefined;
59
+ const cut = token.lastIndexOf(".");
60
+ if (cut <= 0)
61
+ return undefined;
62
+ const payload = token.slice(0, cut);
63
+ const signature = token.slice(cut + 1);
64
+ const expected = Buffer.from(seal(payload));
65
+ const given = Buffer.from(signature);
66
+ // Length has to match before timingSafeEqual will look at the bytes, and comparing
67
+ // lengths first leaks only the length, which the caller already knows.
68
+ if (expected.length !== given.length)
69
+ return undefined;
70
+ if (!timingSafeEqual(expected, given))
71
+ return undefined;
72
+ try {
73
+ const body = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
74
+ if (typeof body?.iat !== "number")
75
+ return undefined;
76
+ if (now - body.iat > TOKEN_TTL_SECONDS)
77
+ return undefined;
78
+ if (body.verdict !== "clash" &&
79
+ body.verdict !== "blank" &&
80
+ body.verdict !== "settled")
81
+ return undefined;
82
+ return body;
83
+ }
84
+ catch {
85
+ return undefined;
86
+ }
87
+ }
@@ -105,12 +105,17 @@ export interface SearchResult {
105
105
  coverage: number;
106
106
  weak: boolean;
107
107
  }
108
- /** Which products a query could plausibly be about, when more than one could. */
109
108
  export interface ProductAmbiguity {
110
109
  /** The products that claim the query's words. Empty when the query settles itself. */
111
110
  products: string[];
112
111
  /** The shared vocabulary that caused it — what to put in front of the user. */
113
112
  terms: string[];
113
+ /** The one product the query's own words point at, when they point at exactly one. */
114
+ settled?: string;
115
+ /** The words that pointed there — the evidence for `settled`. */
116
+ because?: string[];
117
+ /** No word in the request belongs to ANY product: there is nothing to route on. */
118
+ unknown?: boolean;
114
119
  }
115
120
  /**
116
121
  * Products whose OWN vocabulary the query hits, when no word in the query settles it.
@@ -168,3 +173,25 @@ export declare function searchCapabilities(products: Record<string, ProductIndex
168
173
  limit?: number;
169
174
  offset?: number;
170
175
  }): SearchResult;
176
+ export interface ProductAnchor {
177
+ /** Products the quoted words point at, through a word only that product claims. */
178
+ products: string[];
179
+ /** The words that did the anchoring, so a refusal can quote them back. */
180
+ terms: string[];
181
+ }
182
+ /**
183
+ * Which products the USER'S OWN WORDS name, directly or through vocabulary only one
184
+ * product claims.
185
+ *
186
+ * This is the other half of `ambiguousProducts`. That one asks "is the query a word both
187
+ * products use?"; this asks "did the user say anything that settles it?" — and it reads
188
+ * the user's text, not a flag the caller sets about itself. A boolean saying the user
189
+ * confirmed is answered by whoever wants to proceed; a quote has to come from the
190
+ * conversation, and the words in it are checked against the index here.
191
+ *
192
+ * Anchors are of two kinds and both are needed. A product NAME ("test management",
193
+ * "observability") is the explicit case. A word only one product's vocabulary claims
194
+ * ("quality gate", "test plan") is the implicit one, and it is the common one: users say
195
+ * what they want, not which product owns it.
196
+ */
197
+ export declare function productAnchors(products: Record<string, ProductIndex>, text: string | undefined): ProductAnchor;