@browserstack/mcp-server 2.2.0 → 2.2.1-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 (31) hide show
  1. package/capability/tm.capability-index.json +2 -1
  2. package/capability/tra.capability-index.json +31709 -0
  3. package/dist/lib/apiClient.d.ts +2 -1
  4. package/dist/lib/apiClient.js +8 -1
  5. package/dist/lib/central-oauth.d.ts +133 -0
  6. package/dist/lib/central-oauth.js +246 -0
  7. package/dist/tools/ask-browserstack/central-oauth.d.ts +25 -46
  8. package/dist/tools/ask-browserstack/central-oauth.js +55 -177
  9. package/dist/tools/capability-registry/bind.js +43 -7
  10. package/dist/tools/capability-registry/discovery.js +3 -1
  11. package/dist/tools/capability-registry/egress.d.ts +12 -5
  12. package/dist/tools/capability-registry/egress.js +24 -1
  13. package/dist/tools/capability-registry/oauth.d.ts +63 -0
  14. package/dist/tools/capability-registry/oauth.js +176 -0
  15. package/dist/tools/capability-registry/register.js +289 -41
  16. package/dist/tools/capability-registry/resolve.d.ts +1 -1
  17. package/dist/tools/capability-registry/resolve.js +24 -3
  18. package/dist/tools/capability-registry/routing-token.d.ts +25 -0
  19. package/dist/tools/capability-registry/routing-token.js +87 -0
  20. package/dist/tools/capability-registry/search.d.ts +28 -1
  21. package/dist/tools/capability-registry/search.js +202 -4
  22. package/dist/tools/capability-registry/types.d.ts +42 -3
  23. package/dist/tools/failurelogs-utils/app-automate.js +4 -1
  24. package/dist/tools/failurelogs-utils/automate.js +5 -2
  25. package/dist/tools/failurelogs-utils/utils.d.ts +1 -0
  26. package/dist/tools/failurelogs-utils/utils.js +7 -0
  27. package/dist/tools/testmanagement-utils/TCG-utils/helpers.js +1 -1
  28. package/dist/tools/testmanagement.js +5 -5
  29. package/dist/tools/tool-handoff.d.ts +15 -12
  30. package/dist/tools/tool-handoff.js +21 -18
  31. 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
- 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;
@@ -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 term only one product claims decides the query outright.
547
- if (claimants.size < 2)
548
- return none;
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 none;
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
- /** "apiKey" puts the rendered template in a header; "http" with scheme "basic" encodes it. */
237
- type: "apiKey" | "http";
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
@@ -1,5 +1,5 @@
1
1
  import { getBrowserStackAuth } from "../../lib/get-auth.js";
2
- import { filterLinesByKeywords, validateLogResponse } from "./utils.js";
2
+ import { filterLinesByKeywords, MAX_LOG_BYTES, validateLogResponse, } from "./utils.js";
3
3
  import { apiClient } from "../../lib/apiClient.js";
4
4
  import { wrapUntrusted } from "../../lib/untrusted-content.js";
5
5
  // DEVICE LOGS
@@ -13,6 +13,7 @@ export async function retrieveDeviceLogs(sessionId, buildId, config) {
13
13
  "Content-Type": "application/json",
14
14
  Authorization: `Basic ${auth}`,
15
15
  },
16
+ maxContentLength: MAX_LOG_BYTES,
16
17
  raise_error: false,
17
18
  });
18
19
  const validationError = validateLogResponse(response, "device logs");
@@ -37,6 +38,7 @@ export async function retrieveAppiumLogs(sessionId, buildId, config) {
37
38
  "Content-Type": "application/json",
38
39
  Authorization: `Basic ${auth}`,
39
40
  },
41
+ maxContentLength: MAX_LOG_BYTES,
40
42
  raise_error: false,
41
43
  });
42
44
  const validationError = validateLogResponse(response, "Appium logs");
@@ -61,6 +63,7 @@ export async function retrieveCrashLogs(sessionId, buildId, config) {
61
63
  "Content-Type": "application/json",
62
64
  Authorization: `Basic ${auth}`,
63
65
  },
66
+ maxContentLength: MAX_LOG_BYTES,
64
67
  raise_error: false,
65
68
  });
66
69
  const validationError = validateLogResponse(response, "crash logs");
@@ -1,6 +1,6 @@
1
1
  import { getBrowserStackAuth } from "../../lib/get-auth.js";
2
2
  import { wrapUntrusted } from "../../lib/untrusted-content.js";
3
- import { filterLinesByKeywords, validateLogResponse, } from "./utils.js";
3
+ import { filterLinesByKeywords, MAX_LOG_BYTES, validateLogResponse, } from "./utils.js";
4
4
  import { apiClient } from "../../lib/apiClient.js";
5
5
  // NETWORK LOGS
6
6
  export async function retrieveNetworkFailures(sessionId, config) {
@@ -13,13 +13,14 @@ export async function retrieveNetworkFailures(sessionId, config) {
13
13
  "Content-Type": "application/json",
14
14
  Authorization: `Basic ${auth}`,
15
15
  },
16
+ maxContentLength: MAX_LOG_BYTES,
16
17
  raise_error: false,
17
18
  });
18
19
  const validationError = validateLogResponse(response, "network logs");
19
20
  if (validationError)
20
21
  return validationError.message;
21
22
  const networklogs = response.data;
22
- const failureEntries = networklogs.log.entries.filter((entry) => entry.response.status === 0 ||
23
+ const failureEntries = (networklogs?.log?.entries ?? []).filter((entry) => entry.response.status === 0 ||
23
24
  entry.response.status >= 400 ||
24
25
  entry.response._error !== undefined);
25
26
  return failureEntries.length > 0
@@ -51,6 +52,7 @@ export async function retrieveSessionFailures(sessionId, config) {
51
52
  "Content-Type": "application/json",
52
53
  Authorization: `Basic ${auth}`,
53
54
  },
55
+ maxContentLength: MAX_LOG_BYTES,
54
56
  raise_error: false,
55
57
  });
56
58
  const validationError = validateLogResponse(response, "session logs");
@@ -75,6 +77,7 @@ export async function retrieveConsoleFailures(sessionId, config) {
75
77
  "Content-Type": "application/json",
76
78
  Authorization: `Basic ${auth}`,
77
79
  },
80
+ maxContentLength: MAX_LOG_BYTES,
78
81
  raise_error: false,
79
82
  });
80
83
  const validationError = validateLogResponse(response, "console logs");
@@ -1,4 +1,5 @@
1
1
  import type { ApiResponse } from "../../lib/apiClient.js";
2
+ export declare const MAX_LOG_BYTES: number | undefined;
2
3
  export interface LogResponse {
3
4
  logs?: any[];
4
5
  message?: string;
@@ -1,5 +1,12 @@
1
+ import appConfig from "../../config.js";
2
+ export const MAX_LOG_BYTES = appConfig.REMOTE_MCP ? 5 * 1024 * 1024 : undefined;
1
3
  export function validateLogResponse(response, logType) {
2
4
  if (!response.ok) {
5
+ if (response.status === 413) {
6
+ return {
7
+ message: `The ${logType} for this session are too large (over 5 MB) to analyze here. Open the session in the BrowserStack dashboard to view them.`,
8
+ };
9
+ }
3
10
  if (response.status === 404) {
4
11
  return { message: `No ${logType} available for this session` };
5
12
  }
@@ -38,7 +38,7 @@ export function createTestCasePayload(tc, scenarioId, folderId, fieldMaps, docum
38
38
  template: "test_case_steps",
39
39
  metadata: JSON.stringify({
40
40
  ai_prompt: {
41
- attachment_id: documentId,
41
+ attachment_ids: [documentId],
42
42
  rich_text_id: null,
43
43
  scenario: scenarioId,
44
44
  test_case_count: tc.test_case_count || 1,
@@ -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, PLAN_WRITES_VIA_AGENT, PROJECT_ID_ONLY_FOR_FOLDER, } from "./tool-handoff.js";
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
- PLAN_WRITES_VIA_AGENT, ListTestPlansSchema.shape, {
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
- PLAN_WRITES_VIA_AGENT, GetTestPlanSchema.shape, {
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
- PLAN_WRITES_VIA_AGENT, ListSubTestPlansSchema.shape, {
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
- PLAN_WRITES_VIA_AGENT, GetSubTestPlanSchema.shape, {
569
+ PLAN_WRITES_VIA_CAPABILITIES, GetSubTestPlanSchema.shape, {
570
570
  title: "Get Sub Test Plan",
571
571
  readOnlyHint: true,
572
572
  openWorldHint: false,