@browserstack/mcp-server 1.4.0-beta.2 → 1.4.0-beta.3

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/dist/config.d.ts CHANGED
@@ -11,7 +11,10 @@ export declare class Config {
11
11
  readonly O11Y_TFA_RCA_BASE_URL: string;
12
12
  readonly BROWSERSTACK_AUTOMATION_BASE_URL: string;
13
13
  readonly BROWSERSTACK_O11Y_UI_BASE_URL: string;
14
- constructor(DEV_MODE: boolean, browserstackLocalOptions: Record<string, any>, USE_OWN_LOCAL_BINARY_PROCESS: boolean, REMOTE_MCP: boolean, UPLOAD_BASE_DIR: string | undefined, O11Y_TFA_RCA_BASE_URL: string, BROWSERSTACK_AUTOMATION_BASE_URL: string, BROWSERSTACK_O11Y_UI_BASE_URL: string);
14
+ readonly ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY: boolean;
15
+ readonly ASK_BROWSERSTACK_ATLAS_URL: string | undefined;
16
+ readonly ASK_BROWSERSTACK_AUTH_TOKEN_URL: string | undefined;
17
+ constructor(DEV_MODE: boolean, browserstackLocalOptions: Record<string, any>, USE_OWN_LOCAL_BINARY_PROCESS: boolean, REMOTE_MCP: boolean, UPLOAD_BASE_DIR: string | undefined, O11Y_TFA_RCA_BASE_URL: string, BROWSERSTACK_AUTOMATION_BASE_URL: string, BROWSERSTACK_O11Y_UI_BASE_URL: string, ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY: boolean, ASK_BROWSERSTACK_ATLAS_URL: string | undefined, ASK_BROWSERSTACK_AUTH_TOKEN_URL: string | undefined);
15
18
  }
16
19
  declare const config: Config;
17
20
  export default config;
package/dist/config.js CHANGED
@@ -47,7 +47,18 @@ export class Config {
47
47
  O11Y_TFA_RCA_BASE_URL;
48
48
  BROWSERSTACK_AUTOMATION_BASE_URL;
49
49
  BROWSERSTACK_O11Y_UI_BASE_URL;
50
- constructor(DEV_MODE, browserstackLocalOptions, USE_OWN_LOCAL_BINARY_PROCESS, REMOTE_MCP, UPLOAD_BASE_DIR, O11Y_TFA_RCA_BASE_URL, BROWSERSTACK_AUTOMATION_BASE_URL, BROWSERSTACK_O11Y_UI_BASE_URL) {
50
+ ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY;
51
+ ASK_BROWSERSTACK_ATLAS_URL;
52
+ ASK_BROWSERSTACK_AUTH_TOKEN_URL;
53
+ constructor(DEV_MODE, browserstackLocalOptions, USE_OWN_LOCAL_BINARY_PROCESS, REMOTE_MCP, UPLOAD_BASE_DIR, O11Y_TFA_RCA_BASE_URL, BROWSERSTACK_AUTOMATION_BASE_URL, BROWSERSTACK_O11Y_UI_BASE_URL,
54
+ // askBrowserStackAI's process-startup settings. Declared here rather than read from
55
+ // process.env inside src/tools/, per rules/tool-design.md — and so the remote wrapper,
56
+ // which only forwards env it knows about, has one place to look.
57
+ //
58
+ // ASK_BROWSERSTACK_DISABLED is deliberately NOT here: it is a kill switch, and reading
59
+ // it per call keeps it effective without a restart. Fixing it at boot would mean a pod
60
+ // roll to disable the tool, which is slowest exactly when you need it fastest.
61
+ ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY, ASK_BROWSERSTACK_ATLAS_URL, ASK_BROWSERSTACK_AUTH_TOKEN_URL) {
51
62
  this.DEV_MODE = DEV_MODE;
52
63
  this.browserstackLocalOptions = browserstackLocalOptions;
53
64
  this.USE_OWN_LOCAL_BINARY_PROCESS = USE_OWN_LOCAL_BINARY_PROCESS;
@@ -56,6 +67,9 @@ export class Config {
56
67
  this.O11Y_TFA_RCA_BASE_URL = O11Y_TFA_RCA_BASE_URL;
57
68
  this.BROWSERSTACK_AUTOMATION_BASE_URL = BROWSERSTACK_AUTOMATION_BASE_URL;
58
69
  this.BROWSERSTACK_O11Y_UI_BASE_URL = BROWSERSTACK_O11Y_UI_BASE_URL;
70
+ this.ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY = ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY;
71
+ this.ASK_BROWSERSTACK_ATLAS_URL = ASK_BROWSERSTACK_ATLAS_URL;
72
+ this.ASK_BROWSERSTACK_AUTH_TOKEN_URL = ASK_BROWSERSTACK_AUTH_TOKEN_URL;
59
73
  }
60
74
  }
61
75
  const config = new Config(process.env.DEV_MODE === "true", browserstackLocalOptions, process.env.USE_OWN_LOCAL_BINARY_PROCESS === "true", process.env.REMOTE_MCP === "true", process.env.MCP_UPLOAD_BASE_DIR && process.env.MCP_UPLOAD_BASE_DIR.length > 0
@@ -69,5 +83,12 @@ const config = new Config(process.env.DEV_MODE === "true", browserstackLocalOpti
69
83
  : DEFAULT_BROWSERSTACK_AUTOMATION_BASE_URL, process.env.BROWSERSTACK_O11Y_UI_BASE_URL &&
70
84
  process.env.BROWSERSTACK_O11Y_UI_BASE_URL.length > 0
71
85
  ? process.env.BROWSERSTACK_O11Y_UI_BASE_URL
72
- : DEFAULT_BROWSERSTACK_O11Y_UI_BASE_URL);
86
+ : DEFAULT_BROWSERSTACK_O11Y_UI_BASE_URL, (process.env.ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY || "").toLowerCase() ===
87
+ "true", process.env.ASK_BROWSERSTACK_ATLAS_URL &&
88
+ process.env.ASK_BROWSERSTACK_ATLAS_URL.trim().length > 0
89
+ ? process.env.ASK_BROWSERSTACK_ATLAS_URL
90
+ : undefined, process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL &&
91
+ process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL.trim().length > 0
92
+ ? process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL
93
+ : undefined);
73
94
  export default config;
@@ -101,7 +101,13 @@ export declare const AUTH_SERVER_ERROR_DETAIL: (status: number) => string;
101
101
  export declare const AUTH_UNUSABLE_DETAIL: (status: number) => string;
102
102
  /** Drop every cached token. For tests, and for a credential rotation. */
103
103
  export declare function resetTokenCache(): void;
104
- /** A fetch-based transport for the token endpoint. */
104
+ /**
105
+ * The token endpoint, through `apiClient` per rules/security.md — no bare `fetch`.
106
+ *
107
+ * `raise_error: false` keeps the status-first contract this transport has always had: the
108
+ * caller distinguishes a 400 scope refusal from a 401 rejection from an unreachable host,
109
+ * so a thrown AxiosError on any non-2xx would destroy the only signal it reads.
110
+ */
105
111
  export declare function fetchTokenTransport(timeoutMs?: number): TokenTransport;
106
112
  /** The exact form body of the `client_credentials` grant. */
107
113
  export declare function mintForm(credentials: Credentials): Record<string, string>;
@@ -14,6 +14,8 @@
14
14
  * logged, returned, or put in an error message. Only a status code is.
15
15
  */
16
16
  import { createHash } from "node:crypto";
17
+ import { apiClient } from "../../lib/apiClient.js";
18
+ import appConfig from "../../config.js";
17
19
  import logger from "../../logger.js";
18
20
  import { AGENT_TIMEOUT_MS, AskError } from "./config.js";
19
21
  /**
@@ -157,40 +159,33 @@ function cacheKey(url, credentials) {
157
159
  .digest("hex");
158
160
  return `${url} ${credentials.username} ${CENTRAL_SCOPE} ${digest}`;
159
161
  }
160
- /** A fetch-based transport for the token endpoint. */
162
+ /**
163
+ * The token endpoint, through `apiClient` per rules/security.md — no bare `fetch`.
164
+ *
165
+ * `raise_error: false` keeps the status-first contract this transport has always had: the
166
+ * caller distinguishes a 400 scope refusal from a 401 rejection from an unreachable host,
167
+ * so a thrown AxiosError on any non-2xx would destroy the only signal it reads.
168
+ */
161
169
  export function fetchTokenTransport(timeoutMs = TOKEN_TIMEOUT_MS) {
162
170
  return async (url, form) => {
163
- const controller = new AbortController();
164
- const timer = setTimeout(() => controller.abort(), timeoutMs);
165
171
  try {
166
- const response = await fetch(url, {
167
- method: "POST",
172
+ const response = await apiClient.post({
173
+ url,
168
174
  headers: {
169
175
  "Content-Type": "application/x-www-form-urlencoded",
170
176
  Accept: "application/json",
171
177
  },
172
178
  body: new URLSearchParams(form).toString(),
173
- redirect: "manual",
174
- signal: controller.signal,
179
+ timeout: timeoutMs,
180
+ raise_error: false,
175
181
  });
176
- let parsed = null;
177
- try {
178
- parsed = await response.json();
179
- }
180
- catch {
181
- // An HTML error page behind any status. The caller only reads the status.
182
- parsed = null;
183
- }
184
- return { status: response.status, body: parsed };
182
+ return { status: response.status, body: response.data ?? null };
185
183
  }
186
184
  catch {
187
185
  // DNS, TLS, timeout — all of them mean "no token". The reason is deliberately not
188
186
  // carried: it can name the URL and, on some stacks, echo the request body.
189
187
  return { status: 0, body: null, error: "auth could not be reached" };
190
188
  }
191
- finally {
192
- clearTimeout(timer);
193
- }
194
189
  };
195
190
  }
196
191
  /** The exact form body of the `client_credentials` grant. */
@@ -247,6 +242,17 @@ export async function mintCentralToken(url, credentials, transport, now = Date.n
247
242
  throw new AskError("BrowserStack AI is not authenticated: BROWSERSTACK_USERNAME and " +
248
243
  "BROWSERSTACK_ACCESS_KEY are required to sign in");
249
244
  }
245
+ // NOT CACHED IN HOSTED MODE. These tokens are per-user, attested credentials, and the
246
+ // process is shared by every tenant — `rules/multi-tenant-safety.md` forbids holding user
247
+ // data in module-level state there, so remote mode mints per call. Keying on
248
+ // username + sha256(accessKey) already means one user can never be SERVED another's token,
249
+ // but containment is not the contract; not holding it at all is.
250
+ if (appConfig.REMOTE_MCP) {
251
+ return mintOnce(url, credentials, transport).then(({ token }) => {
252
+ logger.info("askBrowserStackAI: signed in as %s", credentials.username);
253
+ return token;
254
+ });
255
+ }
250
256
  const key = cacheKey(url, credentials);
251
257
  const entry = cache.get(key);
252
258
  if (entry && entry.token && now < entry.expiresAt - REFRESH_SKEW_MS) {
@@ -22,7 +22,13 @@ export declare const ELICITATION_TIMEOUT_MS = 270000;
22
22
  /** Thrown for anything this tool refuses to attempt. Never carries a credential. */
23
23
  export declare class AskError extends Error {
24
24
  }
25
- /** Off by default is wrong for a shipped feature, but a kill switch is not. */
25
+ /**
26
+ * Off by default is wrong for a shipped feature, but a kill switch is not.
27
+ *
28
+ * The only setting here still read from `process.env` per call, and deliberately: a kill
29
+ * switch that needs a process restart is slowest exactly when it is needed fastest. The
30
+ * other three are on the config singleton (rules/tool-design.md).
31
+ */
26
32
  export declare function isEnabled(): boolean;
27
33
  /**
28
34
  * May the relay be offered in the hosted (`REMOTE_MCP`) deployment?
@@ -1,3 +1,4 @@
1
+ import appConfig from "../../config.js";
1
2
  import logger from "../../logger.js";
2
3
  /**
3
4
  * Where Atlas lives, and the timeout ladder.
@@ -23,7 +24,13 @@ export const ELICITATION_TIMEOUT_MS = 270_000;
23
24
  /** Thrown for anything this tool refuses to attempt. Never carries a credential. */
24
25
  export class AskError extends Error {
25
26
  }
26
- /** Off by default is wrong for a shipped feature, but a kill switch is not. */
27
+ /**
28
+ * Off by default is wrong for a shipped feature, but a kill switch is not.
29
+ *
30
+ * The only setting here still read from `process.env` per call, and deliberately: a kill
31
+ * switch that needs a process restart is slowest exactly when it is needed fastest. The
32
+ * other three are on the config singleton (rules/tool-design.md).
33
+ */
27
34
  export function isEnabled() {
28
35
  return (process.env.ASK_BROWSERSTACK_DISABLED || "").toLowerCase() !== "true";
29
36
  }
@@ -41,8 +48,7 @@ export function isEnabled() {
41
48
  * run. This flag only removes the blanket refusal.
42
49
  */
43
50
  export function allowRemoteRelay() {
44
- return ((process.env.ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY || "").toLowerCase() ===
45
- "true");
51
+ return appConfig.ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY;
46
52
  }
47
53
  /**
48
54
  * ============================================================================
@@ -111,7 +117,7 @@ function announce(what, url, source) {
111
117
  * no selector: one default, one override.
112
118
  */
113
119
  export function atlasBaseUrl() {
114
- const explicit = process.env.ASK_BROWSERSTACK_ATLAS_URL;
120
+ const explicit = appConfig.ASK_BROWSERSTACK_ATLAS_URL;
115
121
  const url = explicit && explicit.trim() ? trimUrl(explicit) : DEFAULT_ATLAS_URL;
116
122
  announce("Atlas", url, explicit && explicit.trim() ? "env" : "default");
117
123
  return url;
@@ -127,7 +133,7 @@ export function agentUrl() {
127
133
  * the only way in. Same two rungs as the host, and the same staging default.
128
134
  */
129
135
  export function authTokenUrl() {
130
- const explicit = process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL;
136
+ const explicit = appConfig.ASK_BROWSERSTACK_AUTH_TOKEN_URL;
131
137
  const url = explicit && explicit.trim() ? trimUrl(explicit) : DEFAULT_AUTH_TOKEN_URL;
132
138
  announce("auth token endpoint", url, explicit && explicit.trim() ? "env" : "default");
133
139
  return url;
@@ -312,7 +312,7 @@ export function addAskBrowserStackAITool(server, deps, config) {
312
312
  tools.askBrowserStackAI = server.tool("askBrowserStackAI", DESCRIPTION, {
313
313
  product: z
314
314
  .enum(PRODUCTS)
315
- .describe("Which product to work in: tm (Test Management), a11y (Accessibility), " +
315
+ .describe("Which product to work in: tm (Test Management), " +
316
316
  "tra (Test Reporting & Analytics)."),
317
317
  query: z
318
318
  .string()
@@ -323,6 +323,11 @@ export function addAskBrowserStackAITool(server, deps, config) {
323
323
  // sets it false: consent is not a licence to delete.
324
324
  readOnlyHint: false,
325
325
  destructiveHint: false,
326
+ // openWorldHint: the agent fans out to product APIs chosen at runtime, so the set of
327
+ // effects is not knowable from this schema. idempotentHint false because a repeated
328
+ // call can create a second record — the relay asks again, it does not dedupe.
329
+ openWorldHint: true,
330
+ idempotentHint: false,
326
331
  title: "Ask BrowserStack AI (Alpha)",
327
332
  }, async ({ product, query }, extra) => {
328
333
  track("askBrowserStackAI");
@@ -371,6 +376,14 @@ export function addAskBrowserStackAITool(server, deps, config) {
371
376
  ? error.message
372
377
  : String(error);
373
378
  logger.error("askBrowserStackAI failed: %s", message);
379
+ // Error telemetry, in the same never-fatal shape as the success-path `track()`:
380
+ // a failing tool call must not be made worse by a failing analytics call.
381
+ try {
382
+ trackMCP("askBrowserStackAI", server.server.getClientVersion(), error, config);
383
+ }
384
+ catch {
385
+ /* ignore */
386
+ }
374
387
  // No `canElicit` argument: the request never left this process, so whether the
375
388
  // client could have been prompted is not what the reader needs to know.
376
389
  return toResult(errorResult(message, approvals));
@@ -22,6 +22,7 @@
22
22
  * is a pipe. Keeping the judgement out of the transport is why swapping A2 for A1 does
23
23
  * not risk the fail-closed behaviour.
24
24
  */
25
+ import { apiClient } from "../../lib/apiClient.js";
25
26
  import logger from "../../logger.js";
26
27
  import { AskError } from "./config.js";
27
28
  /**
@@ -208,15 +209,16 @@ export function fetchAgentStreamTransport(timeoutMs = WHOLE_RUN_TIMEOUT_MS) {
208
209
  /** The decision POST. 30s, because it is an ordinary short request. */
209
210
  export function fetchDecisionTransport(timeoutMs = 30_000) {
210
211
  return async (url, headers, body) => {
211
- const controller = new AbortController();
212
- const timer = setTimeout(() => controller.abort(), timeoutMs);
213
212
  try {
214
- const response = await fetch(url, {
215
- method: "POST",
213
+ // Through `apiClient` per rules/security.md. `raise_error: false` because the caller
214
+ // reads the STATUS: a 404 (run gone) and a 409 (already decided) are both answers,
215
+ // and a thrown AxiosError would collapse them into the unreachable case below.
216
+ const response = await apiClient.post({
217
+ url,
216
218
  headers,
217
- body: JSON.stringify(body),
218
- redirect: "manual",
219
- signal: controller.signal,
219
+ body,
220
+ timeout: timeoutMs,
221
+ raise_error: false,
220
222
  });
221
223
  return response.status;
222
224
  }
@@ -226,9 +228,6 @@ export function fetchDecisionTransport(timeoutMs = 30_000) {
226
228
  // the caller can say that rather than implying a human refused.
227
229
  return 0;
228
230
  }
229
- finally {
230
- clearTimeout(timer);
231
- }
232
231
  };
233
232
  }
234
233
  /** `POST /agent/{run_id}/permission`, built from the base URL the tool already resolved. */
@@ -6,7 +6,7 @@
6
6
  * different repo, at the same time. A field renamed here to read better is a field the
7
7
  * other half will never send. Nothing here changes without changing that document first.
8
8
  */
9
- export declare const PRODUCTS: readonly ["tm", "a11y", "tra"];
9
+ export declare const PRODUCTS: readonly ["tm", "tra"];
10
10
  export type Product = (typeof PRODUCTS)[number];
11
11
  /** CONTRACT §2 — what Atlas emits on the run's stream when its gate needs a human. */
12
12
  export interface PermissionAsk {
@@ -6,5 +6,9 @@
6
6
  * different repo, at the same time. A field renamed here to read better is a field the
7
7
  * other half will never send. Nothing here changes without changing that document first.
8
8
  */
9
- export const PRODUCTS = ["tm", "a11y", "tra"];
9
+ // a11y is deliberately ABSENT while Ask AI is in limited alpha. Atlas itself serves the
10
+ // product; this tool just does not offer it yet. Re-adding it means this list, the `product`
11
+ // describe text in register.ts, and the two a11y handoffs in tool-handoff.ts — which stop
12
+ // pointing here precisely because the call would now be rejected.
13
+ export const PRODUCTS = ["tm", "tra"];
10
14
  export const ASK_STATUSES = ["ok", "blocked", "error", "rate_limited"];
@@ -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 } from "./tool-handoff.js";
22
+ import { NEEDS_PROJECT_ID, NEEDS_TEST_PLAN_ID, PLAN_WRITES_VIA_AGENT, 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.
@@ -434,7 +434,7 @@ export async function getSubTestPlanTool(args, config, server) {
434
434
  export default function addTestManagementTools(server, config) {
435
435
  const tools = {};
436
436
  tools.createProjectOrFolder = server.tool("createProjectOrFolder", "Create a project and/or folder in BrowserStack Test Management." +
437
- NEEDS_PROJECT_ID, CreateProjFoldSchema.shape, {
437
+ PROJECT_ID_ONLY_FOR_FOLDER, CreateProjFoldSchema.shape, {
438
438
  title: "Create Project or Folder",
439
439
  readOnlyHint: false,
440
440
  openWorldHint: false,
@@ -535,7 +535,8 @@ export default function addTestManagementTools(server, config) {
535
535
  idempotentHint: false,
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
- NEEDS_PROJECT_ID, ListTestPlansSchema.shape, {
538
+ NEEDS_PROJECT_ID +
539
+ PLAN_WRITES_VIA_AGENT, ListTestPlansSchema.shape, {
539
540
  title: "List Test Plans",
540
541
  readOnlyHint: true,
541
542
  openWorldHint: false,
@@ -544,7 +545,8 @@ export default function addTestManagementTools(server, config) {
544
545
  }, (args) => listTestPlansTool(args, config, server));
545
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." +
546
547
  NEEDS_PROJECT_ID +
547
- NEEDS_TEST_PLAN_ID, GetTestPlanSchema.shape, {
548
+ NEEDS_TEST_PLAN_ID +
549
+ PLAN_WRITES_VIA_AGENT, GetTestPlanSchema.shape, {
548
550
  title: "Get Test Plan",
549
551
  readOnlyHint: true,
550
552
  openWorldHint: false,
@@ -553,7 +555,8 @@ export default function addTestManagementTools(server, config) {
553
555
  }, (args) => getTestPlanTool(args, config, server));
554
556
  tools.listSubTestPlans = server.tool("listSubTestPlans", "List sub-test-plans under a parent test plan (TP-*) in a Test Management project. Supports pagination." +
555
557
  NEEDS_PROJECT_ID +
556
- NEEDS_TEST_PLAN_ID, ListSubTestPlansSchema.shape, {
558
+ NEEDS_TEST_PLAN_ID +
559
+ PLAN_WRITES_VIA_AGENT, ListSubTestPlansSchema.shape, {
557
560
  title: "List Sub Test Plans",
558
561
  readOnlyHint: true,
559
562
  openWorldHint: false,
@@ -562,7 +565,8 @@ export default function addTestManagementTools(server, config) {
562
565
  }, (args) => listSubTestPlansTool(args, config, server));
563
566
  tools.getSubTestPlan = server.tool("getSubTestPlan", "Fetch a sub-test-plan (STP-*) under a parent plan (TP-*). Returns metadata and linked test runs." +
564
567
  NEEDS_PROJECT_ID +
565
- NEEDS_TEST_PLAN_ID, GetSubTestPlanSchema.shape, {
568
+ NEEDS_TEST_PLAN_ID +
569
+ PLAN_WRITES_VIA_AGENT, GetSubTestPlanSchema.shape, {
566
570
  title: "Get Sub Test Plan",
567
571
  readOnlyHint: true,
568
572
  openWorldHint: false,
@@ -23,8 +23,33 @@
23
23
  export declare const NEEDS_PROJECT_ID: string;
24
24
  /** A sibling tool can produce the id — prefer it over the agent. */
25
25
  export declare function needsIdFrom(idLabel: string, sourceTool: string): string;
26
+ /**
27
+ * createProjectOrFolder must NOT carry NEEDS_PROJECT_ID: `project_identifier` is optional
28
+ * there, and the create-a-PROJECT half needs no id at all. With the generic constant the
29
+ * tool read "Requires a project identifier ... call askBrowserStackAI", which routed
30
+ * "create me a project" through the agent before letting the tool run.
31
+ */
32
+ export declare const PROJECT_ID_ONLY_FOR_FOLDER: string;
26
33
  /** A test plan id (TP-*) comes from listTestPlans. */
27
34
  export declare const NEEDS_TEST_PLAN_ID: string;
35
+ /**
36
+ * The ONLY capability handoff here: every other constant points at a tool that produces a
37
+ * 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.
43
+ *
44
+ * 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.
47
+ *
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
+ */
52
+ export declare const PLAN_WRITES_VIA_AGENT: string;
28
53
  /** A build id comes from either build-lookup tool. */
29
54
  export declare const NEEDS_BUILD_ID: string;
30
55
  /** Session ids are not listable by any tool here. */
@@ -27,8 +27,37 @@ export const NEEDS_PROJECT_ID = " Requires a project identifier (PR-*). No tool
27
27
  export function needsIdFrom(idLabel, sourceTool) {
28
28
  return ` Requires ${idLabel}. Call ${sourceTool} first if you do not have it.`;
29
29
  }
30
+ /**
31
+ * createProjectOrFolder must NOT carry NEEDS_PROJECT_ID: `project_identifier` is optional
32
+ * there, and the create-a-PROJECT half needs no id at all. With the generic constant the
33
+ * tool read "Requires a project identifier ... call askBrowserStackAI", which routed
34
+ * "create me a project" through the agent before letting the tool run.
35
+ */
36
+ 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.';
30
39
  /** A test plan id (TP-*) comes from listTestPlans. */
31
40
  export const NEEDS_TEST_PLAN_ID = needsIdFrom("a test plan identifier (TP-*)", "listTestPlans");
41
+ /**
42
+ * The ONLY capability handoff here: every other constant points at a tool that produces a
43
+ * 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.
49
+ *
50
+ * 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.
53
+ *
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
+ */
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.";
32
61
  /** A build id comes from either build-lookup tool. */
33
62
  export const NEEDS_BUILD_ID = needsIdFrom("a BrowserStack build id", "getBuildId or listBuildId");
34
63
  /** Session ids are not listable by any tool here. */
@@ -36,12 +65,11 @@ export const NEEDS_SESSION_ID = " Requires a session id, which no tool here list
36
65
  "getBuildId or listBuildId; if you have neither, call askBrowserStackAI with product " +
37
66
  '"tra" and describe the run you mean.';
38
67
  /** A completed scan's ids come from startAccessibilityScan, or from the agent. */
39
- export const NEEDS_A11Y_SCAN_ID = " Requires the ids of a completed scan. They are returned by startAccessibilityScan; " +
40
- 'for a scan run earlier, call askBrowserStackAI with product "a11y" to locate it, since ' +
41
- "no tool here lists past scans.";
68
+ export const NEEDS_A11Y_SCAN_ID = " Requires the ids of a completed scan, which are returned by startAccessibilityScan. " +
69
+ "No tool here lists past scans, so if you do not have the ids, start a new scan rather " +
70
+ "than guessing.";
42
71
  /** Auth-config ids are not listable by any tool here. */
43
72
  export const NEEDS_A11Y_CONFIG_ID = " Requires the numeric id returned by createAccessibilityAuthConfig. No tool here lists " +
44
- "existing configurations, so if you do not have the id, call askBrowserStackAI with " +
45
- 'product "a11y".';
73
+ "existing configurations, so if you do not have the id, create one rather than guessing.";
46
74
  /** Test ids come from listTestIds, which itself needs a build id. */
47
75
  export const NEEDS_TEST_IDS = needsIdFrom("test ids", "listTestIds");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@browserstack/mcp-server",
3
- "version": "1.4.0-beta.2",
3
+ "version": "1.4.0-beta.3",
4
4
  "description": "BrowserStack's Official MCP Server",
5
5
  "mcpName": "io.github.browserstack/mcp-server",
6
6
  "main": "dist/index.js",