@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
@@ -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
@@ -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,
@@ -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 an agent. Point at `askBrowserStackAI` only when NO tool here can.
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. Atlas can do them
39
- * (the tm harness allows POST /api/v1/projects/{id}/test-plans plus /update, /delete,
40
- * /clone, /test-runs and /test-runs/unlink), so without this line the model reads the four
41
- * read tools, finds no create, and reports the capability as absent — which is exactly what
42
- * a QA eval concluded.
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 to the agent generally, because the tool
46
- * descriptions otherwise say to prefer a specific tool whenever one fits.
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: askBrowserStackAI pins every write to human approval, so this path
49
- * only completes on a client that can show a prompt. On one that cannot, the intended write
50
- * comes back in `needs_approval` instead of happening.
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 PLAN_WRITES_VIA_AGENT: string;
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
  /**
@@ -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 an agent. Point at `askBrowserStackAI` only when NO tool here can.
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
@@ -21,8 +24,8 @@
21
24
  */
22
25
  /** No tool lists projects, so this genuinely has to go to the agent. */
23
26
  export const NEEDS_PROJECT_ID = " Requires a project identifier (PR-*). No tool here lists projects, so if you do not " +
24
- 'have one, call askBrowserStackAI with product "tm" and ask which projects exist, then ' +
25
- "retry this tool with the identifier it returns.";
27
+ "have one, use the capability functions to list the Test Management projects, then " +
28
+ "retry this tool with the identifier they return.";
26
29
  /** A sibling tool can produce the id — prefer it over the agent. */
27
30
  export function needsIdFrom(idLabel, sourceTool) {
28
31
  return ` Requires ${idLabel}. Call ${sourceTool} first if you do not have it.`;
@@ -34,30 +37,30 @@ export function needsIdFrom(idLabel, sourceTool) {
34
37
  * "create me a project" through the agent before letting the tool run.
35
38
  */
36
39
  export const PROJECT_ID_ONLY_FOR_FOLDER = " Creating a project needs no identifier. Creating a folder inside an EXISTING project " +
37
- "needs that project's identifier (PR-*); no tool here lists projects, so ask " +
38
- 'askBrowserStackAI with product "tm" for it.';
40
+ "needs that project's identifier (PR-*); no tool here lists projects, so use the " +
41
+ "capability functions to look it up.";
39
42
  /** A test plan id (TP-*) comes from listTestPlans. */
40
43
  export const NEEDS_TEST_PLAN_ID = needsIdFrom("a test plan identifier (TP-*)", "listTestPlans");
41
44
  /**
42
45
  * The ONLY capability handoff here: every other constant points at a tool that produces a
43
46
  * missing *id*, but plan WRITES have no tool at all — the surface is `listTestPlans`,
44
- * `getTestPlan`, `listSubTestPlans`, `getSubTestPlan` and nothing else. Atlas can do them
45
- * (the tm harness allows POST /api/v1/projects/{id}/test-plans plus /update, /delete,
46
- * /clone, /test-runs and /test-runs/unlink), so without this line the model reads the four
47
- * read tools, finds no create, and reports the capability as absent — which is exactly what
48
- * a QA eval concluded.
47
+ * `getTestPlan`, `listSubTestPlans`, `getSubTestPlan` and nothing else. The capability functions can do them
48
+ * (Test Management's own API covers creating, updating, cloning and deleting a plan, and
49
+ * linking or unlinking its runs), so without this line the model reads the four read tools,
50
+ * finds no create, and reports the capability as absent — which is exactly what a QA eval
51
+ * concluded.
49
52
  *
50
53
  * Deliberately narrow: it names the specific operations that are missing rather than
51
- * inviting the model to route plan work to the agent generally, because the tool
52
- * descriptions otherwise say to prefer a specific tool whenever one fits.
54
+ * inviting the model to route plan work elsewhere generally, because the tool descriptions
55
+ * otherwise say to prefer a specific tool whenever one fits.
53
56
  *
54
- * Caveat worth knowing: askBrowserStackAI pins every write to human approval, so this path
55
- * only completes on a client that can show a prompt. On one that cannot, the intended write
56
- * comes back in `needs_approval` instead of happening.
57
+ * Caveat worth knowing: a capability write is pinned to human approval, so this path only
58
+ * completes on a client that can show a prompt. On one that cannot, the intended write is
59
+ * refused rather than performed.
57
60
  */
58
- export const PLAN_WRITES_VIA_AGENT = " Creating a test plan or sub-plan, and linking or unlinking test runs on one, are not " +
59
- 'available as tools here: call askBrowserStackAI with product "tm" and describe what you ' +
60
- "want. It asks you to confirm before changing anything.";
61
+ export const PLAN_WRITES_VIA_CAPABILITIES = " Creating a test plan or sub-plan, and linking or unlinking test runs on one, are not " +
62
+ "available as tools here: use the capability functions, which reach Test Management's " +
63
+ "own API for these operations. They ask you to confirm before changing anything.";
61
64
  /** A build id comes from either build-lookup tool. */
62
65
  export const NEEDS_BUILD_ID = needsIdFrom("a BrowserStack build id", "getBuildId or listBuildId");
63
66
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@browserstack/mcp-server",
3
- "version": "2.1.0",
3
+ "version": "2.2.0-beta.1",
4
4
  "description": "BrowserStack's Official MCP Server",
5
5
  "mcpName": "io.github.browserstack/mcp-server",
6
6
  "main": "dist/index.js",
@@ -19,6 +19,7 @@
19
19
  "check:contract": "python3 scripts/check-contract.py capability/tm.capability-index.json --baseline scripts/contract-baseline/tm.json --quiet",
20
20
  "eval:index": "tsx scripts/eval-index.mts",
21
21
  "seed:live": "tsx scripts/seed-live.mts",
22
+ "seed:live:tra": "tsx scripts/seed-live-tra.mts",
22
23
  "plan:tests": "tsx scripts/plan-capability-tests.mts",
23
24
  "replay:live": "tsx scripts/replay-live.mts"
24
25
  },
@@ -1,74 +0,0 @@
1
- /**
2
- * The two-call shape: ask now, decide later, with nothing held open in between.
3
- *
4
- * WHY IT EXISTS. Elicitation is what makes this process stateful — a suspended
5
- * `elicitation/create` pins an McpServer in one pod's heap until the answer arrives, so
6
- * the hosted deployment needs sessions, ingress affinity keyed on the caller's
7
- * credentials, and a cap on how many sessions may be alive. Deferred removes the
8
- * suspension rather than routing around it: Atlas returns at its first ask, we hand that
9
- * ask back, and the caller returns with the decision in a SECOND call. Between the two
10
- * this process holds nothing, so any pod can serve either.
11
- *
12
- * WHAT IT COSTS, stated plainly because it is not free:
13
- * - Approval rides the CLIENT's own permission prompt on the second call, not an
14
- * elicitation. A client configured not to prompt (`--permission-mode auto`) approves
15
- * with no human. Elicitation fails closed by construction; this fails closed only if
16
- * the client asks. That is the trade, and it is why stdio keeps elicitation.
17
- * - One extra round trip per APPROVAL, not per task. Atlas's gate is serial, so a task
18
- * with five writes is five approvals and six calls.
19
- *
20
- * The run itself is parked on Atlas's side for the gate's own 300s, kept alive exactly as
21
- * a dropped reader leaves it (CONTRACT v2 §4.2). Nothing here has to manage that.
22
- */
23
- import { DeferredTransport } from "./stream.js";
24
- import { AgentRequest, AskResult, RelayMode } from "./types.js";
25
- /** What a parked run needs the caller to send back. */
26
- export interface ParkedAsk {
27
- perm_id: string;
28
- description: string;
29
- product: string;
30
- mode: string;
31
- }
32
- /**
33
- * Atlas's `needs_approval` payload → our result, or `null` when it is not one.
34
- *
35
- * Null rather than a throw: a deferred request whose run never needed approval comes
36
- * back as an ORDINARY result, and that is a success to pass straight through — not an
37
- * unexpected shape. A read-only task must cost one call, not two.
38
- */
39
- export declare function parkedResult(body: unknown, product: string): {
40
- runId: string;
41
- ask: ParkedAsk;
42
- } | null;
43
- /** CONTRACT §5's shape, for a run that is parked rather than finished. */
44
- export declare function needsApprovalResult(runId: string, ask: ParkedAsk, product: string): AskResult;
45
- /** The FIRST call: run until the first ask, then answer. Nothing is held open. */
46
- export declare function runDeferred(url: string, headers: Record<string, string>, body: AgentRequest, transport: DeferredTransport, mode: RelayMode, product: string): Promise<AskResult>;
47
- /**
48
- * The SECOND call: deliver the decision, then pick the run back up.
49
- *
50
- * Two requests, in this order, and the order is the safety property: the decision is
51
- * posted FIRST so that a reattach which then fails cannot leave the gate waiting on an
52
- * answer we already have. A lost reattach costs the caller the answer; a lost decision
53
- * would cost them the run.
54
- */
55
- export declare function runResumed(agentUrl: string, headers: Record<string, string>, runId: string, permId: string, decision: "allow" | "deny", transport: DeferredTransport, mode: RelayMode, product: string): Promise<AskResult>;
56
- /** A resume that did not happen. Every field says "nothing changed", because in each
57
- * case nothing did — and a caller that cannot tell a failed resume from a refusal
58
- * either retries a write that landed or abandons one that never did. */
59
- export declare function resumeFailed(error: string): AskResult;
60
- /** A decision that went out and whose reply never came back.
61
- *
62
- * Deliberately NOT `resumeFailed`: every field there says "nothing changed", and that
63
- * is a claim this case cannot make. The decision request carries the continuation now,
64
- * so a lost reply covers everything from "it never arrived" to "the write landed and
65
- * the answer was lost" — and telling a model nothing happened is what gets the change
66
- * made twice.
67
- *
68
- * `applied_before_stop: null` for the same reason: null is "not measured", which is
69
- * true, where false would be a measurement nobody took. */
70
- export declare function decisionUnknown(): AskResult;
71
- /** Starting a run with no task. Only reachable now that `query` is optional, which it
72
- * has to be so a model can follow `next_step` — which omits it — literally. */
73
- export declare function missingQuery(): AskResult;
74
- export declare function incompleteResume(): AskResult;