@browserstack/mcp-server 2.1.0 → 2.2.0-beta.1
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/loadtesting.capability-index.json +2978 -0
- package/capability/tm.capability-index.json +2 -1
- package/capability/tra.capability-index.json +52074 -0
- package/dist/config.d.ts +2 -27
- package/dist/config.js +6 -19
- package/dist/lib/central-oauth.d.ts +133 -0
- package/dist/lib/central-oauth.js +246 -0
- package/dist/lib/utils.js +0 -8
- package/dist/tools/ask-browserstack/central-oauth.d.ts +25 -46
- package/dist/tools/ask-browserstack/central-oauth.js +55 -177
- package/dist/tools/ask-browserstack/config.d.ts +14 -0
- package/dist/tools/ask-browserstack/config.js +16 -0
- package/dist/tools/ask-browserstack/register.d.ts +2 -3
- package/dist/tools/ask-browserstack/register.js +72 -219
- package/dist/tools/ask-browserstack/relay.js +17 -11
- package/dist/tools/ask-browserstack/stream.d.ts +5 -27
- package/dist/tools/ask-browserstack/stream.js +14 -25
- package/dist/tools/ask-browserstack/types.d.ts +3 -54
- package/dist/tools/ask-browserstack/types.js +1 -11
- package/dist/tools/automate-utils/fetch-screenshots.js +15 -17
- package/dist/tools/capability-registry/bind.js +43 -7
- package/dist/tools/capability-registry/discovery.js +3 -1
- package/dist/tools/capability-registry/egress.d.ts +12 -5
- package/dist/tools/capability-registry/egress.js +24 -1
- package/dist/tools/capability-registry/oauth.d.ts +63 -0
- package/dist/tools/capability-registry/oauth.js +176 -0
- package/dist/tools/capability-registry/register.js +289 -41
- package/dist/tools/capability-registry/resolve.d.ts +1 -1
- package/dist/tools/capability-registry/resolve.js +24 -3
- package/dist/tools/capability-registry/routing-token.d.ts +25 -0
- package/dist/tools/capability-registry/routing-token.js +87 -0
- package/dist/tools/capability-registry/search.d.ts +28 -1
- package/dist/tools/capability-registry/search.js +202 -4
- package/dist/tools/capability-registry/types.d.ts +42 -3
- package/dist/tools/testmanagement.js +5 -5
- package/dist/tools/tool-handoff.d.ts +15 -12
- package/dist/tools/tool-handoff.js +21 -18
- package/package.json +2 -1
- package/dist/tools/ask-browserstack/deferred.d.ts +0 -74
- package/dist/tools/ask-browserstack/deferred.js +0 -242
|
@@ -28,11 +28,10 @@ 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, authTokenUrl, isEnabled, } from "./config.js";
|
|
31
|
+
import { AskError, ELICITATION_TIMEOUT_MS, agentUrl, allowRemoteRelay, 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,
|
|
35
|
-
import { decisionUnknown, incompleteResume, missingQuery, parkedResult, resumeFailed, runDeferred, runResumed, } from "./deferred.js";
|
|
34
|
+
import { EVENT_PERMISSION, EVENT_RESULT, EVENT_RUN, decisionUrl, fetchAgentStreamTransport, fetchDecisionTransport, parseAsk, } from "./stream.js";
|
|
36
35
|
import { buildResult, decide, elicitationMessage, elicitationShape, errorResult, } from "./relay.js";
|
|
37
36
|
import { PRODUCTS, } from "./types.js";
|
|
38
37
|
/**
|
|
@@ -62,10 +61,9 @@ const DESCRIPTION =
|
|
|
62
61
|
"predictable than handing the job to an agent. " +
|
|
63
62
|
"Otherwise, describe what you want in plain language and BrowserStack's agent decides " +
|
|
64
63
|
"which calls to make, then returns its answer plus the steps it took. Anything that would " +
|
|
65
|
-
"change data pauses and asks you to confirm it first; deletes are
|
|
66
|
-
"
|
|
67
|
-
"
|
|
68
|
-
"wanted to change comes back in `needs_approval` instead. One task per call.";
|
|
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.";
|
|
69
67
|
/**
|
|
70
68
|
* `isError` marks a call that FAILED, not one that was refused.
|
|
71
69
|
*
|
|
@@ -163,6 +161,36 @@ async function relayOneAsk(server, ask, approvals, relatedRequestId) {
|
|
|
163
161
|
approvals.push({ description: ask.description, decision, reason });
|
|
164
162
|
return { perm_id: ask.perm_id, decision, reason };
|
|
165
163
|
}
|
|
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
|
+
*/
|
|
166
194
|
/**
|
|
167
195
|
* CONTRACT v2 (A1) — drive one run over the stream.
|
|
168
196
|
*
|
|
@@ -180,90 +208,13 @@ async function relayOneAsk(server, ask, approvals, relatedRequestId) {
|
|
|
180
208
|
* simply stops is indistinguishable from a network drop, and reporting it as a finished
|
|
181
209
|
* run with no answer would be the transport quietly speaking for the agent.
|
|
182
210
|
*/
|
|
183
|
-
async function runStreamed(server, streamTransport,
|
|
211
|
+
async function runStreamed(server, streamTransport, decisionTransport, url, headers, body, approvals, mode, product, relatedRequestId) {
|
|
184
212
|
let runId = "";
|
|
185
213
|
let result;
|
|
186
214
|
let sawResult = false;
|
|
187
215
|
// 200 unless the reply was not a stream at all, in which case the transport carries
|
|
188
216
|
// the real status — `relay.ts` needs it to tell 401 from 403 from a plain failure.
|
|
189
217
|
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.
|
|
267
218
|
for await (const event of streamTransport(url, headers, body)) {
|
|
268
219
|
if (event.event === EVENT_RUN) {
|
|
269
220
|
runId = String(event.data?.run_id || "");
|
|
@@ -292,55 +243,34 @@ async function runStreamed(server, streamTransport, postTransport, url, headers,
|
|
|
292
243
|
logger.error("askBrowserStackAI: permission ask arrived before run_id; cannot answer");
|
|
293
244
|
continue;
|
|
294
245
|
}
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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;
|
|
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);
|
|
320
256
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
if (
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
|
|
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" };
|
|
260
|
+
}
|
|
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);
|
|
335
272
|
}
|
|
336
|
-
pending = answered.next ?? null;
|
|
337
|
-
carried = answered.response ?? null;
|
|
338
273
|
}
|
|
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);
|
|
344
274
|
if (!sawResult) {
|
|
345
275
|
return errorResult("BrowserStack AI ended the run without a result. Nothing was changed " +
|
|
346
276
|
"beyond any step you already approved.", approvals);
|
|
@@ -350,35 +280,13 @@ async function runStreamed(server, streamTransport, postTransport, url, headers,
|
|
|
350
280
|
return buildResult({ status: resultStatus, body: result }, approvals, mode, product);
|
|
351
281
|
}
|
|
352
282
|
export function relayMode(server) {
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
|
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
|
|
382
290
|
// `elicitation` gets a read-only run whatever the deployment.
|
|
383
291
|
return server.server.getClientCapabilities()?.elicitation
|
|
384
292
|
? "offered"
|
|
@@ -390,9 +298,7 @@ export function addAskBrowserStackAITool(server, deps, config) {
|
|
|
390
298
|
// JSON, the parser sees no `text/event-stream`, and the run degrades to a read-only
|
|
391
299
|
// answer carrying that response's own status.
|
|
392
300
|
const streamTransport = deps.streamTransport || fetchAgentStreamTransport();
|
|
393
|
-
|
|
394
|
-
// registration uses, so a test substituting one is not surprised by another.
|
|
395
|
-
const deferredTransport = deps.deferredTransport || fetchDeferredTransport();
|
|
301
|
+
const decisionTransport = deps.decisionTransport || fetchDecisionTransport();
|
|
396
302
|
const tools = {};
|
|
397
303
|
/** Instrumentation in the house style, and never fatal to the call it wraps. */
|
|
398
304
|
const track = (name) => {
|
|
@@ -408,35 +314,9 @@ export function addAskBrowserStackAITool(server, deps, config) {
|
|
|
408
314
|
.enum(PRODUCTS)
|
|
409
315
|
.describe("Which product to work in: tm (Test Management), " +
|
|
410
316
|
"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.
|
|
417
317
|
query: z
|
|
418
318
|
.string()
|
|
419
|
-
.optional()
|
|
420
319
|
.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."),
|
|
440
320
|
}, {
|
|
441
321
|
// It can write now, which is the whole point of the relay. Destructive operations
|
|
442
322
|
// stay refused, so `destructiveHint` is false for the same reason invokeEndpoint
|
|
@@ -449,7 +329,7 @@ export function addAskBrowserStackAITool(server, deps, config) {
|
|
|
449
329
|
openWorldHint: true,
|
|
450
330
|
idempotentHint: false,
|
|
451
331
|
title: "Ask BrowserStack AI (Alpha)",
|
|
452
|
-
}, async ({ product, query
|
|
332
|
+
}, async ({ product, query }, extra) => {
|
|
453
333
|
track("askBrowserStackAI");
|
|
454
334
|
const approvals = [];
|
|
455
335
|
// Negotiated before anything else so the failure paths below report the mode they
|
|
@@ -461,27 +341,6 @@ export function addAskBrowserStackAITool(server, deps, config) {
|
|
|
461
341
|
// lazily mid-call would put a token round-trip inside the window where a human is
|
|
462
342
|
// being prompted, and a mint that failed there would strand an open port.
|
|
463
343
|
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
|
-
}
|
|
485
344
|
const body = { task: query, product };
|
|
486
345
|
// Attribution, and now belt-and-braces rather than the source of truth: the minted
|
|
487
346
|
// JWT is user-attested, so Atlas sets `principal_verified=True` and takes the acting
|
|
@@ -492,18 +351,12 @@ export function addAskBrowserStackAITool(server, deps, config) {
|
|
|
492
351
|
const username = (deps.credentialsFor().username || "").trim();
|
|
493
352
|
if (username)
|
|
494
353
|
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
|
-
}
|
|
501
354
|
// A1: asking for a stream costs nothing to set up — no port, no listener, no
|
|
502
355
|
// per-run bearer, because nothing dials in. Which is the whole point: the
|
|
503
356
|
// callback this replaces could never reach a laptop behind NAT, so the feature
|
|
504
357
|
// was read-only for every real user regardless of what was configured.
|
|
505
358
|
if (mode === "offered") {
|
|
506
|
-
body.permission_relay = { mode: "stream"
|
|
359
|
+
body.permission_relay = { mode: "stream" };
|
|
507
360
|
}
|
|
508
361
|
else {
|
|
509
362
|
// Omitted ENTIRELY, not sent empty: its absence is what selects Atlas's
|
|
@@ -513,7 +366,7 @@ export function addAskBrowserStackAITool(server, deps, config) {
|
|
|
513
366
|
// `product` reaches the result so an entitlement refusal can name it: the flags
|
|
514
367
|
// are per product, and a bare "not enabled" sends the user to their admin
|
|
515
368
|
// asking about the wrong thing.
|
|
516
|
-
return toResult(await runStreamed(server, streamTransport,
|
|
369
|
+
return toResult(await runStreamed(server, streamTransport, decisionTransport, url, headers, body, approvals, mode, product,
|
|
517
370
|
// The tool call's own id, so each elicitation is routed onto THIS request's
|
|
518
371
|
// stream. Over Streamable HTTP there is nowhere else for it to go.
|
|
519
372
|
extra?.requestId));
|
|
@@ -48,6 +48,13 @@ 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.",
|
|
51
58
|
// Not a refusal by anyone and not a relay problem at all: the account is not on the
|
|
52
59
|
// product's agent flag. The product-specific sentence and what to do about it live in
|
|
53
60
|
// `error`, so this one points there rather than duplicating the plumbing.
|
|
@@ -56,17 +63,6 @@ export const RELAY_OFF_DETAILS = {
|
|
|
56
63
|
"refused before the agent started. `error` names the product. This is an entitlement on " +
|
|
57
64
|
"the account, not a problem with your credentials and not a decision anyone made about " +
|
|
58
65
|
"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.",
|
|
70
66
|
// The request never got as far as the agent. Distinct from `disabled` (the agent ran, with
|
|
71
67
|
// the relay switched off) and from a decline (someone was asked and said no), because the
|
|
72
68
|
// three call for completely different things from whoever reads them.
|
|
@@ -417,6 +413,16 @@ function relayVerdict(payload, mode, reachedAgent, notEntitled) {
|
|
|
417
413
|
detail: RELAY_OFF_DETAILS.not_reached,
|
|
418
414
|
};
|
|
419
415
|
}
|
|
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
|
+
}
|
|
420
426
|
if (mode === "no_human") {
|
|
421
427
|
// CONTRACT §7's last row: no elicitation capability means the field was never sent.
|
|
422
428
|
return {
|
|
@@ -48,14 +48,6 @@ 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;
|
|
59
51
|
/** Atlas's `f"perm-{uuid.uuid4().hex}"`, and nothing else. */
|
|
60
52
|
export declare const PERM_ID_PATTERN: RegExp;
|
|
61
53
|
/**
|
|
@@ -83,15 +75,12 @@ export declare function parseAsk(data: unknown): PermissionAsk | null;
|
|
|
83
75
|
* was: the tests must be able to drive a whole approval round trip without a socket.
|
|
84
76
|
*/
|
|
85
77
|
export type AgentStreamTransport = (url: string, headers: Record<string, string>, body: AgentRequest) => AsyncIterable<StreamEvent>;
|
|
86
|
-
/**
|
|
87
|
-
|
|
88
|
-
* one thing that must not be re-derived from persisted state. */
|
|
89
|
-
export interface DecisionBody {
|
|
78
|
+
/** Posts one decision. Separate seam because it is a separate connection. */
|
|
79
|
+
export type DecisionTransport = (url: string, headers: Record<string, string>, body: {
|
|
90
80
|
perm_id: string;
|
|
91
81
|
decision: string;
|
|
92
82
|
reason: string;
|
|
93
|
-
|
|
94
|
-
}
|
|
83
|
+
}) => Promise<number>;
|
|
95
84
|
/**
|
|
96
85
|
* Split a buffer into complete SSE frames, returning the leftover.
|
|
97
86
|
*
|
|
@@ -121,18 +110,7 @@ export declare function parseFrame(frame: string): StreamEvent | null;
|
|
|
121
110
|
* is unchanged and still enforced where it belongs.
|
|
122
111
|
*/
|
|
123
112
|
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;
|
|
124
115
|
/** `POST /agent/{run_id}/permission`, built from the base URL the tool already resolved. */
|
|
125
116
|
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,14 +35,6 @@ 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;
|
|
46
38
|
/** Atlas's `f"perm-{uuid.uuid4().hex}"`, and nothing else. */
|
|
47
39
|
export const PERM_ID_PATTERN = /^perm-[0-9a-f]{32}$/;
|
|
48
40
|
/**
|
|
@@ -214,21 +206,13 @@ export function fetchAgentStreamTransport(timeoutMs = WHOLE_RUN_TIMEOUT_MS) {
|
|
|
214
206
|
};
|
|
215
207
|
};
|
|
216
208
|
}
|
|
217
|
-
/**
|
|
218
|
-
export function
|
|
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) {
|
|
209
|
+
/** The decision POST. 30s, because it is an ordinary short request. */
|
|
210
|
+
export function fetchDecisionTransport(timeoutMs = 30_000) {
|
|
227
211
|
return async (url, headers, body) => {
|
|
228
212
|
try {
|
|
229
|
-
// `raise_error: false`
|
|
230
|
-
//
|
|
231
|
-
//
|
|
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.
|
|
232
216
|
const response = await apiClient.post({
|
|
233
217
|
url,
|
|
234
218
|
headers,
|
|
@@ -236,12 +220,17 @@ export function fetchDeferredTransport(timeoutMs = WHOLE_RUN_TIMEOUT_MS) {
|
|
|
236
220
|
timeout: timeoutMs,
|
|
237
221
|
raise_error: false,
|
|
238
222
|
});
|
|
239
|
-
return
|
|
223
|
+
return response.status;
|
|
240
224
|
}
|
|
241
225
|
catch {
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
|
|
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;
|
|
245
230
|
}
|
|
246
231
|
};
|
|
247
232
|
}
|
|
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,16 +57,6 @@ 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;
|
|
70
60
|
}
|
|
71
61
|
/**
|
|
72
62
|
* CONTRACT §1 — the `POST /agent` body.
|
|
@@ -132,23 +122,9 @@ export type RelayMode =
|
|
|
132
122
|
"offered"
|
|
133
123
|
/** The client declares no `elicitation` capability, so nobody could be prompted. */
|
|
134
124
|
| "no_human"
|
|
135
|
-
/**
|
|
136
|
-
|
|
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"];
|
|
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"];
|
|
152
128
|
export type AskStatus = (typeof ASK_STATUSES)[number];
|
|
153
129
|
/** CONTRACT §5 — the single tool result. */
|
|
154
130
|
export interface AskResult {
|
|
@@ -209,33 +185,6 @@ export interface AskResult {
|
|
|
209
185
|
* side would otherwise silently become `null` here rather than reaching the caller.
|
|
210
186
|
*/
|
|
211
187
|
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
|
-
};
|
|
239
188
|
/**
|
|
240
189
|
* Why the call failed, when it did.
|
|
241
190
|
*
|