@browserstack/mcp-server 2.0.0 → 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.
package/dist/config.d.ts CHANGED
@@ -11,10 +11,35 @@ 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
- readonly ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY: boolean;
15
14
  readonly ASK_BROWSERSTACK_ATLAS_URL: string | undefined;
16
15
  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);
16
+ /**
17
+ * Which relay transport to use, overriding what the deployment implies.
18
+ *
19
+ * `relayMode` otherwise derives this from `REMOTE_MCP`, which conflates two
20
+ * different questions: "am I the hosted process" and "can I hold a connection open
21
+ * across a human". Only the second decides the transport, and they come apart the
22
+ * moment anyone wants to exercise the deferred, two-call flow from a local stdio
23
+ * install — `REMOTE_MCP=true` cannot do it, because this entrypoint exits on that
24
+ * flag by design (the hosted deployment runs remote-mcp-server instead).
25
+ *
26
+ * "deferred" | "elicit". Anything else is ignored.
27
+ */
28
+ readonly ASK_BROWSERSTACK_RELAY_TRANSPORT: string | undefined;
29
+ 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_ATLAS_URL: string | undefined, ASK_BROWSERSTACK_AUTH_TOKEN_URL: string | undefined,
30
+ /**
31
+ * Which relay transport to use, overriding what the deployment implies.
32
+ *
33
+ * `relayMode` otherwise derives this from `REMOTE_MCP`, which conflates two
34
+ * different questions: "am I the hosted process" and "can I hold a connection open
35
+ * across a human". Only the second decides the transport, and they come apart the
36
+ * moment anyone wants to exercise the deferred, two-call flow from a local stdio
37
+ * install — `REMOTE_MCP=true` cannot do it, because this entrypoint exits on that
38
+ * flag by design (the hosted deployment runs remote-mcp-server instead).
39
+ *
40
+ * "deferred" | "elicit". Anything else is ignored.
41
+ */
42
+ ASK_BROWSERSTACK_RELAY_TRANSPORT: string | undefined);
18
43
  }
19
44
  declare const config: Config;
20
45
  export default config;
package/dist/config.js CHANGED
@@ -47,9 +47,9 @@ export class Config {
47
47
  O11Y_TFA_RCA_BASE_URL;
48
48
  BROWSERSTACK_AUTOMATION_BASE_URL;
49
49
  BROWSERSTACK_O11Y_UI_BASE_URL;
50
- ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY;
51
50
  ASK_BROWSERSTACK_ATLAS_URL;
52
51
  ASK_BROWSERSTACK_AUTH_TOKEN_URL;
52
+ ASK_BROWSERSTACK_RELAY_TRANSPORT;
53
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
54
  // askBrowserStackAI's process-startup settings. Declared here rather than read from
55
55
  // process.env inside src/tools/, per rules/tool-design.md — and so the remote wrapper,
@@ -58,7 +58,20 @@ export class Config {
58
58
  // ASK_BROWSERSTACK_DISABLED is deliberately NOT here: it is a kill switch, and reading
59
59
  // it per call keeps it effective without a restart. Fixing it at boot would mean a pod
60
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) {
61
+ ASK_BROWSERSTACK_ATLAS_URL, ASK_BROWSERSTACK_AUTH_TOKEN_URL,
62
+ /**
63
+ * Which relay transport to use, overriding what the deployment implies.
64
+ *
65
+ * `relayMode` otherwise derives this from `REMOTE_MCP`, which conflates two
66
+ * different questions: "am I the hosted process" and "can I hold a connection open
67
+ * across a human". Only the second decides the transport, and they come apart the
68
+ * moment anyone wants to exercise the deferred, two-call flow from a local stdio
69
+ * install — `REMOTE_MCP=true` cannot do it, because this entrypoint exits on that
70
+ * flag by design (the hosted deployment runs remote-mcp-server instead).
71
+ *
72
+ * "deferred" | "elicit". Anything else is ignored.
73
+ */
74
+ ASK_BROWSERSTACK_RELAY_TRANSPORT) {
62
75
  this.DEV_MODE = DEV_MODE;
63
76
  this.browserstackLocalOptions = browserstackLocalOptions;
64
77
  this.USE_OWN_LOCAL_BINARY_PROCESS = USE_OWN_LOCAL_BINARY_PROCESS;
@@ -67,9 +80,9 @@ export class Config {
67
80
  this.O11Y_TFA_RCA_BASE_URL = O11Y_TFA_RCA_BASE_URL;
68
81
  this.BROWSERSTACK_AUTOMATION_BASE_URL = BROWSERSTACK_AUTOMATION_BASE_URL;
69
82
  this.BROWSERSTACK_O11Y_UI_BASE_URL = BROWSERSTACK_O11Y_UI_BASE_URL;
70
- this.ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY = ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY;
71
83
  this.ASK_BROWSERSTACK_ATLAS_URL = ASK_BROWSERSTACK_ATLAS_URL;
72
84
  this.ASK_BROWSERSTACK_AUTH_TOKEN_URL = ASK_BROWSERSTACK_AUTH_TOKEN_URL;
85
+ this.ASK_BROWSERSTACK_RELAY_TRANSPORT = ASK_BROWSERSTACK_RELAY_TRANSPORT;
73
86
  }
74
87
  }
75
88
  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
@@ -83,12 +96,12 @@ const config = new Config(process.env.DEV_MODE === "true", browserstackLocalOpti
83
96
  : DEFAULT_BROWSERSTACK_AUTOMATION_BASE_URL, process.env.BROWSERSTACK_O11Y_UI_BASE_URL &&
84
97
  process.env.BROWSERSTACK_O11Y_UI_BASE_URL.length > 0
85
98
  ? process.env.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 &&
99
+ : DEFAULT_BROWSERSTACK_O11Y_UI_BASE_URL, process.env.ASK_BROWSERSTACK_ATLAS_URL &&
88
100
  process.env.ASK_BROWSERSTACK_ATLAS_URL.trim().length > 0
89
101
  ? process.env.ASK_BROWSERSTACK_ATLAS_URL
90
102
  : undefined, process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL &&
91
103
  process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL.trim().length > 0
92
104
  ? process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL
93
- : undefined);
105
+ : undefined, (process.env.ASK_BROWSERSTACK_RELAY_TRANSPORT || "").trim().toLowerCase() ||
106
+ undefined);
94
107
  export default config;
package/dist/lib/utils.js CHANGED
@@ -1,11 +1,13 @@
1
1
  import { getBrowserStackAuth } from "./get-auth.js";
2
2
  import { trackMCP } from "../index.js";
3
3
  import logger from "../logger.js";
4
+ import globalConfig from "../config.js";
4
5
  export function sanitizeUrlParam(param) {
5
6
  // Remove any characters that could be used for command injection
6
7
  return param.replace(/[;&|`$(){}[\]<>]/g, "");
7
8
  }
8
9
  const ONE_MB = 1048576;
10
+ let sharpConfigured = false;
9
11
  //Compresses a base64 image intelligently to keep it under 1 MB if needed.
10
12
  export async function maybeCompressBase64(base64) {
11
13
  const buffer = Buffer.from(base64, "base64");
@@ -17,6 +19,12 @@ export async function maybeCompressBase64(base64) {
17
19
  const quality = Math.min(95, Math.max(30, estimatedQuality));
18
20
  try {
19
21
  const { default: sharp } = await import("sharp");
22
+ if (!sharpConfigured && globalConfig.REMOTE_MCP) {
23
+ // Bound memory on constrained hosted pods: no libvips cache, single worker thread.
24
+ sharp.cache(false);
25
+ sharp.concurrency(1);
26
+ sharpConfigured = true;
27
+ }
20
28
  const compressedBuffer = await sharp(buffer).png({ quality }).toBuffer();
21
29
  return compressedBuffer.toString("base64");
22
30
  }
@@ -30,20 +30,6 @@ export declare class AskError extends Error {
30
30
  * other three are on the config singleton (rules/tool-design.md).
31
31
  */
32
32
  export declare function isEnabled(): boolean;
33
- /**
34
- * May the relay be offered in the hosted (`REMOTE_MCP`) deployment?
35
- *
36
- * OFF BY DEFAULT, because it depends on something outside this package: the host has to
37
- * keep one `McpServer` alive per session. Stateless hosts build a fresh server per POST,
38
- * and an elicitation answer — which arrives as a SEPARATE POST — then reaches an instance
39
- * that never asked anything, leaving the real one suspended until it times out. So this
40
- * must stay opt-in per deployment rather than become a default that silently hangs.
41
- *
42
- * Turning it on does NOT force the relay on: `relayMode` still asks whether THIS client
43
- * declared the `elicitation` capability, and a client that did not still gets a read-only
44
- * run. This flag only removes the blanket refusal.
45
- */
46
- export declare function allowRemoteRelay(): boolean;
47
33
  /**
48
34
  * ============================================================================
49
35
  * PRODUCTION DEFAULTS
@@ -34,22 +34,6 @@ export class AskError extends Error {
34
34
  export function isEnabled() {
35
35
  return (process.env.ASK_BROWSERSTACK_DISABLED || "").toLowerCase() !== "true";
36
36
  }
37
- /**
38
- * May the relay be offered in the hosted (`REMOTE_MCP`) deployment?
39
- *
40
- * OFF BY DEFAULT, because it depends on something outside this package: the host has to
41
- * keep one `McpServer` alive per session. Stateless hosts build a fresh server per POST,
42
- * and an elicitation answer — which arrives as a SEPARATE POST — then reaches an instance
43
- * that never asked anything, leaving the real one suspended until it times out. So this
44
- * must stay opt-in per deployment rather than become a default that silently hangs.
45
- *
46
- * Turning it on does NOT force the relay on: `relayMode` still asks whether THIS client
47
- * declared the `elicitation` capability, and a client that did not still gets a read-only
48
- * run. This flag only removes the blanket refusal.
49
- */
50
- export function allowRemoteRelay() {
51
- return appConfig.ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY;
52
- }
53
37
  /**
54
38
  * ============================================================================
55
39
  * PRODUCTION DEFAULTS
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The two-call shape: ask now, decide later, with nothing held open in between.
3
+ *
4
+ * WHY IT EXISTS. Elicitation is what makes this process stateful — a suspended
5
+ * `elicitation/create` pins an McpServer in one pod's heap until the answer arrives, so
6
+ * the hosted deployment needs sessions, ingress affinity keyed on the caller's
7
+ * credentials, and a cap on how many sessions may be alive. Deferred removes the
8
+ * suspension rather than routing around it: Atlas returns at its first ask, we hand that
9
+ * ask back, and the caller returns with the decision in a SECOND call. Between the two
10
+ * this process holds nothing, so any pod can serve either.
11
+ *
12
+ * WHAT IT COSTS, stated plainly because it is not free:
13
+ * - Approval rides the CLIENT's own permission prompt on the second call, not an
14
+ * elicitation. A client configured not to prompt (`--permission-mode auto`) approves
15
+ * with no human. Elicitation fails closed by construction; this fails closed only if
16
+ * the client asks. That is the trade, and it is why stdio keeps elicitation.
17
+ * - One extra round trip per APPROVAL, not per task. Atlas's gate is serial, so a task
18
+ * with five writes is five approvals and six calls.
19
+ *
20
+ * The run itself is parked on Atlas's side for the gate's own 300s, kept alive exactly as
21
+ * a dropped reader leaves it (CONTRACT v2 §4.2). Nothing here has to manage that.
22
+ */
23
+ import { DeferredTransport } from "./stream.js";
24
+ import { AgentRequest, AskResult, RelayMode } from "./types.js";
25
+ /** What a parked run needs the caller to send back. */
26
+ export interface ParkedAsk {
27
+ perm_id: string;
28
+ description: string;
29
+ product: string;
30
+ mode: string;
31
+ }
32
+ /**
33
+ * Atlas's `needs_approval` payload → our result, or `null` when it is not one.
34
+ *
35
+ * Null rather than a throw: a deferred request whose run never needed approval comes
36
+ * back as an ORDINARY result, and that is a success to pass straight through — not an
37
+ * unexpected shape. A read-only task must cost one call, not two.
38
+ */
39
+ export declare function parkedResult(body: unknown, product: string): {
40
+ runId: string;
41
+ ask: ParkedAsk;
42
+ } | null;
43
+ /** CONTRACT §5's shape, for a run that is parked rather than finished. */
44
+ export declare function needsApprovalResult(runId: string, ask: ParkedAsk, product: string): AskResult;
45
+ /** The FIRST call: run until the first ask, then answer. Nothing is held open. */
46
+ export declare function runDeferred(url: string, headers: Record<string, string>, body: AgentRequest, transport: DeferredTransport, mode: RelayMode, product: string): Promise<AskResult>;
47
+ /**
48
+ * The SECOND call: deliver the decision, then pick the run back up.
49
+ *
50
+ * Two requests, in this order, and the order is the safety property: the decision is
51
+ * posted FIRST so that a reattach which then fails cannot leave the gate waiting on an
52
+ * answer we already have. A lost reattach costs the caller the answer; a lost decision
53
+ * would cost them the run.
54
+ */
55
+ export declare function runResumed(agentUrl: string, headers: Record<string, string>, runId: string, permId: string, decision: "allow" | "deny", transport: DeferredTransport, mode: RelayMode, product: string): Promise<AskResult>;
56
+ /** A resume that did not happen. Every field says "nothing changed", because in each
57
+ * case nothing did — and a caller that cannot tell a failed resume from a refusal
58
+ * either retries a write that landed or abandons one that never did. */
59
+ export declare function resumeFailed(error: string): AskResult;
60
+ /** A decision that went out and whose reply never came back.
61
+ *
62
+ * Deliberately NOT `resumeFailed`: every field there says "nothing changed", and that
63
+ * is a claim this case cannot make. The decision request carries the continuation now,
64
+ * so a lost reply covers everything from "it never arrived" to "the write landed and
65
+ * the answer was lost" — and telling a model nothing happened is what gets the change
66
+ * made twice.
67
+ *
68
+ * `applied_before_stop: null` for the same reason: null is "not measured", which is
69
+ * true, where false would be a measurement nobody took. */
70
+ export declare function decisionUnknown(): AskResult;
71
+ /** Starting a run with no task. Only reachable now that `query` is optional, which it
72
+ * has to be so a model can follow `next_step` — which omits it — literally. */
73
+ export declare function missingQuery(): AskResult;
74
+ export declare function incompleteResume(): AskResult;
@@ -0,0 +1,242 @@
1
+ /**
2
+ * The two-call shape: ask now, decide later, with nothing held open in between.
3
+ *
4
+ * WHY IT EXISTS. Elicitation is what makes this process stateful — a suspended
5
+ * `elicitation/create` pins an McpServer in one pod's heap until the answer arrives, so
6
+ * the hosted deployment needs sessions, ingress affinity keyed on the caller's
7
+ * credentials, and a cap on how many sessions may be alive. Deferred removes the
8
+ * suspension rather than routing around it: Atlas returns at its first ask, we hand that
9
+ * ask back, and the caller returns with the decision in a SECOND call. Between the two
10
+ * this process holds nothing, so any pod can serve either.
11
+ *
12
+ * WHAT IT COSTS, stated plainly because it is not free:
13
+ * - Approval rides the CLIENT's own permission prompt on the second call, not an
14
+ * elicitation. A client configured not to prompt (`--permission-mode auto`) approves
15
+ * with no human. Elicitation fails closed by construction; this fails closed only if
16
+ * the client asks. That is the trade, and it is why stdio keeps elicitation.
17
+ * - One extra round trip per APPROVAL, not per task. Atlas's gate is serial, so a task
18
+ * with five writes is five approvals and six calls.
19
+ *
20
+ * The run itself is parked on Atlas's side for the gate's own 300s, kept alive exactly as
21
+ * a dropped reader leaves it (CONTRACT v2 §4.2). Nothing here has to manage that.
22
+ */
23
+ import logger from "../../logger.js";
24
+ import { RELAY_CONTRACT, decisionUrl, parseAsk, } from "./stream.js";
25
+ import { RELAY_OFF_DETAILS, buildResult } from "./relay.js";
26
+ function asRecord(value) {
27
+ return value && typeof value === "object" && !Array.isArray(value)
28
+ ? value
29
+ : {};
30
+ }
31
+ /**
32
+ * Atlas's `needs_approval` payload → our result, or `null` when it is not one.
33
+ *
34
+ * Null rather than a throw: a deferred request whose run never needed approval comes
35
+ * back as an ORDINARY result, and that is a success to pass straight through — not an
36
+ * unexpected shape. A read-only task must cost one call, not two.
37
+ */
38
+ export function parkedResult(body, product) {
39
+ const payload = asRecord(body);
40
+ if (payload.status !== "needs_approval")
41
+ return null;
42
+ const runId = String(payload.run_id || "");
43
+ const asks = Array.isArray(payload.asks) ? payload.asks : [];
44
+ // Validated, not cast, for the same reason the streaming path validates: an ask with
45
+ // no usable `perm_id` cannot be answered, so handing it to a caller would invite a
46
+ // second call that can only 404.
47
+ const ask = asks.length ? parseAsk(asks[0]) : null;
48
+ if (!runId || !ask) {
49
+ logger.error("askBrowserStackAI: deferred run parked without an answerable ask; treating as a plain result");
50
+ return null;
51
+ }
52
+ return {
53
+ runId,
54
+ ask: {
55
+ perm_id: ask.perm_id,
56
+ description: ask.description,
57
+ product: ask.product || product,
58
+ mode: ask.mode,
59
+ },
60
+ };
61
+ }
62
+ /** CONTRACT §5's shape, for a run that is parked rather than finished. */
63
+ export function needsApprovalResult(runId, ask, product) {
64
+ return {
65
+ // NOT ok, and not an error either: nothing was applied and nothing failed. A caller
66
+ // that branches on `ok` alone must not read a parked run as a finished one.
67
+ ok: false,
68
+ status: "needs_approval",
69
+ // Addressed to the MODEL, and imperative, because the model is what reads it and
70
+ // the one thing that must happen next is not something it can do by itself.
71
+ answer: `A HUMAN MUST APPROVE THIS BEFORE IT CAN CONTINUE. BrowserStack AI stopped at: ` +
72
+ `${ask.description}. Nothing has been changed yet.\n\n` +
73
+ `Put this to the user and WAIT for their answer. If you have a way to ask them a ` +
74
+ `structured question or show a confirmation — any prompt, question or approval ` +
75
+ `affordance your client offers — use that; otherwise ask plainly in your reply. ` +
76
+ `Quote what is being done either way.\n\n` +
77
+ `Do NOT decide on their behalf, and do not treat "it is what they asked for" as ` +
78
+ `approval: they are being asked to confirm this specific change, which is a ` +
79
+ `different question. Once they have answered, follow \`next_step\`.`,
80
+ approvals: [],
81
+ approvals_source: "atlas",
82
+ elicitations: [],
83
+ needs_approval: [{ description: ask.description, mode: ask.mode }],
84
+ applied_before_stop: false,
85
+ permission_relay: {
86
+ used: true,
87
+ reason: "",
88
+ // Terse on purpose: the actionable half lives in `next_step`, and an explanation
89
+ // of the transport here is what the model read instead of the instruction.
90
+ detail: "Approval is pending a human answer; see `next_step`.",
91
+ },
92
+ atlas_response: null,
93
+ run_id: runId,
94
+ perm_id: ask.perm_id,
95
+ next_step: {
96
+ instruction:
97
+ // Deliberately names no tool and no client. The affordance differs everywhere
98
+ // — a structured question, a confirmation dialog, an approval prompt, or
99
+ // nothing at all — and naming one would be wrong on every other client and
100
+ // would rot as clients change. Describe the ACT, let the model pick the means.
101
+ "AFTER the user has answered — not before — call askBrowserStackAI again " +
102
+ "with exactly these params, setting `decision` to what THEY said. If they " +
103
+ 'declined, send "deny": that is a valid answer and stops the task cleanly. ' +
104
+ "If they have not answered yet, do not call anything.",
105
+ call: "askBrowserStackAI",
106
+ params: {
107
+ product,
108
+ run_id: runId,
109
+ perm_id: ask.perm_id,
110
+ decision: "allow | deny — the user's answer, not your own judgement",
111
+ },
112
+ },
113
+ };
114
+ }
115
+ /** The FIRST call: run until the first ask, then answer. Nothing is held open. */
116
+ export async function runDeferred(url, headers, body, transport, mode, product) {
117
+ const response = await transport(url, headers, {
118
+ ...body,
119
+ permission_relay: { mode: "deferred", contract: RELAY_CONTRACT },
120
+ });
121
+ const parked = parkedResult(response.body, product);
122
+ if (parked) {
123
+ logger.info("askBrowserStackAI: run parked awaiting approval (run=%s)", parked.runId);
124
+ return needsApprovalResult(parked.runId, parked.ask, product);
125
+ }
126
+ // No ask: an ordinary result, passed through the same builder the streaming path
127
+ // uses so an entitlement refusal, a 401 and a plain failure all read identically
128
+ // whichever transport produced them.
129
+ return buildResult(response, [], mode, product);
130
+ }
131
+ /**
132
+ * The SECOND call: deliver the decision, then pick the run back up.
133
+ *
134
+ * Two requests, in this order, and the order is the safety property: the decision is
135
+ * posted FIRST so that a reattach which then fails cannot leave the gate waiting on an
136
+ * answer we already have. A lost reattach costs the caller the answer; a lost decision
137
+ * would cost them the run.
138
+ */
139
+ export async function runResumed(agentUrl, headers, runId, permId, decision, transport, mode, product) {
140
+ // ONE CALL. This used to POST the decision, check for a 204, and then reattach to
141
+ // `GET /agent/{run_id}/stream` to collect what the run did next — two requests, and a
142
+ // reattach that only existed because the run was a paused coroutine in one pod. The
143
+ // ask parks in Atlas's checkpoint now, so the decision request IS the one that carries
144
+ // the run forward and the continuation comes back in its own reply, from any replica.
145
+ let response;
146
+ try {
147
+ response = await transport(decisionUrl(agentUrl, runId), headers, {
148
+ perm_id: permId,
149
+ decision,
150
+ reason: "",
151
+ // Required. The run resumes on a fresh Agent that must be scoped before it can be
152
+ // built, and Atlas deliberately will not take that scope from persisted state.
153
+ product,
154
+ });
155
+ }
156
+ catch {
157
+ // CAUGHT HERE rather than left to the handler's outer catch, which cannot tell this
158
+ // apart from a request that never left and so reports `not_reached` — "nothing was
159
+ // changed" — for a decision that may well have completed.
160
+ logger.warn("askBrowserStackAI: no reply to the decision on run %s; outcome unknown", runId);
161
+ return decisionUnknown();
162
+ }
163
+ if (response.status === 404) {
164
+ // The only failure worth its own words: there is no such parked run. It expired, it
165
+ // was already answered, or this is not the caller that owns it. None of those is an
166
+ // approval, and none of them changed anything.
167
+ return resumeFailed(`That approval could not be applied (HTTP 404). The run may have expired — ` +
168
+ `BrowserStack AI stops waiting after five minutes — or it may already have ` +
169
+ `been answered. Nothing was changed by this call. Start the task again if it ` +
170
+ `still needs doing.`);
171
+ }
172
+ // PARKED AGAIN: the task needs more than one approval. Handed straight back rather
173
+ // than answered here, because the whole point is that a human decides each one.
174
+ const parked = parkedResult(response.body, product);
175
+ if (parked) {
176
+ logger.info("askBrowserStackAI: run parked again after a decision (run=%s)", runId);
177
+ return needsApprovalResult(parked.runId, parked.ask, product);
178
+ }
179
+ // Finished. Through the SAME builder the start path uses, so an entitlement refusal,
180
+ // a 401 and a plain failure read identically whichever call produced them.
181
+ return buildResult(response, [], mode, product);
182
+ }
183
+ /** A resume that did not happen. Every field says "nothing changed", because in each
184
+ * case nothing did — and a caller that cannot tell a failed resume from a refusal
185
+ * either retries a write that landed or abandons one that never did. */
186
+ export function resumeFailed(error) {
187
+ return {
188
+ ok: false,
189
+ status: "error",
190
+ answer: null,
191
+ approvals: [],
192
+ approvals_source: "mcp",
193
+ elicitations: [],
194
+ needs_approval: [],
195
+ applied_before_stop: false,
196
+ permission_relay: { used: true, reason: "", detail: "" },
197
+ atlas_response: null,
198
+ error,
199
+ };
200
+ }
201
+ /** A decision that went out and whose reply never came back.
202
+ *
203
+ * Deliberately NOT `resumeFailed`: every field there says "nothing changed", and that
204
+ * is a claim this case cannot make. The decision request carries the continuation now,
205
+ * so a lost reply covers everything from "it never arrived" to "the write landed and
206
+ * the answer was lost" — and telling a model nothing happened is what gets the change
207
+ * made twice.
208
+ *
209
+ * `applied_before_stop: null` for the same reason: null is "not measured", which is
210
+ * true, where false would be a measurement nobody took. */
211
+ export function decisionUnknown() {
212
+ return {
213
+ ok: false,
214
+ status: "error",
215
+ answer: null,
216
+ approvals: [],
217
+ approvals_source: "mcp",
218
+ elicitations: [],
219
+ needs_approval: [],
220
+ applied_before_stop: null,
221
+ permission_relay: {
222
+ used: true,
223
+ reason: "outcome_unknown",
224
+ detail: RELAY_OFF_DETAILS.outcome_unknown,
225
+ },
226
+ atlas_response: null,
227
+ error: "The approval was sent to BrowserStack AI but no reply came back, so it is not " +
228
+ "known whether the step completed. Nothing should be retried on this run.",
229
+ };
230
+ }
231
+ /** Starting a run with no task. Only reachable now that `query` is optional, which it
232
+ * has to be so a model can follow `next_step` — which omits it — literally. */
233
+ export function missingQuery() {
234
+ return resumeFailed("`query` is required to start a run: say what you want in plain language. " +
235
+ "(It is only omitted when answering a pending approval, which also needs " +
236
+ "`run_id`, `perm_id` and `decision`.) Nothing was sent, so nothing changed.");
237
+ }
238
+ export function incompleteResume() {
239
+ return resumeFailed("Resuming needs all three of `run_id`, `perm_id` and `decision`. Nothing was " +
240
+ "sent, so nothing changed and the pending approval is still waiting — ask the " +
241
+ "user whether to allow it and call again with all three.");
242
+ }
@@ -26,7 +26,7 @@
26
26
  import { McpServer, RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js";
27
27
  import { BrowserStackConfig } from "../../lib/types.js";
28
28
  import { Credentials } from "./egress.js";
29
- import type { AgentStreamTransport, DecisionTransport } from "./stream.js";
29
+ import type { AgentStreamTransport, DeferredTransport } from "./stream.js";
30
30
  import { RelayMode } from "./types.js";
31
31
  export interface AskDeps {
32
32
  /** Resolved per call: a deployment's host is configuration, not a constructor argument. */
@@ -52,7 +52,8 @@ export interface AskDeps {
52
52
  * — ask, elicit, decide, result — without a socket.
53
53
  */
54
54
  streamTransport?: AgentStreamTransport;
55
- decisionTransport?: DecisionTransport;
55
+ /** The deferred START (one request, one JSON response). Substituted in tests. */
56
+ deferredTransport?: DeferredTransport;
56
57
  }
57
58
  export declare function relayMode(server: McpServer): RelayMode;
58
59
  export declare function addAskBrowserStackAITool(server: McpServer, deps: AskDeps, config?: BrowserStackConfig): Record<string, RegisteredTool>;
@@ -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
  *
@@ -11,4 +11,14 @@
11
11
  // describe text in register.ts, and the two a11y handoffs in tool-handoff.ts — which stop
12
12
  // pointing here precisely because the call would now be rejected.
13
13
  export const PRODUCTS = ["tm", "tra"];
14
- export const ASK_STATUSES = ["ok", "blocked", "error", "rate_limited"];
14
+ export const ASK_STATUSES = [
15
+ "ok",
16
+ "blocked",
17
+ "error",
18
+ "rate_limited",
19
+ // Deferred only: the run reached an ask and is PARKED, not finished. Distinct from
20
+ // `blocked` (a human said no) and from `error` (nothing is waiting) — this one is the
21
+ // only status a caller is invited to come back from, and the only one carrying a
22
+ // `run_id`.
23
+ "needs_approval",
24
+ ];
@@ -2,6 +2,7 @@ import { assertOkResponse, maybeCompressBase64 } from "../../lib/utils.js";
2
2
  import { SessionType } from "../../lib/constants.js";
3
3
  import { getBrowserStackAuth } from "../../lib/get-auth.js";
4
4
  import { apiClient } from "../../lib/apiClient.js";
5
+ import globalConfig from "../../config.js";
5
6
  async function extractScreenshotUrls(sessionId, sessionType, config) {
6
7
  const authString = getBrowserStackAuth(config);
7
8
  const auth = Buffer.from(authString).toString("base64");
@@ -36,23 +37,24 @@ async function extractScreenshotUrls(sessionId, sessionType, config) {
36
37
  }
37
38
  return urls;
38
39
  }
40
+ async function fetchAndCompress(url) {
41
+ const response = await apiClient.get({ url, responseType: "arraybuffer" });
42
+ // Axios returns response.data as a Buffer for binary data
43
+ const base64 = Buffer.from(response.data).toString("base64");
44
+ return { url, base64: await maybeCompressBase64(base64) };
45
+ }
39
46
  //Converts screenshot URLs to base64 encoded images
40
47
  async function convertUrlsToBase64(urls) {
41
- const screenshots = await Promise.all(urls.map(async (url) => {
42
- const response = await apiClient.get({
43
- url,
44
- responseType: "arraybuffer",
45
- });
46
- // Axios returns response.data as a Buffer for binary data
47
- const base64 = Buffer.from(response.data).toString("base64");
48
- // Compress the base64 image if needed
49
- const compressedBase64 = await maybeCompressBase64(base64);
50
- return {
51
- url,
52
- base64: compressedBase64,
53
- };
54
- }));
55
- return screenshots;
48
+ // Hosted pods are memory-constrained: process one image at a time. Local runs
49
+ // keep the concurrent path.
50
+ if (globalConfig.REMOTE_MCP) {
51
+ const screenshots = [];
52
+ for (const url of urls) {
53
+ screenshots.push(await fetchAndCompress(url));
54
+ }
55
+ return screenshots;
56
+ }
57
+ return Promise.all(urls.map(fetchAndCompress));
56
58
  }
57
59
  //Fetches and converts screenshot URLs to base64 encoded images
58
60
  export async function fetchAutomationScreenshots(sessionId, sessionType = SessionType.Automate, config) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@browserstack/mcp-server",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "BrowserStack's Official MCP Server",
5
5
  "mcpName": "io.github.browserstack/mcp-server",
6
6
  "main": "dist/index.js",