@browserstack/mcp-server 1.5.1 → 2.1.0

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 (41) hide show
  1. package/README.md +54 -1
  2. package/capability/tm.capability-index.json +50419 -0
  3. package/dist/config.d.ts +27 -2
  4. package/dist/config.js +19 -6
  5. package/dist/lib/instrumentation.d.ts +14 -1
  6. package/dist/lib/instrumentation.js +8 -1
  7. package/dist/lib/utils.js +8 -0
  8. package/dist/server-factory.js +5 -0
  9. package/dist/tools/ask-browserstack/config.d.ts +0 -14
  10. package/dist/tools/ask-browserstack/config.js +0 -16
  11. package/dist/tools/ask-browserstack/deferred.d.ts +74 -0
  12. package/dist/tools/ask-browserstack/deferred.js +242 -0
  13. package/dist/tools/ask-browserstack/register.d.ts +3 -2
  14. package/dist/tools/ask-browserstack/register.js +219 -72
  15. package/dist/tools/ask-browserstack/relay.js +11 -17
  16. package/dist/tools/ask-browserstack/stream.d.ts +27 -5
  17. package/dist/tools/ask-browserstack/stream.js +25 -14
  18. package/dist/tools/ask-browserstack/types.d.ts +54 -3
  19. package/dist/tools/ask-browserstack/types.js +11 -1
  20. package/dist/tools/automate-utils/fetch-screenshots.js +17 -15
  21. package/dist/tools/capability-registry/bind.d.ts +28 -0
  22. package/dist/tools/capability-registry/bind.js +371 -0
  23. package/dist/tools/capability-registry/config.d.ts +62 -0
  24. package/dist/tools/capability-registry/config.js +218 -0
  25. package/dist/tools/capability-registry/discovery.d.ts +44 -0
  26. package/dist/tools/capability-registry/discovery.js +99 -0
  27. package/dist/tools/capability-registry/egress.d.ts +44 -0
  28. package/dist/tools/capability-registry/egress.js +174 -0
  29. package/dist/tools/capability-registry/index-loader.d.ts +162 -0
  30. package/dist/tools/capability-registry/index-loader.js +523 -0
  31. package/dist/tools/capability-registry/redact.d.ts +34 -0
  32. package/dist/tools/capability-registry/redact.js +98 -0
  33. package/dist/tools/capability-registry/register.d.ts +48 -0
  34. package/dist/tools/capability-registry/register.js +708 -0
  35. package/dist/tools/capability-registry/resolve.d.ts +38 -0
  36. package/dist/tools/capability-registry/resolve.js +83 -0
  37. package/dist/tools/capability-registry/search.d.ts +170 -0
  38. package/dist/tools/capability-registry/search.js +709 -0
  39. package/dist/tools/capability-registry/types.d.ts +302 -0
  40. package/dist/tools/capability-registry/types.js +33 -0
  41. package/package.json +9 -3
@@ -28,10 +28,11 @@ import { z } from "zod";
28
28
  import appConfig from "../../config.js";
29
29
  import { trackMCP } from "../../lib/instrumentation.js";
30
30
  import logger from "../../logger.js";
31
- import { AskError, ELICITATION_TIMEOUT_MS, agentUrl, allowRemoteRelay, authTokenUrl, isEnabled, } from "./config.js";
31
+ import { AskError, ELICITATION_TIMEOUT_MS, agentUrl, authTokenUrl, isEnabled, } from "./config.js";
32
32
  import { fetchTokenTransport, mintCentralToken } from "./central-oauth.js";
33
33
  import { agentHeaders } from "./egress.js";
34
- import { EVENT_PERMISSION, EVENT_RESULT, EVENT_RUN, decisionUrl, fetchAgentStreamTransport, fetchDecisionTransport, parseAsk, } from "./stream.js";
34
+ import { EVENT_PERMISSION, EVENT_RESULT, EVENT_RUN, decisionUrl, RELAY_CONTRACT, fetchAgentStreamTransport, fetchDeferredTransport, parseAsk, } from "./stream.js";
35
+ import { decisionUnknown, incompleteResume, missingQuery, parkedResult, resumeFailed, runDeferred, runResumed, } from "./deferred.js";
35
36
  import { buildResult, decide, elicitationMessage, elicitationShape, errorResult, } from "./relay.js";
36
37
  import { PRODUCTS, } from "./types.js";
37
38
  /**
@@ -61,9 +62,10 @@ const DESCRIPTION =
61
62
  "predictable than handing the job to an agent. " +
62
63
  "Otherwise, describe what you want in plain language and BrowserStack's agent decides " +
63
64
  "which calls to make, then returns its answer plus the steps it took. Anything that would " +
64
- "change data pauses and asks you to confirm it first, in your own client; deletes are " +
65
- "refused outright. If your client cannot show you a prompt, the run is read-only and " +
66
- "everything it wanted to change comes back in `needs_approval` instead. One task per call.";
65
+ "change data pauses and asks you to confirm it first; deletes are refused outright. " +
66
+ "Put that confirmation to the user and answer with what THEY say — never decide on " +
67
+ "their behalf. If it cannot be put to anyone, the run is read-only and everything it " +
68
+ "wanted to change comes back in `needs_approval` instead. One task per call.";
67
69
  /**
68
70
  * `isError` marks a call that FAILED, not one that was refused.
69
71
  *
@@ -161,36 +163,6 @@ async function relayOneAsk(server, ask, approvals, relatedRequestId) {
161
163
  approvals.push({ description: ask.description, decision, reason });
162
164
  return { perm_id: ask.perm_id, decision, reason };
163
165
  }
164
- /**
165
- * Decide whether to offer the approval channel at all.
166
- *
167
- * STDIO ALWAYS. HOSTED ONLY WHEN ITS OPERATOR OPTS IN — and the reason is a property of
168
- * the HOST, not of this tool.
169
- *
170
- * Elicitation is a SERVER-INITIATED message whose answer arrives on a SEPARATE POST. A
171
- * stateless host builds a fresh `McpServer` per POST, so that answer reaches an instance
172
- * which never asked anything, while the one actually suspended on `await` waits out its
173
- * timeout. Nothing in this package can fix that; what it holds is a live Promise resolver
174
- * and a paused function in the host's heap, and a paused call cannot be moved.
175
- *
176
- * This is why `841c6358` was right to remove sessions from the hosted server on the
177
- * grounds that "we use neither server-initiated messages nor subscriptions/sampling" —
178
- * this feature is the exception that commit did not have to consider.
179
- *
180
- * MEASURED, not assumed: with the host keeping one server per session
181
- * (browserstack/remote-mcp-server#96), a tool call and its elicitation answer were served
182
- * by the same instance over hosted Streamable HTTP, and the relay completed. So the
183
- * refusal below is now conditional rather than absolute.
184
- *
185
- * It stays OFF by default because it depends on a deployment property this package cannot
186
- * observe. A hosted operator turns it on only once their host keeps sessions AND pins a
187
- * session to a pod — sessions are per-process, so without affinity the answer POST can
188
- * land on a replica that has never seen it. That failure is intermittent and reads like a
189
- * client bug, which is exactly why it must not be the default.
190
- *
191
- * When refused, Atlas runs read-only — a supported path that already works — and
192
- * `permission_relay.reason` says `remote_mode` so nobody mistakes it for a human's no.
193
- */
194
166
  /**
195
167
  * CONTRACT v2 (A1) — drive one run over the stream.
196
168
  *
@@ -208,13 +180,90 @@ async function relayOneAsk(server, ask, approvals, relatedRequestId) {
208
180
  * simply stops is indistinguishable from a network drop, and reporting it as a finished
209
181
  * run with no answer would be the transport quietly speaking for the agent.
210
182
  */
211
- async function runStreamed(server, streamTransport, decisionTransport, url, headers, body, approvals, mode, product, relatedRequestId) {
183
+ async function runStreamed(server, streamTransport, postTransport, url, headers, body, approvals, mode, product, relatedRequestId) {
212
184
  let runId = "";
213
185
  let result;
214
186
  let sawResult = false;
215
187
  // 200 unless the reply was not a stream at all, in which case the transport carries
216
188
  // the real status — `relay.ts` needs it to tell 401 from 403 from a plain failure.
217
189
  let resultStatus = 200;
190
+ let pending = null;
191
+ // Set once a decision reply carries the run, which only a converged Atlas does. When
192
+ // it is set it is the authoritative outcome and the stream's own `result` is stale.
193
+ let carried = null;
194
+ /**
195
+ * Put ONE ask to the human and deliver their answer. Shared by both loops so the two
196
+ * servers cannot drift apart in how an approval is asked for or recorded.
197
+ *
198
+ * Returns exactly one of:
199
+ * `terminal` — stop, with this result (a lost reply, or a run that is not there).
200
+ * `carried` — the reply contained the run: `next` is the following ask, if any.
201
+ * neither — a bare 204; this server continues on the stream.
202
+ */
203
+ const answerAsk = async (ask) => {
204
+ // `relayOneAsk` RETHROWS on an unexpected elicitation failure. Letting it escape
205
+ // would abandon the run and leave Atlas parked until its gate expires — turning a
206
+ // client hiccup into a five-minute stall — so it is caught and converted into the
207
+ // explicit deny the throw used to imply. `relayOneAsk` has already recorded the
208
+ // approvals entry, so only the wire decision is missing.
209
+ let decision;
210
+ try {
211
+ decision = await relayOneAsk(server, ask, approvals, relatedRequestId);
212
+ }
213
+ catch (error) {
214
+ logger.warn("askBrowserStackAI: elicitation failed, denying explicitly: %s", error instanceof Error ? error.message : String(error));
215
+ decision = { perm_id: ask.perm_id, decision: "deny", reason: "error" };
216
+ }
217
+ let response;
218
+ try {
219
+ response = await postTransport(decisionUrl(url, runId), headers, {
220
+ perm_id: decision.perm_id,
221
+ decision: decision.decision,
222
+ reason: decision.reason || "",
223
+ product,
224
+ });
225
+ }
226
+ catch {
227
+ // This request can carry the continuation, so a lost reply is NOT "nothing
228
+ // happened". Letting it reach the outer catch would report `not_reached` — which
229
+ // says nothing was changed — for a write that may well have landed.
230
+ logger.warn("askBrowserStackAI: no reply to the decision on run %s; outcome unknown", runId);
231
+ return { terminal: decisionUnknown() };
232
+ }
233
+ if (response.status === 404) {
234
+ // No such parked run: expired, already answered, or not ours. None of those is
235
+ // an approval and none of them changed anything — but a denial we could not
236
+ // deliver is not a denial either, so say which.
237
+ return {
238
+ terminal: resumeFailed("That approval could not be applied (HTTP 404). The run may have expired — " +
239
+ "BrowserStack AI stops waiting after five minutes — or it may already " +
240
+ "have been answered. Nothing was changed beyond any step you already " +
241
+ "approved."),
242
+ };
243
+ }
244
+ // 204 MEANS AN OLDER SERVER: it acknowledged the decision and is continuing on the
245
+ // stream, so there is no continuation to read here and nothing to supersede.
246
+ if (response.status === 204)
247
+ return {};
248
+ const parked = parkedResult(response.body, product);
249
+ return {
250
+ carried: true,
251
+ response,
252
+ // Another ask: the task needs more than one approval, and each is a separate
253
+ // question for the human.
254
+ next: parked
255
+ ? {
256
+ perm_id: parked.ask.perm_id,
257
+ description: parked.ask.description,
258
+ product: parked.ask.product,
259
+ mode: parked.ask.mode,
260
+ }
261
+ : null,
262
+ };
263
+ };
264
+ // PHASE 1 — the start, streamed. Kept a stream rather than folded into the decision
265
+ // loop below because a long turn needs the heartbeats: a proxy that gives up
266
+ // mid-thinking looks exactly like a hang.
218
267
  for await (const event of streamTransport(url, headers, body)) {
219
268
  if (event.event === EVENT_RUN) {
220
269
  runId = String(event.data?.run_id || "");
@@ -243,34 +292,55 @@ async function runStreamed(server, streamTransport, decisionTransport, url, head
243
292
  logger.error("askBrowserStackAI: permission ask arrived before run_id; cannot answer");
244
293
  continue;
245
294
  }
246
- // `relayOneAsk` RETHROWS on an unexpected elicitation failure. Under A2 that was
247
- // load-bearing: the throw made the inbound callback answer 500, which Atlas's
248
- // fail-closed rule read as a deny. Under A1 there is no inbound request to fail, so
249
- // letting it escape would abandon the run and leave Atlas waiting out its full 300s
250
- // gate — turning a client hiccup into a five-minute stall. So it is caught here and
251
- // converted into the explicit deny the throw used to imply. `relayOneAsk` has
252
- // already recorded the approvals entry, so only the wire decision is missing.
253
- let decision;
254
- try {
255
- decision = await relayOneAsk(server, ask, approvals, relatedRequestId);
256
- }
257
- catch (error) {
258
- logger.warn("askBrowserStackAI: elicitation failed, denying explicitly: %s", error instanceof Error ? error.message : String(error));
259
- decision = { perm_id: ask.perm_id, decision: "deny", reason: "error" };
295
+ // ANSWERED HERE, ON THE STREAM, and that placement is a compatibility decision
296
+ // rather than a preference.
297
+ //
298
+ // A converged Atlas emits its asks as the stream ENDS and carries the run forward
299
+ // on the decision request. An Atlas that predates that holds the stream OPEN
300
+ // waiting for the decision to arrive, and acknowledges it with a bare 204 before
301
+ // continuing on the same socket. Collecting asks and answering only after the
302
+ // stream closed worked against the first and DEADLOCKED against the second: both
303
+ // sides waited, until the server's own five-minute gate denied the ask and the
304
+ // user got a refusal nobody made.
305
+ //
306
+ // Answering as each ask arrives works against both, and the reply says which one
307
+ // we are talking to: a 204 carries nothing, so the run continues on the stream,
308
+ // while anything else IS the continuation and the stream is already over. That
309
+ // removes the deploy ordering constraint in both directions — this client is safe
310
+ // to publish before or after the server changes.
311
+ const answered = await answerAsk(ask);
312
+ if (answered.terminal)
313
+ return answered.terminal;
314
+ if (answered.carried) {
315
+ // The reply carried the run: a converged Atlas. Anything still on the stream is
316
+ // the tail of a response that has already been superseded by this one.
317
+ pending = answered.next ?? null;
318
+ carried = answered.response ?? null;
319
+ break;
260
320
  }
261
- const status = await decisionTransport(decisionUrl(url, runId), headers, {
262
- perm_id: decision.perm_id,
263
- decision: decision.decision,
264
- reason: decision.reason || "",
265
- });
266
- if (status !== 204) {
267
- // Never fatal, and never re-sent. Atlas's gate is still waiting and denies on its
268
- // own expiry, so a lost decision is safe — it can only cost an approval, never
269
- // grant one. Retrying risks the opposite: a duplicate that 409s, or worse, an
270
- // approval applied to a step the run has already moved past.
271
- logger.warn("askBrowserStackAI: decision for %s was not accepted (HTTP %s)", decision.perm_id, status);
321
+ // A bare 204: this server continues on the stream, so keep reading it.
322
+ }
323
+ // PHASE 2 — only reachable against a converged Atlas, where the decision reply IS the
324
+ // run. Each further ask comes back in a reply rather than on the stream, so the
325
+ // prompt-and-post pair repeats here instead of in the loop above.
326
+ while (pending) {
327
+ const answered = await answerAsk(pending);
328
+ if (answered.terminal)
329
+ return answered.terminal;
330
+ if (!answered.carried) {
331
+ // A 204 cannot arise here: only a converged Atlas reaches phase 2, and it always
332
+ // replies with the continuation. Treated as a run that ended without saying
333
+ // anything rather than assumed away.
334
+ break;
272
335
  }
336
+ pending = answered.next ?? null;
337
+ carried = answered.response ?? null;
273
338
  }
339
+ // The decision reply supersedes the stream's own `result`, which said "needs
340
+ // approval" — true when it was written, and the question it asked has since been
341
+ // answered.
342
+ if (carried)
343
+ return buildResult(carried, approvals, mode, product);
274
344
  if (!sawResult) {
275
345
  return errorResult("BrowserStack AI ended the run without a result. Nothing was changed " +
276
346
  "beyond any step you already approved.", approvals);
@@ -280,13 +350,35 @@ async function runStreamed(server, streamTransport, decisionTransport, url, head
280
350
  return buildResult({ status: resultStatus, body: result }, approvals, mode, product);
281
351
  }
282
352
  export function relayMode(server) {
283
- // The hosted deployment refuses UNLESS its operator has opted in, because whether an
284
- // elicitation can be answered there depends on the host keeping one server alive per
285
- // session — see `allowRemoteRelay`. Verified working against the hosted Streamable
286
- // HTTP server once it does (browserstack/remote-mcp-server#96).
287
- if (appConfig.REMOTE_MCP && !allowRemoteRelay())
288
- return "remote_mode";
289
- // The real gate either way: can THIS client be asked? A client that never declared
353
+ // An explicit transport wins over whatever the deployment implies. `REMOTE_MCP`
354
+ // cannot select `deferred` from a local stdio install — that flag makes this
355
+ // entrypoint exit, because the hosted deployment runs remote-mcp-server instead — so
356
+ // without this there is no way to exercise the two-call flow anywhere it can be
357
+ // watched. "elicit" forces the other direction for the same reason.
358
+ const forced = appConfig.ASK_BROWSERSTACK_RELAY_TRANSPORT;
359
+ if (forced === "deferred")
360
+ return "deferred";
361
+ if (forced !== "elicit" && appConfig.REMOTE_MCP) {
362
+ // DEFERRED, unconditionally, and not because elicitation would be worse here — it
363
+ // would be better. It is what makes this process stateful: a suspended
364
+ // `elicitation/create` pins an McpServer in one pod's heap until the answer
365
+ // arrives, so the hosted deployment would have to keep sessions, pin requests to a
366
+ // pod and cap how many can be alive. Deferred removes the suspension, and with it
367
+ // the reason any of that exists.
368
+ //
369
+ // THERE IS NO OPT-IN ANY MORE. `ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY` gated this
370
+ // while relaying meant elicitation — it was a guard against switching on the
371
+ // stateful thing, and that thing is gone. Two better switches remain: this tool's
372
+ // own `ASK_BROWSERSTACK_DISABLED`, which is read per call and so does not need a
373
+ // pod roll, and Atlas's `delegation.permission_relay`, which turns the relay off
374
+ // for every client at once from a config change.
375
+ return "deferred";
376
+ }
377
+ // stdio keeps elicitation, deliberately: one process, nothing to pin, and the client
378
+ // renders the prompt so the model cannot fabricate the answer. Nothing is gained by
379
+ // making a local install pay the two-call cost.
380
+ //
381
+ // The real gate here: can THIS client be asked? A client that never declared
290
382
  // `elicitation` gets a read-only run whatever the deployment.
291
383
  return server.server.getClientCapabilities()?.elicitation
292
384
  ? "offered"
@@ -298,7 +390,9 @@ export function addAskBrowserStackAITool(server, deps, config) {
298
390
  // JSON, the parser sees no `text/event-stream`, and the run degrades to a read-only
299
391
  // answer carrying that response's own status.
300
392
  const streamTransport = deps.streamTransport || fetchAgentStreamTransport();
301
- const decisionTransport = deps.decisionTransport || fetchDecisionTransport();
393
+ // Resolved here with the others: one place decides which transports this
394
+ // registration uses, so a test substituting one is not surprised by another.
395
+ const deferredTransport = deps.deferredTransport || fetchDeferredTransport();
302
396
  const tools = {};
303
397
  /** Instrumentation in the house style, and never fatal to the call it wraps. */
304
398
  const track = (name) => {
@@ -314,9 +408,35 @@ export function addAskBrowserStackAITool(server, deps, config) {
314
408
  .enum(PRODUCTS)
315
409
  .describe("Which product to work in: tm (Test Management), " +
316
410
  "tra (Test Reporting & Analytics)."),
411
+ // OPTIONAL, because a resume does not carry one. `next_step` tells the model to
412
+ // call again "with exactly these params" — `product`, `run_id`, `perm_id`,
413
+ // `decision` — and a required `query` made following that instruction literally
414
+ // fail schema validation before the handler ever ran. It is still required to
415
+ // START a run; the handler enforces that, where a useful message can be given
416
+ // instead of an InvalidParams from the SDK.
317
417
  query: z
318
418
  .string()
419
+ .optional()
319
420
  .describe("What you want, in plain language. One thing per call."),
421
+ // --- resuming a parked run (the hosted, deferred path only) ---
422
+ //
423
+ // Both together or neither. They exist because that deployment cannot hold a
424
+ // prompt open mid-run: it returns the pending approval instead, and the caller
425
+ // comes back. On a local stdio install these are never needed — the prompt
426
+ // appears inside the first call — so they are optional and unmentioned in the
427
+ // description's main flow.
428
+ run_id: z
429
+ .string()
430
+ .optional()
431
+ .describe("`run_id` from a needs_approval result."),
432
+ perm_id: z
433
+ .string()
434
+ .optional()
435
+ .describe("`perm_id` from that same result."),
436
+ decision: z
437
+ .enum(["allow", "deny"])
438
+ .optional()
439
+ .describe("The user's answer. Never assume allow — ask them."),
320
440
  }, {
321
441
  // It can write now, which is the whole point of the relay. Destructive operations
322
442
  // stay refused, so `destructiveHint` is false for the same reason invokeEndpoint
@@ -329,7 +449,7 @@ export function addAskBrowserStackAITool(server, deps, config) {
329
449
  openWorldHint: true,
330
450
  idempotentHint: false,
331
451
  title: "Ask BrowserStack AI (Alpha)",
332
- }, async ({ product, query }, extra) => {
452
+ }, async ({ product, query, run_id, perm_id, decision }, extra) => {
333
453
  track("askBrowserStackAI");
334
454
  const approvals = [];
335
455
  // Negotiated before anything else so the failure paths below report the mode they
@@ -341,6 +461,27 @@ export function addAskBrowserStackAITool(server, deps, config) {
341
461
  // lazily mid-call would put a token round-trip inside the window where a human is
342
462
  // being prompted, and a mint that failed there would strand an open port.
343
463
  const headers = agentHeaders(await deps.mintToken());
464
+ // RESUMING a parked run: no new run, no task — just the decision and the
465
+ // remainder. FIRST, because the arguments are mutually exclusive with starting
466
+ // one and a caller that sent both meant to resume — and because `query` is not
467
+ // required on this path, so nothing above may depend on it.
468
+ if (run_id) {
469
+ if (!perm_id || !decision) {
470
+ return toResult(incompleteResume());
471
+ }
472
+ // A `query` sent here is ignored rather than rejected: the run's task is in
473
+ // Atlas's checkpoint, so a model that resent it is redundant, not wrong.
474
+ return toResult(await runResumed(url, headers, run_id, perm_id, decision,
475
+ // The SAME transport the start uses: since the converged gate both calls
476
+ // POST and read back either a parked run or a finished one.
477
+ deferredTransport, mode, product));
478
+ }
479
+ // STARTING one, which is the only path that needs a task. Enforced here rather
480
+ // than in the schema so the model gets a sentence it can act on instead of an
481
+ // SDK InvalidParams — and so `next_step`, which omits `query`, stays valid.
482
+ if (!query || !query.trim()) {
483
+ return toResult(missingQuery());
484
+ }
344
485
  const body = { task: query, product };
345
486
  // Attribution, and now belt-and-braces rather than the source of truth: the minted
346
487
  // JWT is user-attested, so Atlas sets `principal_verified=True` and takes the acting
@@ -351,12 +492,18 @@ export function addAskBrowserStackAITool(server, deps, config) {
351
492
  const username = (deps.credentialsFor().username || "").trim();
352
493
  if (username)
353
494
  body.user_id = username;
495
+ // DEFERRED: the hosted shape. One request, one JSON answer, nothing held open
496
+ // while a human decides — see `deferred.ts` for why that matters here and why
497
+ // stdio does not use it.
498
+ if (mode === "deferred") {
499
+ return toResult(await runDeferred(url, headers, body, deferredTransport, mode, product));
500
+ }
354
501
  // A1: asking for a stream costs nothing to set up — no port, no listener, no
355
502
  // per-run bearer, because nothing dials in. Which is the whole point: the
356
503
  // callback this replaces could never reach a laptop behind NAT, so the feature
357
504
  // was read-only for every real user regardless of what was configured.
358
505
  if (mode === "offered") {
359
- body.permission_relay = { mode: "stream" };
506
+ body.permission_relay = { mode: "stream", contract: RELAY_CONTRACT };
360
507
  }
361
508
  else {
362
509
  // Omitted ENTIRELY, not sent empty: its absence is what selects Atlas's
@@ -366,7 +513,7 @@ export function addAskBrowserStackAITool(server, deps, config) {
366
513
  // `product` reaches the result so an entitlement refusal can name it: the flags
367
514
  // are per product, and a bare "not enabled" sends the user to their admin
368
515
  // asking about the wrong thing.
369
- return toResult(await runStreamed(server, streamTransport, decisionTransport, url, headers, body, approvals, mode, product,
516
+ return toResult(await runStreamed(server, streamTransport, deferredTransport, url, headers, body, approvals, mode, product,
370
517
  // The tool call's own id, so each elicitation is routed onto THIS request's
371
518
  // stream. Over Streamable HTTP there is nowhere else for it to go.
372
519
  extra?.requestId));
@@ -48,13 +48,6 @@ export const RELAY_OFF_DETAILS = {
48
48
  malformed: "BrowserStack could not use the approval channel this client offered and ran read-only. " +
49
49
  "That is a bug on this side, not something you did; everything in `needs_approval` was " +
50
50
  "refused because of it.",
51
- // Neither the human nor the client is the constraint here — the DEPLOYMENT is, and no
52
- // change either of them can make will help.
53
- remote_mode: "NOBODY DECLINED THIS, AND YOUR CLIENT IS NOT THE PROBLEM. This BrowserStack MCP server " +
54
- "is running in its hosted, multi-tenant mode, which has no way to put an approval " +
55
- "prompt in front of you, so it ran read-only and everything in `needs_approval` was " +
56
- "refused for that reason alone. Mid-run approval works when the server runs locally over stdio; retrying " +
57
- "against this deployment will keep failing the same way.",
58
51
  // Not a refusal by anyone and not a relay problem at all: the account is not on the
59
52
  // product's agent flag. The product-specific sentence and what to do about it live in
60
53
  // `error`, so this one points there rather than duplicating the plumbing.
@@ -63,6 +56,17 @@ export const RELAY_OFF_DETAILS = {
63
56
  "refused before the agent started. `error` names the product. This is an entitlement on " +
64
57
  "the account, not a problem with your credentials and not a decision anyone made about " +
65
58
  "your request.",
59
+ // THE ONE CASE WHERE WE DO NOT KNOW. The decision was put on the wire and the reply
60
+ // never came back — a timeout, or a proxy cutting an idle connection. Since the
61
+ // converged gate the decision request carries the whole continuation, so "no reply"
62
+ // spans everything from "it never arrived" to "the write succeeded and we lost the
63
+ // answer". `not_reached` claims the first of those, which is exactly the claim that
64
+ // gets a write done twice, so this exists to stop the two being collapsed.
65
+ outcome_unknown: "THE ANSWER WAS DELIVERED BUT ITS OUTCOME IS UNKNOWN. The approval reached " +
66
+ "BrowserStack and the reply was lost before it came back, so the step may or may " +
67
+ "not have completed. DO NOT repeat this task — a retry could perform the same " +
68
+ "change a second time. Check the state in the product, and only then decide " +
69
+ "whether anything still needs doing.",
66
70
  // The request never got as far as the agent. Distinct from `disabled` (the agent ran, with
67
71
  // the relay switched off) and from a decline (someone was asked and said no), because the
68
72
  // three call for completely different things from whoever reads them.
@@ -413,16 +417,6 @@ function relayVerdict(payload, mode, reachedAgent, notEntitled) {
413
417
  detail: RELAY_OFF_DETAILS.not_reached,
414
418
  };
415
419
  }
416
- // BEFORE `no_human`, deliberately, when both are true. In the hosted deployment even a
417
- // client that CAN be prompted is of no use, so the deployment is the binding constraint and
418
- // the one the reader can act on; telling them to switch clients would waste their time.
419
- if (mode === "remote_mode") {
420
- return {
421
- used: false,
422
- reason: "remote_mode",
423
- detail: RELAY_OFF_DETAILS.remote_mode,
424
- };
425
- }
426
420
  if (mode === "no_human") {
427
421
  // CONTRACT §7's last row: no elicitation capability means the field was never sent.
428
422
  return {
@@ -48,6 +48,14 @@ export declare const WHOLE_RUN_TIMEOUT_MS = 1800000;
48
48
  export declare const EVENT_RUN = "run";
49
49
  export declare const EVENT_PERMISSION = "permission";
50
50
  export declare const EVENT_RESULT = "result";
51
+ /**
52
+ * The wire version this client speaks, sent in `permission_relay`. See
53
+ * `PermissionRelay.contract` for why Atlas cannot infer it.
54
+ *
55
+ * Bump only when a change leaves an older client unable to COMPLETE the exchange —
56
+ * not merely unaware of a new field.
57
+ */
58
+ export declare const RELAY_CONTRACT = 2;
51
59
  /** Atlas's `f"perm-{uuid.uuid4().hex}"`, and nothing else. */
52
60
  export declare const PERM_ID_PATTERN: RegExp;
53
61
  /**
@@ -75,12 +83,15 @@ export declare function parseAsk(data: unknown): PermissionAsk | null;
75
83
  * was: the tests must be able to drive a whole approval round trip without a socket.
76
84
  */
77
85
  export type AgentStreamTransport = (url: string, headers: Record<string, string>, body: AgentRequest) => AsyncIterable<StreamEvent>;
78
- /** Posts one decision. Separate seam because it is a separate connection. */
79
- export type DecisionTransport = (url: string, headers: Record<string, string>, body: {
86
+ /** What a decision POST carries. `product` is required: the run comes back on a fresh
87
+ * Agent that has to be scoped to a harness before it can be built, and scoping is the
88
+ * one thing that must not be re-derived from persisted state. */
89
+ export interface DecisionBody {
80
90
  perm_id: string;
81
91
  decision: string;
82
92
  reason: string;
83
- }) => Promise<number>;
93
+ product: string;
94
+ }
84
95
  /**
85
96
  * Split a buffer into complete SSE frames, returning the leftover.
86
97
  *
@@ -110,7 +121,18 @@ export declare function parseFrame(frame: string): StreamEvent | null;
110
121
  * is unchanged and still enforced where it belongs.
111
122
  */
112
123
  export declare function fetchAgentStreamTransport(timeoutMs?: number): AgentStreamTransport;
113
- /** The decision POST. 30s, because it is an ordinary short request. */
114
- export declare function fetchDecisionTransport(timeoutMs?: number): DecisionTransport;
115
124
  /** `POST /agent/{run_id}/permission`, built from the base URL the tool already resolved. */
116
125
  export declare function decisionUrl(agentUrl: string, runId: string): string;
126
+ /**
127
+ * One POST, one JSON response, nothing held open.
128
+ *
129
+ * Used for BOTH calls of the two-call shape — the start (`POST /agent`) and every
130
+ * decision (`POST /agent/{run_id}/permission`) — because since the converged gate they
131
+ * answer the same thing: a parked run, or a finished one. A separate seam from the
132
+ * streaming transport because it is a different kind of call, not a different URL.
133
+ */
134
+ export type DeferredTransport = (url: string, headers: Record<string, string>, body: AgentRequest | DecisionBody) => Promise<{
135
+ status: number;
136
+ body: unknown;
137
+ }>;
138
+ export declare function fetchDeferredTransport(timeoutMs?: number): DeferredTransport;
@@ -35,6 +35,14 @@ export const WHOLE_RUN_TIMEOUT_MS = 1_800_000;
35
35
  export const EVENT_RUN = "run";
36
36
  export const EVENT_PERMISSION = "permission";
37
37
  export const EVENT_RESULT = "result";
38
+ /**
39
+ * The wire version this client speaks, sent in `permission_relay`. See
40
+ * `PermissionRelay.contract` for why Atlas cannot infer it.
41
+ *
42
+ * Bump only when a change leaves an older client unable to COMPLETE the exchange —
43
+ * not merely unaware of a new field.
44
+ */
45
+ export const RELAY_CONTRACT = 2;
38
46
  /** Atlas's `f"perm-{uuid.uuid4().hex}"`, and nothing else. */
39
47
  export const PERM_ID_PATTERN = /^perm-[0-9a-f]{32}$/;
40
48
  /**
@@ -206,13 +214,21 @@ export function fetchAgentStreamTransport(timeoutMs = WHOLE_RUN_TIMEOUT_MS) {
206
214
  };
207
215
  };
208
216
  }
209
- /** The decision POST. 30s, because it is an ordinary short request. */
210
- export function fetchDecisionTransport(timeoutMs = 30_000) {
217
+ /** `POST /agent/{run_id}/permission`, built from the base URL the tool already resolved. */
218
+ export function decisionUrl(agentUrl, runId) {
219
+ return `${agentUrl.replace(/\/+$/, "")}/${encodeURIComponent(runId)}/permission`;
220
+ }
221
+ // Bounded by the WHOLE RUN, like the streaming transport, because that is what these
222
+ // requests now contain. 120s was sized for a request that returned as soon as the run
223
+ // parked; since the converged gate the DECISION request carries everything after the
224
+ // approval — the write and the rest of the task — so the old bound cut off successful
225
+ // runs partway and reported them as unreachable.
226
+ export function fetchDeferredTransport(timeoutMs = WHOLE_RUN_TIMEOUT_MS) {
211
227
  return async (url, headers, body) => {
212
228
  try {
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.
229
+ // `raise_error: false` for the same reason the decision transport does it: the
230
+ // caller reads the STATUS, and a thrown AxiosError would collapse a 403
231
+ // (not entitled) and a 503 (delegation off) into the unreachable case below.
216
232
  const response = await apiClient.post({
217
233
  url,
218
234
  headers,
@@ -220,17 +236,12 @@ export function fetchDecisionTransport(timeoutMs = 30_000) {
220
236
  timeout: timeoutMs,
221
237
  raise_error: false,
222
238
  });
223
- return response.status;
239
+ return { status: response.status, body: response.data };
224
240
  }
225
241
  catch {
226
- // The gate on the far side is still waiting and will deny on its own expiry, so
227
- // a lost decision is safe — it is never an approval. 0 says "never delivered" so
228
- // the caller can say that rather than implying a human refused.
229
- return 0;
242
+ // Same sentence as the streaming transport, and for the same reason: the upstream
243
+ // detail names our plumbing rather than anything the reader can act on.
244
+ throw new AskError("BrowserStack AI could not be reached");
230
245
  }
231
246
  };
232
247
  }
233
- /** `POST /agent/{run_id}/permission`, built from the base URL the tool already resolved. */
234
- export function decisionUrl(agentUrl, runId) {
235
- return `${agentUrl.replace(/\/+$/, "")}/${encodeURIComponent(runId)}/permission`;
236
- }
@@ -57,6 +57,16 @@ export interface PermissionDecision {
57
57
  */
58
58
  export interface PermissionRelay {
59
59
  mode?: string;
60
+ /**
61
+ * The wire this client speaks. Atlas reads an absent or lower value as a build that
62
+ * CANNOT complete an approval and refuses to park rather than offering one.
63
+ *
64
+ * Declared, not inferred, because the two are identical on the way in: this client's
65
+ * stdio path sends `{"mode": "stream"}` exactly as 1.5.1 does. Measured against
66
+ * 1.5.1 talking to a converged Atlas — it answers a decision without `product`, gets
67
+ * a 400, logs it to stderr and then reports `ok: true` for a run that did nothing.
68
+ */
69
+ contract?: number;
60
70
  }
61
71
  /**
62
72
  * CONTRACT §1 — the `POST /agent` body.
@@ -122,9 +132,23 @@ export type RelayMode =
122
132
  "offered"
123
133
  /** The client declares no `elicitation` capability, so nobody could be prompted. */
124
134
  | "no_human"
125
- /** This process is the hosted multi-tenant server, which cannot prompt a human. */
126
- | "remote_mode";
127
- export declare const ASK_STATUSES: readonly ["ok", "blocked", "error", "rate_limited"];
135
+ /**
136
+ * The hosted server, relaying WITHOUT holding anything open.
137
+ *
138
+ * Elicitation is what makes this process stateful: a suspended `elicitation/create`
139
+ * pins an McpServer in one pod's heap until the answer arrives, which is why the
140
+ * hosted deployment needs sessions, ingress affinity and a session cap. `deferred`
141
+ * removes the suspension instead of routing around it — Atlas returns at its first
142
+ * ask, this tool hands that ask back, and the caller returns with the decision in a
143
+ * second call. Nothing is held between the two, so any pod can serve either.
144
+ *
145
+ * The trade, stated because it is real: approval rides the CLIENT's own permission
146
+ * prompt on that second call rather than an elicitation, so a client configured not
147
+ * to prompt (`--permission-mode auto`) approves without a human. Elicitation fails
148
+ * closed by construction; this fails closed only if the client asks.
149
+ */
150
+ | "deferred";
151
+ export declare const ASK_STATUSES: readonly ["ok", "blocked", "error", "rate_limited", "needs_approval"];
128
152
  export type AskStatus = (typeof ASK_STATUSES)[number];
129
153
  /** CONTRACT §5 — the single tool result. */
130
154
  export interface AskResult {
@@ -185,6 +209,33 @@ export interface AskResult {
185
209
  * side would otherwise silently become `null` here rather than reaching the caller.
186
210
  */
187
211
  atlas_response: unknown;
212
+ /**
213
+ * Deferred only: the parked run, and the ask waiting on it.
214
+ *
215
+ * Present exactly when `status === "needs_approval"`. Both are echoed back on the
216
+ * caller's second call — `perm_id` alongside `run_id` so the answer names WHICH ask it
217
+ * answers, which matters the moment a task needs more than one.
218
+ */
219
+ run_id?: string;
220
+ perm_id?: string;
221
+ /**
222
+ * Deferred only: what the caller must do next, and with what.
223
+ *
224
+ * Carried as its own field rather than buried in prose because the model reads this
225
+ * result and decides what to do from it. The first version explained the TRANSPORT
226
+ * ("this deployment cannot hold a prompt open…") which is true and useless: it told
227
+ * the model how the plumbing works, not that a person has to answer. Measured
228
+ * consequence — the model read the ask, judged it matched the user's request, and
229
+ * answered `allow` itself in the same turn without asking anyone.
230
+ *
231
+ * This cannot MAKE a model ask; nothing in the protocol can, which is the standing
232
+ * argument for elicitation. It removes the excuse.
233
+ */
234
+ next_step?: {
235
+ instruction: string;
236
+ call: string;
237
+ params: Record<string, string>;
238
+ };
188
239
  /**
189
240
  * Why the call failed, when it did.
190
241
  *