@browserstack/mcp-server 2.2.0-beta.1 → 2.2.0-beta.2
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/capability/tra.capability-index.json +36 -14
- package/dist/config.d.ts +27 -2
- package/dist/config.js +19 -6
- package/dist/lib/instrumentation.js +29 -3
- package/dist/lib/utils.js +8 -0
- package/dist/tools/accessiblity-utils/scanner.js +1 -1
- package/dist/tools/ask-browserstack/config.d.ts +0 -14
- package/dist/tools/ask-browserstack/config.js +0 -16
- package/dist/tools/ask-browserstack/deferred.d.ts +74 -0
- package/dist/tools/ask-browserstack/deferred.js +242 -0
- package/dist/tools/ask-browserstack/register.d.ts +3 -2
- package/dist/tools/ask-browserstack/register.js +219 -72
- package/dist/tools/ask-browserstack/relay.js +11 -17
- package/dist/tools/ask-browserstack/stream.d.ts +27 -5
- package/dist/tools/ask-browserstack/stream.js +25 -14
- package/dist/tools/ask-browserstack/types.d.ts +54 -3
- package/dist/tools/ask-browserstack/types.js +11 -1
- package/dist/tools/automate-utils/fetch-screenshots.js +17 -15
- package/dist/tools/capability-registry/redact.js +7 -5
- package/package.json +1 -1
- package/capability/loadtesting.capability-index.json +0 -2978
|
@@ -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,
|
|
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,
|
|
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
|
|
65
|
-
"
|
|
66
|
-
"
|
|
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,
|
|
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
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
/**
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
210
|
-
export function
|
|
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
|
-
//
|
|
214
|
-
// reads the STATUS
|
|
215
|
-
// and a
|
|
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
|
-
//
|
|
227
|
-
//
|
|
228
|
-
|
|
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
|
-
/**
|
|
126
|
-
|
|
127
|
-
|
|
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
|
*
|