@browserstack/mcp-server 1.4.0-beta.1 → 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;
@@ -16,7 +16,7 @@ import addAppLiveTools from "./tools/applive.js";
16
16
  import addBuildInsightsTools from "./tools/build-insights.js";
17
17
  import { setupOnInitialized } from "./oninitialized.js";
18
18
  import addRCATools from "./tools/rca-agent.js";
19
- import addAskBrowserstackAITool from "./tools/ask-browserstack/register.js";
19
+ import addAskBrowserStackAITool from "./tools/ask-browserstack/register.js";
20
20
  /**
21
21
  * Wrapper class for BrowserStack MCP Server
22
22
  * Stores a map of registered tools by name
@@ -55,7 +55,7 @@ export class BrowserStackMcpServer {
55
55
  // Hands a plain-language task to BrowserStack's agent and relays its mid-run
56
56
  // permission asks back to this client, so a write can be confirmed by the human
57
57
  // sitting in front of it rather than refused for want of anyone to ask.
58
- addAskBrowserstackAITool,
58
+ addAskBrowserStackAITool,
59
59
  ];
60
60
  toolAdders.forEach((adder) => {
61
61
  // Each adder now returns a Record<string, Tool>
@@ -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) {
@@ -258,7 +264,7 @@ export async function mintCentralToken(url, credentials, transport, now = Date.n
258
264
  const pending = mintOnce(url, credentials, transport)
259
265
  .then(({ token, lifetimeMs }) => {
260
266
  cache.set(key, { token, expiresAt: now + lifetimeMs });
261
- logger.info("askBrowserstackAI: signed in as %s (lifetime %ss)", credentials.username, Math.round(lifetimeMs / 1000));
267
+ logger.info("askBrowserStackAI: signed in as %s (lifetime %ss)", credentials.username, Math.round(lifetimeMs / 1000));
262
268
  return token;
263
269
  })
264
270
  .catch((error) => {
@@ -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
  * ============================================================================
@@ -98,7 +104,7 @@ function announce(what, url, source) {
98
104
  if (announced.has(line))
99
105
  return;
100
106
  announced.add(line);
101
- logger.info("askBrowserstackAI: %s is %s (source: %s)", what, url, source);
107
+ logger.info("askBrowserStackAI: %s is %s (source: %s)", what, url, source);
102
108
  }
103
109
  /**
104
110
  * Resolve Atlas's base URL:
@@ -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;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `askBrowserstackAI` — one tool call in, one tool result out, with a human's approval
2
+ * `askBrowserStackAI` — one tool call in, one tool result out, with a human's approval
3
3
  * relayed through the middle of it.
4
4
  *
5
5
  * The shape, and why:
@@ -55,7 +55,7 @@ export interface AskDeps {
55
55
  decisionTransport?: DecisionTransport;
56
56
  }
57
57
  export declare function relayMode(server: McpServer): RelayMode;
58
- export declare function addAskBrowserstackAITool(server: McpServer, deps: AskDeps, config?: BrowserStackConfig): Record<string, RegisteredTool>;
58
+ export declare function addAskBrowserStackAITool(server: McpServer, deps: AskDeps, config?: BrowserStackConfig): Record<string, RegisteredTool>;
59
59
  /** The tool-adder the server factory calls. */
60
- export declare function addAskBrowserstackAIToolFromConfig(server: McpServer, config: BrowserStackConfig): Record<string, RegisteredTool>;
61
- export default addAskBrowserstackAIToolFromConfig;
60
+ export declare function addAskBrowserStackAIToolFromConfig(server: McpServer, config: BrowserStackConfig): Record<string, RegisteredTool>;
61
+ export default addAskBrowserStackAIToolFromConfig;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `askBrowserstackAI` — one tool call in, one tool result out, with a human's approval
2
+ * `askBrowserStackAI` — one tool call in, one tool result out, with a human's approval
3
3
  * relayed through the middle of it.
4
4
  *
5
5
  * The shape, and why:
@@ -47,7 +47,16 @@ import { PRODUCTS, } from "./types.js";
47
47
  * words alone, before any call is made — so the ordering is deliberate: when to reach for it
48
48
  * first, what it does second, and the consent behaviour last.
49
49
  */
50
- const DESCRIPTION = "Use this when no other BrowserStack tool here fits the task, or when the ones you tried " +
50
+ const DESCRIPTION =
51
+ // Alpha status leads, deliberately. The model reads this before deciding to call, and a
52
+ // tool that is not enabled for the account cannot do the job at all — so "there is a
53
+ // per-account gate, fall back to the individual tools" is the most useful thing to say
54
+ // first. Parenthesised so it reads as a status note, not as the tool's purpose.
55
+ "(Alpha, limited availability. Enabled per account and per product; if it is not enabled " +
56
+ "the call returns an entitlement error, nothing runs, and you should complete the task " +
57
+ "with the individual tools instead. To request access, the user should contact their " +
58
+ "BrowserStack account owner.) " +
59
+ "Use this when no other BrowserStack tool here fits the task, or when the ones you tried " +
51
60
  "did not get you there. Prefer a specific tool whenever one fits: it is faster and more " +
52
61
  "predictable than handing the job to an agent. " +
53
62
  "Otherwise, describe what you want in plain language and BrowserStack's agent decides " +
@@ -147,7 +156,7 @@ async function relayOneAsk(server, ask, approvals, relatedRequestId) {
147
156
  // The SHAPE of the answer only — a fixed action enum and a boolean, never the description
148
157
  // or anything a user typed. Logged so that what a client actually submits can be read next
149
158
  // time rather than inferred from a compiled binary.
150
- logger.info("askBrowserstackAI: elicitation answered %s", elicitationShape(answer));
159
+ logger.info("askBrowserStackAI: elicitation answered %s", elicitationShape(answer));
151
160
  const { decision, reason } = decide(answer);
152
161
  approvals.push({ description: ask.description, decision, reason });
153
162
  return { perm_id: ask.perm_id, decision, reason };
@@ -224,14 +233,14 @@ async function runStreamed(server, streamTransport, decisionTransport, url, head
224
233
  // description cannot produce an answerable prompt, so it must not produce a prompt.
225
234
  const ask = parseAsk(event.data);
226
235
  if (!ask) {
227
- logger.error("askBrowserstackAI: unusable permission ask on the stream; ignoring");
236
+ logger.error("askBrowserStackAI: unusable permission ask on the stream; ignoring");
228
237
  continue;
229
238
  }
230
239
  if (!runId) {
231
240
  // Atlas emits `run` before any ask precisely so this cannot happen. If it does,
232
241
  // there is nowhere to send a decision — so do not prompt a human for an answer
233
242
  // that could never be delivered.
234
- logger.error("askBrowserstackAI: permission ask arrived before run_id; cannot answer");
243
+ logger.error("askBrowserStackAI: permission ask arrived before run_id; cannot answer");
235
244
  continue;
236
245
  }
237
246
  // `relayOneAsk` RETHROWS on an unexpected elicitation failure. Under A2 that was
@@ -246,7 +255,7 @@ async function runStreamed(server, streamTransport, decisionTransport, url, head
246
255
  decision = await relayOneAsk(server, ask, approvals, relatedRequestId);
247
256
  }
248
257
  catch (error) {
249
- logger.warn("askBrowserstackAI: elicitation failed, denying explicitly: %s", error instanceof Error ? error.message : String(error));
258
+ logger.warn("askBrowserStackAI: elicitation failed, denying explicitly: %s", error instanceof Error ? error.message : String(error));
250
259
  decision = { perm_id: ask.perm_id, decision: "deny", reason: "error" };
251
260
  }
252
261
  const status = await decisionTransport(decisionUrl(url, runId), headers, {
@@ -259,7 +268,7 @@ async function runStreamed(server, streamTransport, decisionTransport, url, head
259
268
  // own expiry, so a lost decision is safe — it can only cost an approval, never
260
269
  // grant one. Retrying risks the opposite: a duplicate that 409s, or worse, an
261
270
  // approval applied to a step the run has already moved past.
262
- logger.warn("askBrowserstackAI: decision for %s was not accepted (HTTP %s)", decision.perm_id, status);
271
+ logger.warn("askBrowserStackAI: decision for %s was not accepted (HTTP %s)", decision.perm_id, status);
263
272
  }
264
273
  }
265
274
  if (!sawResult) {
@@ -283,7 +292,7 @@ export function relayMode(server) {
283
292
  ? "offered"
284
293
  : "no_human";
285
294
  }
286
- export function addAskBrowserstackAITool(server, deps, config) {
295
+ export function addAskBrowserStackAITool(server, deps, config) {
287
296
  // A1 (CONTRACT v2) is the only path; A2 is gone. No version flag is needed to talk to
288
297
  // an Atlas that predates the stream: such a server answers `POST /agent` with ordinary
289
298
  // JSON, the parser sees no `text/event-stream`, and the run degrades to a read-only
@@ -300,10 +309,10 @@ export function addAskBrowserstackAITool(server, deps, config) {
300
309
  // Telemetry must not decide whether a tool call succeeds.
301
310
  }
302
311
  };
303
- tools.askBrowserstackAI = server.tool("askBrowserstackAI", DESCRIPTION, {
312
+ tools.askBrowserStackAI = server.tool("askBrowserStackAI", DESCRIPTION, {
304
313
  product: z
305
314
  .enum(PRODUCTS)
306
- .describe("Which product to work in: tm (Test Management), a11y (Accessibility), " +
315
+ .describe("Which product to work in: tm (Test Management), " +
307
316
  "tra (Test Reporting & Analytics)."),
308
317
  query: z
309
318
  .string()
@@ -314,9 +323,14 @@ export function addAskBrowserstackAITool(server, deps, config) {
314
323
  // sets it false: consent is not a licence to delete.
315
324
  readOnlyHint: false,
316
325
  destructiveHint: false,
317
- title: "Ask BrowserStack AI",
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,
331
+ title: "Ask BrowserStack AI (Alpha)",
318
332
  }, async ({ product, query }, extra) => {
319
- track("askBrowserstackAI");
333
+ track("askBrowserStackAI");
320
334
  const approvals = [];
321
335
  // Negotiated before anything else so the failure paths below report the mode they
322
336
  // would have run in.
@@ -347,7 +361,7 @@ export function addAskBrowserstackAITool(server, deps, config) {
347
361
  else {
348
362
  // Omitted ENTIRELY, not sent empty: its absence is what selects Atlas's
349
363
  // read-only HeadlessGate.
350
- logger.info("askBrowserstackAI: no permission relay (%s); running read-only", mode);
364
+ logger.info("askBrowserStackAI: no permission relay (%s); running read-only", mode);
351
365
  }
352
366
  // `product` reaches the result so an entitlement refusal can name it: the flags
353
367
  // are per product, and a bare "not enabled" sends the user to their admin
@@ -361,7 +375,15 @@ export function addAskBrowserstackAITool(server, deps, config) {
361
375
  const message = error instanceof AskError || error instanceof Error
362
376
  ? error.message
363
377
  : String(error);
364
- logger.error("askBrowserstackAI failed: %s", message);
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
+ }
365
387
  // No `canElicit` argument: the request never left this process, so whether the
366
388
  // client could have been prompted is not what the reader needs to know.
367
389
  return toResult(errorResult(message, approvals));
@@ -373,9 +395,9 @@ export function addAskBrowserstackAITool(server, deps, config) {
373
395
  return tools;
374
396
  }
375
397
  /** The tool-adder the server factory calls. */
376
- export function addAskBrowserstackAIToolFromConfig(server, config) {
398
+ export function addAskBrowserStackAIToolFromConfig(server, config) {
377
399
  if (!isEnabled()) {
378
- logger.info("askBrowserstackAI disabled by ASK_BROWSERSTACK_DISABLED");
400
+ logger.info("askBrowserStackAI disabled by ASK_BROWSERSTACK_DISABLED");
379
401
  return {};
380
402
  }
381
403
  const credentials = () => ({
@@ -383,7 +405,7 @@ export function addAskBrowserstackAIToolFromConfig(server, config) {
383
405
  accessKey: config["browserstack-access-key"],
384
406
  });
385
407
  const tokenTransport = fetchTokenTransport();
386
- return addAskBrowserstackAITool(server, {
408
+ return addAskBrowserStackAITool(server, {
387
409
  // Both resolved per call. An unconfigured host surfaces as a named error from the
388
410
  // tool rather than as a missing tool, so the cause is visible to whoever hits it.
389
411
  agentUrl,
@@ -391,4 +413,4 @@ export function addAskBrowserstackAIToolFromConfig(server, config) {
391
413
  credentialsFor: credentials,
392
414
  }, config);
393
415
  }
394
- export default addAskBrowserstackAIToolFromConfig;
416
+ export default addAskBrowserStackAIToolFromConfig;
@@ -58,10 +58,11 @@ export const RELAY_OFF_DETAILS = {
58
58
  // Not a refusal by anyone and not a relay problem at all: the account is not on the
59
59
  // product's agent flag. The product-specific sentence and what to do about it live in
60
60
  // `error`, so this one points there rather than duplicating the plumbing.
61
- not_entitled: "NOBODY DECLINED THIS AND NOTHING RAN. BrowserStack AI is not enabled for this account, " +
62
- "so the request was refused before the agent started. `error` says which product and " +
63
- "what to do about it. This is an entitlement on the account, not a problem with your " +
64
- "credentials and not a decision anyone made about your request.",
61
+ not_entitled: "NOBODY DECLINED THIS AND NOTHING RAN. Ask AI (Alpha) is not enabled on this account. " +
62
+ "Ask AI is in limited alpha and available only to enrolled accounts, so the request was " +
63
+ "refused before the agent started. `error` names the product. This is an entitlement on " +
64
+ "the account, not a problem with your credentials and not a decision anyone made about " +
65
+ "your request.",
65
66
  // The request never got as far as the agent. Distinct from `disabled` (the agent ran, with
66
67
  // the relay switched off) and from a decline (someone was asked and said no), because the
67
68
  // three call for completely different things from whoever reads them.
@@ -137,9 +138,10 @@ export function isNotEntitled(response) {
137
138
  */
138
139
  export const NOT_ENTITLED_DETAIL = (product) => {
139
140
  const scope = product && product.trim() ? ` for \`${product.trim()}\`` : "";
140
- return (`BrowserStack AI is not enabled${scope} on your account. Please contact your admin. ` +
141
- `YOUR CREDENTIALS ARE FINE they authenticated successfully; this is a per-product ` +
142
- `entitlement on the account. Nothing was run and nobody declined anything.`);
141
+ return (`Ask AI (Alpha) is not enabled${scope} on this account. Ask AI is in limited alpha and ` +
142
+ `available only to enrolled accounts. Authentication succeeded and nothing was run. ` +
143
+ `To request access, contact your BrowserStack account owner, or reach out at ` +
144
+ `https://www.browserstack.com/contact-sales`);
143
145
  };
144
146
  export function neverReachedAgent(response) {
145
147
  // No response at all: nothing could have run.
@@ -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
  /**
@@ -120,7 +121,7 @@ export function parseFrame(frame) {
120
121
  // A frame we cannot read is not a frame we may guess at. Dropping it is safe
121
122
  // because the only consequence is that an ask goes unanswered and the gate denies
122
123
  // on its own expiry — never that something is approved.
123
- logger.warn("askBrowserstackAI: unparseable stream frame, ignoring");
124
+ logger.warn("askBrowserStackAI: unparseable stream frame, ignoring");
124
125
  return null;
125
126
  }
126
127
  }
@@ -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,
@@ -8,7 +8,7 @@
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 an agent. Point at `askBrowserStackAI` only when NO tool here can.
12
12
  *
13
13
  * The one that matters most: 15 of the 17 Test Management tools require a project
14
14
  * identifier and NONE of them accepts its absence, yet no tool in this server lists
@@ -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. */
@@ -8,7 +8,7 @@
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 an agent. Point at `askBrowserStackAI` only when NO tool here can.
12
12
  *
13
13
  * The one that matters most: 15 of the 17 Test Management tools require a project
14
14
  * identifier and NONE of them accepts its absence, yet no tool in this server lists
@@ -21,27 +21,55 @@
21
21
  */
22
22
  /** No tool lists projects, so this genuinely has to go to the agent. */
23
23
  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 ' +
24
+ 'have one, call askBrowserStackAI with product "tm" and ask which projects exist, then ' +
25
25
  "retry this tool with the identifier it returns.";
26
26
  /** A sibling tool can produce the id — prefer it over the agent. */
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. */
35
64
  export const NEEDS_SESSION_ID = " Requires a session id, which no tool here lists. If you only know the build, call " +
36
- "getBuildId or listBuildId; if you have neither, call askBrowserstackAI with product " +
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.1",
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",