@browserstack/mcp-server 2.0.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.
- package/capability/loadtesting.capability-index.json +2978 -0
- package/capability/tm.capability-index.json +2 -1
- package/capability/tra.capability-index.json +52074 -0
- package/dist/lib/central-oauth.d.ts +133 -0
- package/dist/lib/central-oauth.js +246 -0
- package/dist/tools/ask-browserstack/central-oauth.d.ts +25 -46
- package/dist/tools/ask-browserstack/central-oauth.js +55 -177
- package/dist/tools/capability-registry/bind.js +43 -7
- package/dist/tools/capability-registry/discovery.js +3 -1
- package/dist/tools/capability-registry/egress.d.ts +12 -5
- package/dist/tools/capability-registry/egress.js +24 -1
- package/dist/tools/capability-registry/oauth.d.ts +63 -0
- package/dist/tools/capability-registry/oauth.js +176 -0
- package/dist/tools/capability-registry/register.js +289 -41
- package/dist/tools/capability-registry/resolve.d.ts +1 -1
- package/dist/tools/capability-registry/resolve.js +24 -3
- package/dist/tools/capability-registry/routing-token.d.ts +25 -0
- package/dist/tools/capability-registry/routing-token.js +87 -0
- package/dist/tools/capability-registry/search.d.ts +28 -1
- package/dist/tools/capability-registry/search.js +202 -4
- package/dist/tools/capability-registry/types.d.ts +42 -3
- package/dist/tools/testmanagement.js +5 -5
- package/dist/tools/tool-handoff.d.ts +15 -12
- package/dist/tools/tool-handoff.js +21 -18
- package/package.json +2 -1
|
@@ -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
|
-
|
|
70
|
-
|
|
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;
|
|
@@ -493,6 +493,25 @@ function score(capability, wanted, weights, aliases, hint, plural) {
|
|
|
493
493
|
const WEAK_COVERAGE = 0.5;
|
|
494
494
|
/** The heaviest field, and so the yardstick a perfect match is measured against. */
|
|
495
495
|
const IDENTITY_WEIGHT = 6;
|
|
496
|
+
// THE CROSS-PRODUCT FLOOR WAS HERE, and is gone with the thing that needed it.
|
|
497
|
+
//
|
|
498
|
+
// It reserved two slots per matching product so a small product could not be shut out by
|
|
499
|
+
// a large one — tm has 173 capabilities to Load Testing's 20, and on a word both share
|
|
500
|
+
// ("test", "config", "tag") tm took the whole page. "add a tag to xyz test" returned
|
|
501
|
+
// tm tm LT tm tm, and an agent reading the first row went to the wrong product.
|
|
502
|
+
//
|
|
503
|
+
// `product` is now REQUIRED on searchCapability, so a search never spans products and the
|
|
504
|
+
// floor could never fire. The bug it fixed is fixed harder: the agent chooses the product
|
|
505
|
+
// deliberately against listProducts, or asks the user, instead of inferring it from which
|
|
506
|
+
// row happened to rank first. Keeping the code would mean carrying a branch nothing
|
|
507
|
+
// reaches and tests asserting behaviour the surface cannot produce.
|
|
508
|
+
//
|
|
509
|
+
// If searching across products ever returns — a "search everywhere" mode, or a caller
|
|
510
|
+
// that legitimately cannot know the product — this comes back with it. It is in the
|
|
511
|
+
// history at 617b4b9, and the reasoning above is why it was right.
|
|
512
|
+
/** Which products a query could plausibly be about, when more than one could. */
|
|
513
|
+
/** Words too common in this domain to settle a product on their own. */
|
|
514
|
+
const GENERIC_ANCHORS = new Set(["test", "tests", "testing"]);
|
|
496
515
|
/**
|
|
497
516
|
* Products whose OWN vocabulary the query hits, when no word in the query settles it.
|
|
498
517
|
*
|
|
@@ -539,17 +558,97 @@ export function ambiguousProducts(products, query) {
|
|
|
539
558
|
}
|
|
540
559
|
}
|
|
541
560
|
}
|
|
561
|
+
// WORDS A PRODUCT OWNS WITHOUT MODELLING THEM. `routing_terms` is how a product claims
|
|
562
|
+
// what its users call it, as opposed to what it contains, and unlike a summary it MAY
|
|
563
|
+
// introduce a term: that is the whole point of it, since the words most in need of
|
|
564
|
+
// routing — `flaky` — are the ones no index names an entity after.
|
|
565
|
+
for (const [product, index] of Object.entries(products)) {
|
|
566
|
+
for (const word of index.routing_terms ?? []) {
|
|
567
|
+
const key = fold(String(word));
|
|
568
|
+
if (!key)
|
|
569
|
+
continue;
|
|
570
|
+
if (!owners.has(key))
|
|
571
|
+
owners.set(key, new Set());
|
|
572
|
+
owners.get(key).add(product);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
// THE SUMMARY CAN ADD A CLAIMANT, NEVER A TERM.
|
|
576
|
+
//
|
|
577
|
+
// A product that models `view` as an entity and one that only describes views in its
|
|
578
|
+
// summary both answer questions about views, but only the first was visible here, so
|
|
579
|
+
// "delete the saved view" read as settled. Letting the summary claim a word the other
|
|
580
|
+
// product already models catches that, and refusing to mine NEW terms out of it is what
|
|
581
|
+
// keeps the prose from taking over: a summary is a paragraph of verbs and nouns written
|
|
582
|
+
// to sell the product, and every word in it would otherwise become routing vocabulary.
|
|
583
|
+
const summaries = Object.fromEntries(Object.entries(products).map(([product, index]) => [
|
|
584
|
+
product,
|
|
585
|
+
` ${fold(index.summary ?? "")} `,
|
|
586
|
+
]));
|
|
587
|
+
for (const [key, claimants] of owners) {
|
|
588
|
+
for (const [product, text] of Object.entries(summaries)) {
|
|
589
|
+
if (text.includes(` ${key} `))
|
|
590
|
+
claimants.add(product);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
542
593
|
const shared = new Map();
|
|
594
|
+
const decided = new Set();
|
|
595
|
+
const decidedBy = new Map();
|
|
543
596
|
for (const [key, claimants] of owners) {
|
|
544
597
|
if (!asked_.includes(` ${key} `))
|
|
545
598
|
continue;
|
|
546
|
-
// A
|
|
547
|
-
|
|
548
|
-
|
|
599
|
+
// A word this domain uses in every other sentence settles nothing and clashes over
|
|
600
|
+
// nothing: `test` is an alias on one side and appears throughout both summaries, so
|
|
601
|
+
// counting it would make almost any request look like a clash.
|
|
602
|
+
if (GENERIC_ANCHORS.has(key))
|
|
603
|
+
continue;
|
|
604
|
+
if (claimants.size < 2) {
|
|
605
|
+
// A term only one product claims POINTS somewhere; whether it decides the question
|
|
606
|
+
// depends on what else is in the sentence. It used to end the check outright, and
|
|
607
|
+
// that is what made this gate almost silent: an agent's paraphrase nearly always
|
|
608
|
+
// carries some incidental one-product word — `upload`, `saved report` — so the
|
|
609
|
+
// shared noun the user actually said stopped being examined. Worse, a sentence
|
|
610
|
+
// carrying one-product words for BOTH products read as settled, which is the case
|
|
611
|
+
// most in need of asking.
|
|
612
|
+
if (!GENERIC_ANCHORS.has(key)) {
|
|
613
|
+
const owner = [...claimants][0];
|
|
614
|
+
decided.add(owner);
|
|
615
|
+
decidedBy.set(owner, [...(decidedBy.get(owner) ?? []), key]);
|
|
616
|
+
}
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
549
619
|
shared.set(key, claimants);
|
|
550
620
|
}
|
|
621
|
+
// NOTHING RECOGNISED IS THE MOST AMBIGUOUS CASE, NOT THE LEAST.
|
|
622
|
+
//
|
|
623
|
+
// "Can we ship this?", "What should we fix first?", "Show me the failed tests." — no
|
|
624
|
+
// word in them is any product's, so the shared-term check finds nothing and used to
|
|
625
|
+
// report the same calm verdict it gives a request that genuinely settles itself. These
|
|
626
|
+
// are the requests with the least to go on, and answering them for a product nobody
|
|
627
|
+
// chose is exactly the guess this whole check exists to stop.
|
|
628
|
+
if (shared.size === 0 && decided.size === 0)
|
|
629
|
+
return { ...none, unknown: true };
|
|
630
|
+
// Decided, with no shared word anywhere in it — "bulk delete test cases". There is
|
|
631
|
+
// nothing to ask about, and saying WHICH product it landed on matters as much here as
|
|
632
|
+
// it does below: a caller that only hears "no clash" can still search the other one.
|
|
551
633
|
if (shared.size === 0)
|
|
552
|
-
return
|
|
634
|
+
return decided.size === 1
|
|
635
|
+
? {
|
|
636
|
+
...none,
|
|
637
|
+
settled: [...decided][0],
|
|
638
|
+
because: (decidedBy.get([...decided][0]) ?? []).sort(),
|
|
639
|
+
}
|
|
640
|
+
: none;
|
|
641
|
+
// One product, and only one, is pointed at by the unshared words: the sentence answers
|
|
642
|
+
// itself and there is nothing to ask. It is still worth SAYING which one, because the
|
|
643
|
+
// caller sees only that nothing needs asking — "tell me about this project, including
|
|
644
|
+
// its settings" settles on the product that models settings, and an answer of silence
|
|
645
|
+
// let the other one be searched instead, which is the same wrong turn by a quieter route.
|
|
646
|
+
if (decided.size === 1)
|
|
647
|
+
return {
|
|
648
|
+
...none,
|
|
649
|
+
settled: [...decided][0],
|
|
650
|
+
because: (decidedBy.get([...decided][0]) ?? []).sort(),
|
|
651
|
+
};
|
|
553
652
|
const claimed = new Set();
|
|
554
653
|
for (const claimants of shared.values())
|
|
555
654
|
for (const product of claimants)
|
|
@@ -707,3 +806,102 @@ export function searchCapabilities(products, query, options = {}) {
|
|
|
707
806
|
weak: wanted.length > 0 && perfect > 0 && coverage < WEAK_COVERAGE,
|
|
708
807
|
};
|
|
709
808
|
}
|
|
809
|
+
/**
|
|
810
|
+
* Which products the USER'S OWN WORDS name, directly or through vocabulary only one
|
|
811
|
+
* product claims.
|
|
812
|
+
*
|
|
813
|
+
* This is the other half of `ambiguousProducts`. That one asks "is the query a word both
|
|
814
|
+
* products use?"; this asks "did the user say anything that settles it?" — and it reads
|
|
815
|
+
* the user's text, not a flag the caller sets about itself. A boolean saying the user
|
|
816
|
+
* confirmed is answered by whoever wants to proceed; a quote has to come from the
|
|
817
|
+
* conversation, and the words in it are checked against the index here.
|
|
818
|
+
*
|
|
819
|
+
* Anchors are of two kinds and both are needed. A product NAME ("test management",
|
|
820
|
+
* "observability") is the explicit case. A word only one product's vocabulary claims
|
|
821
|
+
* ("quality gate", "test plan") is the implicit one, and it is the common one: users say
|
|
822
|
+
* what they want, not which product owns it.
|
|
823
|
+
*/
|
|
824
|
+
export function productAnchors(products, text) {
|
|
825
|
+
const none = { products: [], terms: [] };
|
|
826
|
+
if (terms(text).length === 0)
|
|
827
|
+
return none;
|
|
828
|
+
const fold = (s) => terms(s).map(singular).join(" ");
|
|
829
|
+
const said = ` ${fold(text || "")} `;
|
|
830
|
+
const found = new Map();
|
|
831
|
+
const claim = (product, term) => {
|
|
832
|
+
if (!found.has(product))
|
|
833
|
+
found.set(product, new Set());
|
|
834
|
+
found.get(product).add(term);
|
|
835
|
+
};
|
|
836
|
+
// The product's own name and key, as a user would type them. `summary` is prose, not a
|
|
837
|
+
// name, so only the leading clause before ':' is used — "BrowserStack Test Management",
|
|
838
|
+
// "BrowserStack Test Reporting & Analytics (TRA, aka Test Observability)".
|
|
839
|
+
for (const [product, index] of Object.entries(products)) {
|
|
840
|
+
const names = [
|
|
841
|
+
product,
|
|
842
|
+
...(index.summary ?? "").split(":")[0].split(/[(),]/),
|
|
843
|
+
];
|
|
844
|
+
for (const name of names) {
|
|
845
|
+
// "aka Test Observability" is the product's other name with a connective in front
|
|
846
|
+
// of it; the user types the name, not the connective.
|
|
847
|
+
const key = fold(name.replace(/^\s*(browserstack|aka|formerly)\s+/i, ""));
|
|
848
|
+
if (key.length < 2)
|
|
849
|
+
continue;
|
|
850
|
+
if (said.includes(` ${key} `))
|
|
851
|
+
claim(product, key);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
// Vocabulary only one product claims. Shared words settle nothing and are skipped —
|
|
855
|
+
// that is exactly what `ambiguousProducts` flagged in the first place.
|
|
856
|
+
const owners = new Map();
|
|
857
|
+
for (const [product, entries] of Object.entries(vocabularyOf(products))) {
|
|
858
|
+
for (const entry of entries) {
|
|
859
|
+
for (const word of [entry.entity, ...(entry.aliases ?? [])]) {
|
|
860
|
+
const key = fold(String(word));
|
|
861
|
+
if (!key)
|
|
862
|
+
continue;
|
|
863
|
+
if (!owners.has(key))
|
|
864
|
+
owners.set(key, new Set());
|
|
865
|
+
owners.get(key).add(product);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
// The same words `ambiguousProducts` routes on, so a term that settles the question
|
|
870
|
+
// there also anchors the product here — otherwise `flaky` names a product in one half
|
|
871
|
+
// of the check and nothing at all in the other.
|
|
872
|
+
for (const [product, index] of Object.entries(products)) {
|
|
873
|
+
for (const word of index.routing_terms ?? []) {
|
|
874
|
+
const key = fold(String(word));
|
|
875
|
+
if (!key)
|
|
876
|
+
continue;
|
|
877
|
+
if (!owners.has(key))
|
|
878
|
+
owners.set(key, new Set());
|
|
879
|
+
owners.get(key).add(product);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
const matched = [];
|
|
883
|
+
for (const [key, claimants] of owners) {
|
|
884
|
+
if (claimants.size !== 1)
|
|
885
|
+
continue;
|
|
886
|
+
if (!said.includes(` ${key} `))
|
|
887
|
+
continue;
|
|
888
|
+
matched.push({ key, product: [...claimants][0] });
|
|
889
|
+
}
|
|
890
|
+
for (const { key, product } of matched) {
|
|
891
|
+
// A word carried by nearly every request in this domain anchors nothing: tra's
|
|
892
|
+
// sp_test lists the bare word `test` as an alias, which would make "what test plans
|
|
893
|
+
// are active" read as tra on the strength of one noun that means nothing on its own.
|
|
894
|
+
if (GENERIC_ANCHORS.has(key))
|
|
895
|
+
continue;
|
|
896
|
+
// Longest match wins. "test management" and "test" both match the same sentence, and
|
|
897
|
+
// only the longer one carries the user's meaning — counting both makes a settled
|
|
898
|
+
// question look ambiguous again.
|
|
899
|
+
if (matched.some((other) => other.key !== key && ` ${other.key} `.includes(` ${key} `)))
|
|
900
|
+
continue;
|
|
901
|
+
claim(product, key);
|
|
902
|
+
}
|
|
903
|
+
return {
|
|
904
|
+
products: [...found.keys()].sort(),
|
|
905
|
+
terms: [...new Set([...found.values()].flatMap((s) => [...s]))].sort(),
|
|
906
|
+
};
|
|
907
|
+
}
|
|
@@ -233,19 +233,58 @@ export interface PagingRule {
|
|
|
233
233
|
* harness's `{ env: … }` value sources must NOT cross the boundary into the artifact.
|
|
234
234
|
*/
|
|
235
235
|
export interface AuthScheme {
|
|
236
|
-
/**
|
|
237
|
-
|
|
236
|
+
/**
|
|
237
|
+
* "apiKey" puts the rendered template in a header; "http" with scheme "basic" encodes it;
|
|
238
|
+
* "oauth2" exchanges the caller's credentials for a JWT and sends `Authorization: Bearer`.
|
|
239
|
+
*
|
|
240
|
+
* Each value fixes BOTH where the value comes from and how it is presented. Only `apiKey`
|
|
241
|
+
* carries `in`/`name`, because it is the only one with no convention for where it goes —
|
|
242
|
+
* RFC 6750 settles that for a bearer token, which is why OpenAPI's own `oauth2` scheme has
|
|
243
|
+
* no location fields either. `in`, `name` and `template` are REFUSED on `oauth2` rather
|
|
244
|
+
* than ignored, so an author who writes one gets an error instead of a silent 401.
|
|
245
|
+
*/
|
|
246
|
+
type: "apiKey" | "http" | "oauth2";
|
|
238
247
|
/** apiKey only. Header is the sole supported location — see `egress.authHeaders`. */
|
|
239
248
|
in?: "header" | "cookie" | "query";
|
|
240
249
|
/** apiKey only: the header name, e.g. "Api-Token". */
|
|
241
250
|
name?: string;
|
|
242
251
|
/** http only. */
|
|
243
252
|
scheme?: string;
|
|
244
|
-
/** Defaults to `{username}:{access_key}`. */
|
|
253
|
+
/** apiKey/http only. Defaults to `{username}:{access_key}`. */
|
|
245
254
|
template?: string;
|
|
255
|
+
/**
|
|
256
|
+
* oauth2 only: override the endpoint the JWT is minted at.
|
|
257
|
+
*
|
|
258
|
+
* Optional, and most products want the default. Bounded by an allowlist in `oauth.ts` —
|
|
259
|
+
* this field decides where the caller's credentials are POSTed, and an index is a data
|
|
260
|
+
* file that ships to public npm.
|
|
261
|
+
*/
|
|
262
|
+
token_url?: string;
|
|
263
|
+
/**
|
|
264
|
+
* oauth2 only: override the scope set. Order is not significant; it is sorted and deduped
|
|
265
|
+
* before it reaches the grant or the token cache.
|
|
266
|
+
*
|
|
267
|
+
* There is deliberately no `flow` beside this. The server implements one grant, so a field
|
|
268
|
+
* with one legal value could distinguish nothing — and an unvalidated one would read as
|
|
269
|
+
* though it selected behaviour while selecting nothing.
|
|
270
|
+
*/
|
|
271
|
+
scopes?: string[];
|
|
246
272
|
}
|
|
247
273
|
export interface ProductIndex {
|
|
248
274
|
summary: string;
|
|
275
|
+
/**
|
|
276
|
+
* Words that route to this product but are not the name of anything in it.
|
|
277
|
+
*
|
|
278
|
+
* The entity names and their aliases are what a product CONTAINS; this is what its users
|
|
279
|
+
* CALL it when they ask for something. `flaky` is the clearest case: both indexes talk
|
|
280
|
+
* about flakiness and neither models it as an entity, so the word was invisible to
|
|
281
|
+
* routing and a question about it read as naming no product at all. The other half is
|
|
282
|
+
* the reverse — `workspace`, `team`, `setting` and `suite` belong to both products and
|
|
283
|
+
* happen to be modelled by one, which let an incidental word settle a question it had no
|
|
284
|
+
* business settling. Listing a word here is a claim about vocabulary only; it never adds
|
|
285
|
+
* a capability and never widens a search.
|
|
286
|
+
*/
|
|
287
|
+
routing_terms?: string[];
|
|
249
288
|
/**
|
|
250
289
|
* How to authenticate to this product. Absent means the historical default: the caller's
|
|
251
290
|
* credentials as `Api-Token: {username}:{access_key}`, which is what every shipped index
|
|
@@ -19,7 +19,7 @@ import { getTestPlan, GetTestPlanSchema, } from "./testmanagement-utils/get-test
|
|
|
19
19
|
import { listSubTestPlans, ListSubTestPlansSchema, } from "./testmanagement-utils/list-sub-testplans.js";
|
|
20
20
|
import { getSubTestPlan, GetSubTestPlanSchema, } from "./testmanagement-utils/get-sub-testplan.js";
|
|
21
21
|
import { elicitCredentialsIfSupported } from "../lib/elicit-credentials.js";
|
|
22
|
-
import { NEEDS_PROJECT_ID, NEEDS_TEST_PLAN_ID,
|
|
22
|
+
import { NEEDS_PROJECT_ID, NEEDS_TEST_PLAN_ID, PLAN_WRITES_VIA_CAPABILITIES, PROJECT_ID_ONLY_FOR_FOLDER, } from "./tool-handoff.js";
|
|
23
23
|
//TODO: Moving the traceMCP and catch block to the parent(server) function
|
|
24
24
|
/**
|
|
25
25
|
* Wrapper to call createProjectOrFolder util.
|
|
@@ -536,7 +536,7 @@ export default function addTestManagementTools(server, config) {
|
|
|
536
536
|
}, (args, context) => createLCAStepsTool(args, context, config, server));
|
|
537
537
|
tools.listTestPlans = server.tool("listTestPlans", "List test plans in a BrowserStack Test Management project. Returns each plan's identifier (TP-*), name, status, description, dates, and active/closed test-run counts. Supports pagination." +
|
|
538
538
|
NEEDS_PROJECT_ID +
|
|
539
|
-
|
|
539
|
+
PLAN_WRITES_VIA_CAPABILITIES, ListTestPlansSchema.shape, {
|
|
540
540
|
title: "List Test Plans",
|
|
541
541
|
readOnlyHint: true,
|
|
542
542
|
openWorldHint: false,
|
|
@@ -546,7 +546,7 @@ export default function addTestManagementTools(server, config) {
|
|
|
546
546
|
tools.getTestPlan = server.tool("getTestPlan", "Fetch a test plan by identifier (TP-*) from BrowserStack Test Management. Returns plan metadata, the full list of linked test runs, total test-case count across runs, and a status summary — suitable for generating test documentation or QA status reports." +
|
|
547
547
|
NEEDS_PROJECT_ID +
|
|
548
548
|
NEEDS_TEST_PLAN_ID +
|
|
549
|
-
|
|
549
|
+
PLAN_WRITES_VIA_CAPABILITIES, GetTestPlanSchema.shape, {
|
|
550
550
|
title: "Get Test Plan",
|
|
551
551
|
readOnlyHint: true,
|
|
552
552
|
openWorldHint: false,
|
|
@@ -556,7 +556,7 @@ export default function addTestManagementTools(server, config) {
|
|
|
556
556
|
tools.listSubTestPlans = server.tool("listSubTestPlans", "List sub-test-plans under a parent test plan (TP-*) in a Test Management project. Supports pagination." +
|
|
557
557
|
NEEDS_PROJECT_ID +
|
|
558
558
|
NEEDS_TEST_PLAN_ID +
|
|
559
|
-
|
|
559
|
+
PLAN_WRITES_VIA_CAPABILITIES, ListSubTestPlansSchema.shape, {
|
|
560
560
|
title: "List Sub Test Plans",
|
|
561
561
|
readOnlyHint: true,
|
|
562
562
|
openWorldHint: false,
|
|
@@ -566,7 +566,7 @@ export default function addTestManagementTools(server, config) {
|
|
|
566
566
|
tools.getSubTestPlan = server.tool("getSubTestPlan", "Fetch a sub-test-plan (STP-*) under a parent plan (TP-*). Returns metadata and linked test runs." +
|
|
567
567
|
NEEDS_PROJECT_ID +
|
|
568
568
|
NEEDS_TEST_PLAN_ID +
|
|
569
|
-
|
|
569
|
+
PLAN_WRITES_VIA_CAPABILITIES, GetSubTestPlanSchema.shape, {
|
|
570
570
|
title: "Get Sub Test Plan",
|
|
571
571
|
readOnlyHint: true,
|
|
572
572
|
openWorldHint: false,
|
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
* where the missing identifier comes from.
|
|
9
9
|
*
|
|
10
10
|
* Point at a sibling tool whenever one can produce the id — it is faster and more
|
|
11
|
-
* predictable than
|
|
11
|
+
* predictable than anything that has to reason its way there. When no tool here can, point
|
|
12
|
+
* at the CAPABILITY FUNCTIONS: they reach the product's own API surface, so they answer the
|
|
13
|
+
* id question directly. Name them as a group, never individually — which function to call
|
|
14
|
+
* is their own business, and a name written here goes stale the moment one is renamed.
|
|
12
15
|
*
|
|
13
16
|
* The one that matters most: 15 of the 17 Test Management tools require a project
|
|
14
17
|
* identifier and NONE of them accepts its absence, yet no tool in this server lists
|
|
@@ -35,21 +38,21 @@ export declare const NEEDS_TEST_PLAN_ID: string;
|
|
|
35
38
|
/**
|
|
36
39
|
* The ONLY capability handoff here: every other constant points at a tool that produces a
|
|
37
40
|
* missing *id*, but plan WRITES have no tool at all — the surface is `listTestPlans`,
|
|
38
|
-
* `getTestPlan`, `listSubTestPlans`, `getSubTestPlan` and nothing else.
|
|
39
|
-
* (
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
41
|
+
* `getTestPlan`, `listSubTestPlans`, `getSubTestPlan` and nothing else. The capability functions can do them
|
|
42
|
+
* (Test Management's own API covers creating, updating, cloning and deleting a plan, and
|
|
43
|
+
* linking or unlinking its runs), so without this line the model reads the four read tools,
|
|
44
|
+
* finds no create, and reports the capability as absent — which is exactly what a QA eval
|
|
45
|
+
* concluded.
|
|
43
46
|
*
|
|
44
47
|
* Deliberately narrow: it names the specific operations that are missing rather than
|
|
45
|
-
* inviting the model to route plan work
|
|
46
|
-
*
|
|
48
|
+
* inviting the model to route plan work elsewhere generally, because the tool descriptions
|
|
49
|
+
* otherwise say to prefer a specific tool whenever one fits.
|
|
47
50
|
*
|
|
48
|
-
* Caveat worth knowing:
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
+
* Caveat worth knowing: a capability write is pinned to human approval, so this path only
|
|
52
|
+
* completes on a client that can show a prompt. On one that cannot, the intended write is
|
|
53
|
+
* refused rather than performed.
|
|
51
54
|
*/
|
|
52
|
-
export declare const
|
|
55
|
+
export declare const PLAN_WRITES_VIA_CAPABILITIES: string;
|
|
53
56
|
/** A build id comes from either build-lookup tool. */
|
|
54
57
|
export declare const NEEDS_BUILD_ID: string;
|
|
55
58
|
/**
|