@browserstack/mcp-server 1.4.0-beta.3 → 1.5.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/loadtesting.capability-index.json +1764 -0
- package/capability/tm.capability-index.json +19793 -0
- package/dist/config.d.ts +1 -4
- package/dist/config.js +2 -23
- package/dist/index.js +2 -5
- package/dist/server-factory.js +5 -5
- package/dist/tools/accessibility.js +2 -5
- package/dist/tools/capability-registry/bind.d.ts +29 -0
- package/dist/tools/capability-registry/bind.js +134 -0
- package/dist/tools/capability-registry/config.d.ts +62 -0
- package/dist/tools/capability-registry/config.js +218 -0
- package/dist/tools/capability-registry/discovery.d.ts +44 -0
- package/dist/tools/capability-registry/discovery.js +99 -0
- package/dist/tools/capability-registry/egress.d.ts +44 -0
- package/dist/tools/capability-registry/egress.js +128 -0
- package/dist/tools/capability-registry/index-loader.d.ts +119 -0
- package/dist/tools/capability-registry/index-loader.js +314 -0
- package/dist/tools/capability-registry/register.d.ts +34 -0
- package/dist/tools/capability-registry/register.js +354 -0
- package/dist/tools/capability-registry/resolve.d.ts +38 -0
- package/dist/tools/capability-registry/resolve.js +45 -0
- package/dist/tools/capability-registry/search.d.ts +65 -0
- package/dist/tools/capability-registry/search.js +342 -0
- package/dist/tools/capability-registry/types.d.ts +208 -0
- package/dist/tools/capability-registry/types.js +33 -0
- package/dist/tools/get-failure-logs.js +1 -3
- package/dist/tools/rca-agent.js +2 -5
- package/dist/tools/selfheal.js +2 -5
- package/dist/tools/testmanagement.js +15 -37
- package/package.json +3 -2
- package/dist/tools/ask-browserstack/central-oauth.d.ts +0 -120
- package/dist/tools/ask-browserstack/central-oauth.js +0 -277
- package/dist/tools/ask-browserstack/config.d.ts +0 -102
- package/dist/tools/ask-browserstack/config.js +0 -140
- package/dist/tools/ask-browserstack/egress.d.ts +0 -34
- package/dist/tools/ask-browserstack/egress.js +0 -31
- package/dist/tools/ask-browserstack/register.d.ts +0 -61
- package/dist/tools/ask-browserstack/register.js +0 -416
- package/dist/tools/ask-browserstack/relay.d.ts +0 -201
- package/dist/tools/ask-browserstack/relay.js +0 -577
- package/dist/tools/ask-browserstack/stream.d.ts +0 -116
- package/dist/tools/ask-browserstack/stream.js +0 -236
- package/dist/tools/ask-browserstack/types.d.ts +0 -196
- package/dist/tools/ask-browserstack/types.js +0 -14
- package/dist/tools/tool-handoff.d.ts +0 -62
- package/dist/tools/tool-handoff.js +0 -75
|
@@ -1,416 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `askBrowserStackAI` — one tool call in, one tool result out, with a human's approval
|
|
3
|
-
* relayed through the middle of it.
|
|
4
|
-
*
|
|
5
|
-
* The shape, and why:
|
|
6
|
-
*
|
|
7
|
-
* 1. NEGOTIATE FIRST. `relayMode()` is consulted BEFORE Atlas is called, so Atlas learns
|
|
8
|
-
* whether a human is reachable before it starts rather than discovering it at the gate.
|
|
9
|
-
* Anything other than "offered" means `permission_relay` is omitted entirely and Atlas
|
|
10
|
-
* runs read-only — today's exact behaviour, and the path opencode and goose stay on.
|
|
11
|
-
* That also covers the hosted `REMOTE_MCP` deployment, where the relay cannot work at
|
|
12
|
-
* all; see `relayMode` for why. Nothing here depends on `sampling`, which Claude Code
|
|
13
|
-
* does not declare.
|
|
14
|
-
* 2. LISTEN ON LOOPBACK. Transport is A2, so Atlas calls US back; because it initiates,
|
|
15
|
-
* the decision returns on the same connection to the same pod and PLAN.md's affinity
|
|
16
|
-
* problem never arises for this stdio deployment.
|
|
17
|
-
* 3. ELICIT, ONCE. Atlas's `description` is the message, nothing is requested in the form,
|
|
18
|
-
* and the ACTION is mapped by CONTRACT §7 with no second chances.
|
|
19
|
-
* 4. RETURN THE TRAIL. `approvals` and `applied_before_stop` are what let a caller tell
|
|
20
|
-
* "nothing happened" from "some steps applied, then stopped".
|
|
21
|
-
*
|
|
22
|
-
* FAIL CLOSED THROUGHOUT. A decline, a cancel, a timeout, a bad token, a body we cannot
|
|
23
|
-
* parse, a handler that throws — every one of them denies. An unattended run cannot approve
|
|
24
|
-
* itself because a headless client returns `cancel`, which is a deny.
|
|
25
|
-
*/
|
|
26
|
-
import { ErrorCode, McpError, } from "@modelcontextprotocol/sdk/types.js";
|
|
27
|
-
import { z } from "zod";
|
|
28
|
-
import appConfig from "../../config.js";
|
|
29
|
-
import { trackMCP } from "../../lib/instrumentation.js";
|
|
30
|
-
import logger from "../../logger.js";
|
|
31
|
-
import { AskError, ELICITATION_TIMEOUT_MS, agentUrl, allowRemoteRelay, authTokenUrl, isEnabled, } from "./config.js";
|
|
32
|
-
import { fetchTokenTransport, mintCentralToken } from "./central-oauth.js";
|
|
33
|
-
import { agentHeaders } from "./egress.js";
|
|
34
|
-
import { EVENT_PERMISSION, EVENT_RESULT, EVENT_RUN, decisionUrl, fetchAgentStreamTransport, fetchDecisionTransport, parseAsk, } from "./stream.js";
|
|
35
|
-
import { buildResult, decide, elicitationMessage, elicitationShape, errorResult, } from "./relay.js";
|
|
36
|
-
import { PRODUCTS, } from "./types.js";
|
|
37
|
-
/**
|
|
38
|
-
* THE FALLBACK POSITIONING IN THE FIRST SENTENCE IS LOAD-BEARING.
|
|
39
|
-
*
|
|
40
|
-
* This server registers 45 tools, most of them hand-written for one endpoint each. Those are
|
|
41
|
-
* faster, cheaper and more predictable than handing a task to an agent that has to work out
|
|
42
|
-
* its own API calls, so they should win whenever one of them actually fits. What this tool
|
|
43
|
-
* covers is the gap: a task nothing here has a tool for, or one where the specific tools were
|
|
44
|
-
* tried and did not get there.
|
|
45
|
-
*
|
|
46
|
-
* A description is the ONLY thing steering that choice — the client picks a tool from these
|
|
47
|
-
* words alone, before any call is made — so the ordering is deliberate: when to reach for it
|
|
48
|
-
* first, what it does second, and the consent behaviour last.
|
|
49
|
-
*/
|
|
50
|
-
const DESCRIPTION =
|
|
51
|
-
// Alpha status leads, deliberately. The model reads this before deciding to call, and a
|
|
52
|
-
// tool that is not enabled for the account cannot do the job at all — so "there is a
|
|
53
|
-
// per-account gate, fall back to the individual tools" is the most useful thing to say
|
|
54
|
-
// first. Parenthesised so it reads as a status note, not as the tool's purpose.
|
|
55
|
-
"(Alpha, limited availability. Enabled per account and per product; if it is not enabled " +
|
|
56
|
-
"the call returns an entitlement error, nothing runs, and you should complete the task " +
|
|
57
|
-
"with the individual tools instead. To request access, the user should contact their " +
|
|
58
|
-
"BrowserStack account owner.) " +
|
|
59
|
-
"Use this when no other BrowserStack tool here fits the task, or when the ones you tried " +
|
|
60
|
-
"did not get you there. Prefer a specific tool whenever one fits: it is faster and more " +
|
|
61
|
-
"predictable than handing the job to an agent. " +
|
|
62
|
-
"Otherwise, describe what you want in plain language and BrowserStack's agent decides " +
|
|
63
|
-
"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.";
|
|
67
|
-
/**
|
|
68
|
-
* `isError` marks a call that FAILED, not one that was refused.
|
|
69
|
-
*
|
|
70
|
-
* A `blocked` run is the feature working: the agent asked, a human said no, and the trail
|
|
71
|
-
* says so. Flagging that as a tool error makes a client render a correct refusal in red and
|
|
72
|
-
* — worse — invites it to retry, which is exactly the "retry forever" loop the distinct
|
|
73
|
-
* `permission_relay` reasons exist to prevent. `ok` still means `status === "ok"`.
|
|
74
|
-
*/
|
|
75
|
-
function toResult(payload) {
|
|
76
|
-
const failed = payload.status === "error" || payload.status === "rate_limited";
|
|
77
|
-
return {
|
|
78
|
-
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
79
|
-
...(failed ? { isError: true } : {}),
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
function isTimeout(error) {
|
|
83
|
-
return error instanceof McpError && error.code === ErrorCode.RequestTimeout;
|
|
84
|
-
}
|
|
85
|
-
/**
|
|
86
|
-
* Relay one ask to the human and record what they said.
|
|
87
|
-
*
|
|
88
|
-
* The elicitation is NEVER retried. A client that timed out or errored has told us it
|
|
89
|
-
* cannot get an answer, and asking again only produces a second prompt for the same action.
|
|
90
|
-
*/
|
|
91
|
-
async function relayOneAsk(server, ask, approvals, relatedRequestId) {
|
|
92
|
-
let answer;
|
|
93
|
-
try {
|
|
94
|
-
// `relatedRequestId` IS LOAD-BEARING OVER HTTP, and its absence fails silently.
|
|
95
|
-
// Streamable HTTP routes a server->client message onto the stream of the request it
|
|
96
|
-
// relates to (`_requestToStreamMapping`). With no id the SDK falls back to the
|
|
97
|
-
// standalone SSE stream, and a host that answers GET /mcp with 405 has none — so the
|
|
98
|
-
// SDK drops the message with "Stream is disconnected", the tool waits out its 270s,
|
|
99
|
-
// and Atlas's gate expires into `reason: "timeout"`. The human is told they did not
|
|
100
|
-
// answer a question they were never shown.
|
|
101
|
-
//
|
|
102
|
-
// Measured exactly that way against the hosted server before this was threaded
|
|
103
|
-
// through. On stdio it is irrelevant — one pipe, nothing to route — which is why no
|
|
104
|
-
// local test could have caught it.
|
|
105
|
-
answer = await server.server.elicitInput({
|
|
106
|
-
mode: "form",
|
|
107
|
-
// Framed with the PRODUCT and nothing else (v1.1 §G): a bare sentence with no
|
|
108
|
-
// attribution is a worse prompt than a framed one, and `product` is all the
|
|
109
|
-
// ask carries — the route, method, path and op_key never reach this side by design,
|
|
110
|
-
// and that is the whole privacy boundary (the ask is four named fields). The
|
|
111
|
-
// description goes through VERBATIM: paraphrasing it would mean the human approves
|
|
112
|
-
// something other than what the model actually said. Atlas no longer rewrites it
|
|
113
|
-
// either — it used to replace a route-shaped one with a placeholder, which asked a
|
|
114
|
-
// person to approve a sentence they could not read; CONTRACT v2 §3 was amended and
|
|
115
|
-
// that guard removed. An older Atlas can still send the placeholder, which is why
|
|
116
|
-
// the framing is tested against it.
|
|
117
|
-
message: elicitationMessage(ask.product, ask.description),
|
|
118
|
-
requestedSchema: {
|
|
119
|
-
// NOTHING IS REQUESTED. The action IS the answer: `accept` already means the human
|
|
120
|
-
// approved, and `decline` already gives them an unambiguous refusal in the same
|
|
121
|
-
// dialog. A `confirm` boolean used to live here and produced a FALSE DENIAL — a
|
|
122
|
-
// user approved on preprod and was told they had refused, because a client renders
|
|
123
|
-
// a form field and submits its unset value. We cannot distinguish that from a
|
|
124
|
-
// deliberate untick, so the field is gone rather than guessed at.
|
|
125
|
-
//
|
|
126
|
-
// Fail-closed is untouched by this and never rested on the boolean: a headless
|
|
127
|
-
// client with nobody at the terminal returns `cancel` (measured, HANDOFF.md), and
|
|
128
|
-
// `cancel` is a deny. That is what stops an unattended run self-approving.
|
|
129
|
-
type: "object",
|
|
130
|
-
properties: {},
|
|
131
|
-
},
|
|
132
|
-
},
|
|
133
|
-
// The inner rung of CONTRACT §4's ladder, strictly shorter than Atlas's 300s gate.
|
|
134
|
-
{ timeout: ELICITATION_TIMEOUT_MS, relatedRequestId });
|
|
135
|
-
}
|
|
136
|
-
catch (error) {
|
|
137
|
-
if (isTimeout(error)) {
|
|
138
|
-
approvals.push({
|
|
139
|
-
description: ask.description,
|
|
140
|
-
decision: "deny",
|
|
141
|
-
reason: "timeout",
|
|
142
|
-
});
|
|
143
|
-
return { perm_id: ask.perm_id, decision: "deny", reason: "timeout" };
|
|
144
|
-
}
|
|
145
|
-
// An unexpected failure has no honest `reason` in CONTRACT §2's vocabulary, so it is
|
|
146
|
-
// not given one on the wire: the throw says "this side broke" without claiming a human
|
|
147
|
-
// decided anything. `runStreamed` catches it and sends the explicit deny the throw
|
|
148
|
-
// implies — see the comment there for why A1 cannot let it escape.
|
|
149
|
-
approvals.push({
|
|
150
|
-
description: ask.description,
|
|
151
|
-
decision: "deny",
|
|
152
|
-
reason: "error",
|
|
153
|
-
});
|
|
154
|
-
throw error;
|
|
155
|
-
}
|
|
156
|
-
// The SHAPE of the answer only — a fixed action enum and a boolean, never the description
|
|
157
|
-
// or anything a user typed. Logged so that what a client actually submits can be read next
|
|
158
|
-
// time rather than inferred from a compiled binary.
|
|
159
|
-
logger.info("askBrowserStackAI: elicitation answered %s", elicitationShape(answer));
|
|
160
|
-
const { decision, reason } = decide(answer);
|
|
161
|
-
approvals.push({ description: ask.description, decision, reason });
|
|
162
|
-
return { perm_id: ask.perm_id, decision, reason };
|
|
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
|
-
*/
|
|
194
|
-
/**
|
|
195
|
-
* CONTRACT v2 (A1) — drive one run over the stream.
|
|
196
|
-
*
|
|
197
|
-
* The loop is the whole orchestration: read events, elicit on each `permission`, POST
|
|
198
|
-
* the decision, hand the `result` to `buildResult`. It decides nothing itself —
|
|
199
|
-
* `relayOneAsk` owns the elicitation and the allow/deny mapping, unchanged from the
|
|
200
|
-
* transport it replaced. That is deliberate: the transport changed, the judgement did
|
|
201
|
-
* not, and the judgement is the part that is dangerous to get wrong.
|
|
202
|
-
*
|
|
203
|
-
* Reading pauses while a human is being prompted, which is correct rather than merely
|
|
204
|
-
* tolerable: Atlas is blocked on that decision and will emit nothing but heartbeats
|
|
205
|
-
* until it arrives, and heartbeats are dropped by the parser.
|
|
206
|
-
*
|
|
207
|
-
* A run that ends with no `result` is an error, not an empty success. A stream that
|
|
208
|
-
* simply stops is indistinguishable from a network drop, and reporting it as a finished
|
|
209
|
-
* run with no answer would be the transport quietly speaking for the agent.
|
|
210
|
-
*/
|
|
211
|
-
async function runStreamed(server, streamTransport, decisionTransport, url, headers, body, approvals, mode, product, relatedRequestId) {
|
|
212
|
-
let runId = "";
|
|
213
|
-
let result;
|
|
214
|
-
let sawResult = false;
|
|
215
|
-
// 200 unless the reply was not a stream at all, in which case the transport carries
|
|
216
|
-
// the real status — `relay.ts` needs it to tell 401 from 403 from a plain failure.
|
|
217
|
-
let resultStatus = 200;
|
|
218
|
-
for await (const event of streamTransport(url, headers, body)) {
|
|
219
|
-
if (event.event === EVENT_RUN) {
|
|
220
|
-
runId = String(event.data?.run_id || "");
|
|
221
|
-
continue;
|
|
222
|
-
}
|
|
223
|
-
if (event.event === EVENT_RESULT) {
|
|
224
|
-
result = event.data;
|
|
225
|
-
sawResult = true;
|
|
226
|
-
if (typeof event.status === "number")
|
|
227
|
-
resultStatus = event.status;
|
|
228
|
-
continue;
|
|
229
|
-
}
|
|
230
|
-
if (event.event !== EVENT_PERMISSION)
|
|
231
|
-
continue;
|
|
232
|
-
// Validated, not cast: a frame missing a usable `perm_id` or carrying a blank
|
|
233
|
-
// description cannot produce an answerable prompt, so it must not produce a prompt.
|
|
234
|
-
const ask = parseAsk(event.data);
|
|
235
|
-
if (!ask) {
|
|
236
|
-
logger.error("askBrowserStackAI: unusable permission ask on the stream; ignoring");
|
|
237
|
-
continue;
|
|
238
|
-
}
|
|
239
|
-
if (!runId) {
|
|
240
|
-
// Atlas emits `run` before any ask precisely so this cannot happen. If it does,
|
|
241
|
-
// there is nowhere to send a decision — so do not prompt a human for an answer
|
|
242
|
-
// that could never be delivered.
|
|
243
|
-
logger.error("askBrowserStackAI: permission ask arrived before run_id; cannot answer");
|
|
244
|
-
continue;
|
|
245
|
-
}
|
|
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" };
|
|
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);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
if (!sawResult) {
|
|
275
|
-
return errorResult("BrowserStack AI ended the run without a result. Nothing was changed " +
|
|
276
|
-
"beyond any step you already approved.", approvals);
|
|
277
|
-
}
|
|
278
|
-
// Shaped as an `AgentResponse` so `buildResult` — written for the transport A1 replaced
|
|
279
|
-
// and unchanged — sees exactly what it always saw.
|
|
280
|
-
return buildResult({ status: resultStatus, body: result }, approvals, mode, product);
|
|
281
|
-
}
|
|
282
|
-
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
|
|
290
|
-
// `elicitation` gets a read-only run whatever the deployment.
|
|
291
|
-
return server.server.getClientCapabilities()?.elicitation
|
|
292
|
-
? "offered"
|
|
293
|
-
: "no_human";
|
|
294
|
-
}
|
|
295
|
-
export function addAskBrowserStackAITool(server, deps, config) {
|
|
296
|
-
// A1 (CONTRACT v2) is the only path; A2 is gone. No version flag is needed to talk to
|
|
297
|
-
// an Atlas that predates the stream: such a server answers `POST /agent` with ordinary
|
|
298
|
-
// JSON, the parser sees no `text/event-stream`, and the run degrades to a read-only
|
|
299
|
-
// answer carrying that response's own status.
|
|
300
|
-
const streamTransport = deps.streamTransport || fetchAgentStreamTransport();
|
|
301
|
-
const decisionTransport = deps.decisionTransport || fetchDecisionTransport();
|
|
302
|
-
const tools = {};
|
|
303
|
-
/** Instrumentation in the house style, and never fatal to the call it wraps. */
|
|
304
|
-
const track = (name) => {
|
|
305
|
-
try {
|
|
306
|
-
trackMCP(name, server.server.getClientVersion(), undefined, config);
|
|
307
|
-
}
|
|
308
|
-
catch {
|
|
309
|
-
// Telemetry must not decide whether a tool call succeeds.
|
|
310
|
-
}
|
|
311
|
-
};
|
|
312
|
-
tools.askBrowserStackAI = server.tool("askBrowserStackAI", DESCRIPTION, {
|
|
313
|
-
product: z
|
|
314
|
-
.enum(PRODUCTS)
|
|
315
|
-
.describe("Which product to work in: tm (Test Management), " +
|
|
316
|
-
"tra (Test Reporting & Analytics)."),
|
|
317
|
-
query: z
|
|
318
|
-
.string()
|
|
319
|
-
.describe("What you want, in plain language. One thing per call."),
|
|
320
|
-
}, {
|
|
321
|
-
// It can write now, which is the whole point of the relay. Destructive operations
|
|
322
|
-
// stay refused, so `destructiveHint` is false for the same reason invokeEndpoint
|
|
323
|
-
// sets it false: consent is not a licence to delete.
|
|
324
|
-
readOnlyHint: false,
|
|
325
|
-
destructiveHint: false,
|
|
326
|
-
// openWorldHint: the agent fans out to product APIs chosen at runtime, so the set of
|
|
327
|
-
// effects is not knowable from this schema. idempotentHint false because a repeated
|
|
328
|
-
// call can create a second record — the relay asks again, it does not dedupe.
|
|
329
|
-
openWorldHint: true,
|
|
330
|
-
idempotentHint: false,
|
|
331
|
-
title: "Ask BrowserStack AI (Alpha)",
|
|
332
|
-
}, async ({ product, query }, extra) => {
|
|
333
|
-
track("askBrowserStackAI");
|
|
334
|
-
const approvals = [];
|
|
335
|
-
// Negotiated before anything else so the failure paths below report the mode they
|
|
336
|
-
// would have run in.
|
|
337
|
-
const mode = relayMode(server);
|
|
338
|
-
try {
|
|
339
|
-
const url = deps.agentUrl();
|
|
340
|
-
// Signed in BEFORE the listener is opened and before the run starts. Minting
|
|
341
|
-
// lazily mid-call would put a token round-trip inside the window where a human is
|
|
342
|
-
// being prompted, and a mint that failed there would strand an open port.
|
|
343
|
-
const headers = agentHeaders(await deps.mintToken());
|
|
344
|
-
const body = { task: query, product };
|
|
345
|
-
// Attribution, and now belt-and-braces rather than the source of truth: the minted
|
|
346
|
-
// JWT is user-attested, so Atlas sets `principal_verified=True` and takes the acting
|
|
347
|
-
// user from signed claims instead of this field. It is still sent because it is part
|
|
348
|
-
// of the frozen wire format (CONTRACT v1.2 §3) and dropping it would be a one-sided
|
|
349
|
-
// change — but nothing should trust it, and Atlas no longer does.
|
|
350
|
-
// Omitted ENTIRELY when unset, never sent as "".
|
|
351
|
-
const username = (deps.credentialsFor().username || "").trim();
|
|
352
|
-
if (username)
|
|
353
|
-
body.user_id = username;
|
|
354
|
-
// A1: asking for a stream costs nothing to set up — no port, no listener, no
|
|
355
|
-
// per-run bearer, because nothing dials in. Which is the whole point: the
|
|
356
|
-
// callback this replaces could never reach a laptop behind NAT, so the feature
|
|
357
|
-
// was read-only for every real user regardless of what was configured.
|
|
358
|
-
if (mode === "offered") {
|
|
359
|
-
body.permission_relay = { mode: "stream" };
|
|
360
|
-
}
|
|
361
|
-
else {
|
|
362
|
-
// Omitted ENTIRELY, not sent empty: its absence is what selects Atlas's
|
|
363
|
-
// read-only HeadlessGate.
|
|
364
|
-
logger.info("askBrowserStackAI: no permission relay (%s); running read-only", mode);
|
|
365
|
-
}
|
|
366
|
-
// `product` reaches the result so an entitlement refusal can name it: the flags
|
|
367
|
-
// are per product, and a bare "not enabled" sends the user to their admin
|
|
368
|
-
// asking about the wrong thing.
|
|
369
|
-
return toResult(await runStreamed(server, streamTransport, decisionTransport, url, headers, body, approvals, mode, product,
|
|
370
|
-
// The tool call's own id, so each elicitation is routed onto THIS request's
|
|
371
|
-
// stream. Over Streamable HTTP there is nowhere else for it to go.
|
|
372
|
-
extra?.requestId));
|
|
373
|
-
}
|
|
374
|
-
catch (error) {
|
|
375
|
-
const message = error instanceof AskError || error instanceof Error
|
|
376
|
-
? error.message
|
|
377
|
-
: String(error);
|
|
378
|
-
logger.error("askBrowserStackAI failed: %s", message);
|
|
379
|
-
// Error telemetry, in the same never-fatal shape as the success-path `track()`:
|
|
380
|
-
// a failing tool call must not be made worse by a failing analytics call.
|
|
381
|
-
try {
|
|
382
|
-
trackMCP("askBrowserStackAI", server.server.getClientVersion(), error, config);
|
|
383
|
-
}
|
|
384
|
-
catch {
|
|
385
|
-
/* ignore */
|
|
386
|
-
}
|
|
387
|
-
// No `canElicit` argument: the request never left this process, so whether the
|
|
388
|
-
// client could have been prompted is not what the reader needs to know.
|
|
389
|
-
return toResult(errorResult(message, approvals));
|
|
390
|
-
}
|
|
391
|
-
// No teardown: A1 opens no port and binds nothing, so there is nothing that can
|
|
392
|
-
// leak across calls or survive an error. The stream is closed by its own
|
|
393
|
-
// iteration ending, and Atlas drops the run when the response completes.
|
|
394
|
-
});
|
|
395
|
-
return tools;
|
|
396
|
-
}
|
|
397
|
-
/** The tool-adder the server factory calls. */
|
|
398
|
-
export function addAskBrowserStackAIToolFromConfig(server, config) {
|
|
399
|
-
if (!isEnabled()) {
|
|
400
|
-
logger.info("askBrowserStackAI disabled by ASK_BROWSERSTACK_DISABLED");
|
|
401
|
-
return {};
|
|
402
|
-
}
|
|
403
|
-
const credentials = () => ({
|
|
404
|
-
username: config["browserstack-username"],
|
|
405
|
-
accessKey: config["browserstack-access-key"],
|
|
406
|
-
});
|
|
407
|
-
const tokenTransport = fetchTokenTransport();
|
|
408
|
-
return addAskBrowserStackAITool(server, {
|
|
409
|
-
// Both resolved per call. An unconfigured host surfaces as a named error from the
|
|
410
|
-
// tool rather than as a missing tool, so the cause is visible to whoever hits it.
|
|
411
|
-
agentUrl,
|
|
412
|
-
mintToken: () => mintCentralToken(authTokenUrl(), credentials(), tokenTransport),
|
|
413
|
-
credentialsFor: credentials,
|
|
414
|
-
}, config);
|
|
415
|
-
}
|
|
416
|
-
export default addAskBrowserStackAIToolFromConfig;
|
|
@@ -1,201 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The decision mapping and the result assembly — the two places where being wrong is
|
|
3
|
-
* expensive, kept pure so they can be tested without a server, a socket or a client.
|
|
4
|
-
*/
|
|
5
|
-
import { ElicitResult } from "@modelcontextprotocol/sdk/types.js";
|
|
6
|
-
import { AgentResponse } from "./egress.js";
|
|
7
|
-
import { ApprovalRecord, AskResult, AskStatus, Decision, DecisionReason, RelayMode } from "./types.js";
|
|
8
|
-
export declare const RELAY_ON_DETAIL: string;
|
|
9
|
-
/**
|
|
10
|
-
* Used when the channel worked and every ask came back with nobody behind it.
|
|
11
|
-
*
|
|
12
|
-
* `RELAY_ON_DETAIL` cannot be used here and saying it was a bug, observed live: it claims
|
|
13
|
-
* "BrowserStack asked before each change and the answers are in `approvals`" while the
|
|
14
|
-
* trail says `refused: nobody was there to be asked`. Both sentences were in the same
|
|
15
|
-
* result, contradicting each other — the same class of confusion as `disabled` vs a human
|
|
16
|
-
* saying no, and it is the reader who pays for it. The channel being usable and a person
|
|
17
|
-
* actually answering are different facts, and only the second one licenses the word
|
|
18
|
-
* "answers".
|
|
19
|
-
*/
|
|
20
|
-
export declare const RELAY_ON_NO_ANSWER_DETAIL: string;
|
|
21
|
-
/**
|
|
22
|
-
* One sentence per way the relay can fail to run, because they call for different things
|
|
23
|
-
* from the person reading them, and a caller who cannot tell them apart retries forever.
|
|
24
|
-
*
|
|
25
|
-
* `no_human` is ours (CONTRACT §7's last row); the rest are Atlas's (v1.1 §D).
|
|
26
|
-
*/
|
|
27
|
-
export declare const RELAY_OFF_DETAILS: Record<string, string>;
|
|
28
|
-
/** Kept for anything still importing the old single constant. */
|
|
29
|
-
export declare const RELAY_OFF_DETAIL: string;
|
|
30
|
-
/** The sentence that goes with a `{used, reason}` pair, whoever produced it. */
|
|
31
|
-
export declare function relayDetail(used: boolean, reason: string): string;
|
|
32
|
-
/**
|
|
33
|
-
* Did this request die before the agent ever started?
|
|
34
|
-
*
|
|
35
|
-
* Atlas omits its `permission_relay` verdict on refusals that never reach the delegation
|
|
36
|
-
* layer — 401 unauthorized, 400 bad body, 503 delegation-not-enabled all answer with a bare
|
|
37
|
-
* `{"detail": …}` — and a transport failure has no body at all. Read naively, "no verdict"
|
|
38
|
-
* looks identical to "an Atlas older than v1.1", and the optimistic fallback for THAT case
|
|
39
|
-
* then claims the channel was used and answers were collected when zero prompts appeared.
|
|
40
|
-
*
|
|
41
|
-
* Which is the same confusion the `disabled` sentence exists to prevent, one layer earlier:
|
|
42
|
-
* a caller who cannot tell "nobody was asked" from "somebody said no" retries forever.
|
|
43
|
-
*/
|
|
44
|
-
export declare function looksLikeDelegationResult(body: unknown): boolean;
|
|
45
|
-
/**
|
|
46
|
-
* Is this Atlas saying the ACCOUNT is not enabled for the product's agent?
|
|
47
|
-
*
|
|
48
|
-
* Atlas gained an entitlement gate on `POST /agent` (`flags.is_agent_enabled`, AIC-386) that
|
|
49
|
-
* the WebSocket path already had. The flags are PER PRODUCT — `aiHarnessAgent` for tm,
|
|
50
|
-
* `aiHarnessAgentTRA`, `aiHarnessAgentA11y` — so an account can be entitled for one product
|
|
51
|
-
* and not another. It is also FAIL-OPEN on Atlas's side: Redis down, a flag never seeded, or
|
|
52
|
-
* an unknown product all allow the request. So a 403 here is a real, deliberate "this account
|
|
53
|
-
* is not enabled", never an outage.
|
|
54
|
-
*
|
|
55
|
-
* KEYED STRUCTURALLY, NEVER ON THE PROSE. Matching the sentence would break silently the
|
|
56
|
-
* first time someone rewords it, falling through to a generic error.
|
|
57
|
-
*
|
|
58
|
-
* TODO(atlas/9.md): Atlas is deciding whether to add a `code` field to this body. When it
|
|
59
|
-
* lands, prefer it over the status — add the check as the first rung here and leave the
|
|
60
|
-
* status as the fallback for an older Atlas. Until then the status IS the structural signal.
|
|
61
|
-
*/
|
|
62
|
-
export declare function isNotEntitled(response: AgentResponse): boolean;
|
|
63
|
-
/**
|
|
64
|
-
* The sentence the user asked for, with the product named.
|
|
65
|
-
*
|
|
66
|
-
* Entitlement is per product, so a bare "not enabled" sends someone to their admin asking
|
|
67
|
-
* about the wrong thing. The disambiguation from a 401 is deliberate and load-bearing:
|
|
68
|
-
* without it, a working access key gets rotated in response to a permissions problem.
|
|
69
|
-
*/
|
|
70
|
-
export declare const NOT_ENTITLED_DETAIL: (product: string) => string;
|
|
71
|
-
export declare function neverReachedAgent(response: AgentResponse): boolean;
|
|
72
|
-
/**
|
|
73
|
-
* Atlas's own verdict on the relay (v1.1 §D), when it gave one.
|
|
74
|
-
*
|
|
75
|
-
* Present ONLY when we supplied a `permission_relay` block, so its absence is either "we
|
|
76
|
-
* never offered one" or "this Atlas predates v1.1" — neither of which is an error. A block
|
|
77
|
-
* we cannot read is treated as no block at all rather than half-trusted.
|
|
78
|
-
*/
|
|
79
|
-
export declare function atlasRelayVerdict(payload: Record<string, unknown>): {
|
|
80
|
-
used: boolean;
|
|
81
|
-
reason: string;
|
|
82
|
-
} | null;
|
|
83
|
-
/**
|
|
84
|
-
* Product-language framing for the prompt (v1.1 §G, approved).
|
|
85
|
-
*
|
|
86
|
-
* `product` is the ONLY thing added — it is all §2 carries, and the route, method and path
|
|
87
|
-
* never reach this side by design. The description itself is passed through untouched:
|
|
88
|
-
* paraphrasing or truncating it would mean the human approves something other than what the
|
|
89
|
-
* model actually said. Atlas sends it as the model wrote it (its route guard was removed —
|
|
90
|
-
* CONTRACT v2 §3, amended), so this prefix is the only thing in front of the model's own
|
|
91
|
-
* sentence. A placeholder can still arrive from an older Atlas and reads fine after it.
|
|
92
|
-
*/
|
|
93
|
-
export declare const PRODUCT_LABELS: Record<string, string>;
|
|
94
|
-
export declare function elicitationMessage(product: string, description: string): string;
|
|
95
|
-
/**
|
|
96
|
-
* CONTRACT §7. THE ACTION IS THE WHOLE ANSWER.
|
|
97
|
-
*
|
|
98
|
-
* | accept | allow | "" |
|
|
99
|
-
* | decline | deny | declined |
|
|
100
|
-
* | cancel | deny | cancelled |
|
|
101
|
-
*
|
|
102
|
-
* Nothing is requested in the form any more, so nothing can contradict the action. There used
|
|
103
|
-
* to be a `confirm` boolean, and it had to go: with an `accept` action that ALREADY means the
|
|
104
|
-
* human approved, `accept` + `confirm: false` is genuinely ambiguous between "I approved, and
|
|
105
|
-
* a checkbox I never saw defaulted to false" and "I unticked it deliberately". The first is a
|
|
106
|
-
* FALSE DENIAL — indistinguishable in the result from a human refusing, which is the exact
|
|
107
|
-
* confusion D3 and N1 existed to remove — and a user hit it live. We cannot tell the two
|
|
108
|
-
* apart, and guessing either way is wrong for the other. `decline` already gives an
|
|
109
|
-
* unambiguous refusal in the same dialog, so the boolean bought nothing.
|
|
110
|
-
*
|
|
111
|
-
* FAIL-CLOSED IS UNCHANGED, and the boolean was never what provided it. A headless client with
|
|
112
|
-
* no human at a terminal returns `cancel` — measured, not assumed (HANDOFF.md) — and `cancel`
|
|
113
|
-
* is a deny. That is why an unattended run still cannot self-approve. It is also why an
|
|
114
|
-
* elicitation is never retried: a second ask cannot conjure a human, only wear one down.
|
|
115
|
-
*/
|
|
116
|
-
export declare function decide(result: ElicitResult): {
|
|
117
|
-
decision: Decision;
|
|
118
|
-
reason: DecisionReason;
|
|
119
|
-
};
|
|
120
|
-
/**
|
|
121
|
-
* A one-line description of the SHAPE of what a client answered with — never its content.
|
|
122
|
-
*
|
|
123
|
-
* Which client sends what is currently guesswork: the elicitation bug in task 9 had to be
|
|
124
|
-
* fixed without being able to confirm what Claude Code actually submits, because its binary
|
|
125
|
-
* is compiled and its strings too fragmented to read. This line means the next person can
|
|
126
|
-
* look it up instead of inferring it.
|
|
127
|
-
*
|
|
128
|
-
* `action` and `confirm` are a fixed enum and a boolean; neither can carry a description, a
|
|
129
|
-
* credential or anything else a user typed.
|
|
130
|
-
*/
|
|
131
|
-
export declare function elicitationShape(result: ElicitResult): string;
|
|
132
|
-
/**
|
|
133
|
-
* Read Atlas's `applied_before_stop`. NEVER DERIVE IT.
|
|
134
|
-
*
|
|
135
|
-
* This side used to compute it as CONTRACT §5's literal "any allow preceded a deny", which
|
|
136
|
-
* could only ever be a guess: an approval whose request then failed counted as applied, so
|
|
137
|
-
* the field lied in the exact direction it exists to prevent (D2). Atlas now computes it
|
|
138
|
-
* from `applied`, which only Atlas can know, and sends it whenever a gate ran — including
|
|
139
|
-
* `false`, including with an empty trail.
|
|
140
|
-
*
|
|
141
|
-
* So a MISSING field is never "false". It is "nobody measured this": either no gate ran, or
|
|
142
|
-
* this Atlas predates the field. `null` says that out loud instead of asserting a fact.
|
|
143
|
-
*/
|
|
144
|
-
export declare function readAppliedBeforeStop(payload: Record<string, unknown>): boolean | null;
|
|
145
|
-
/**
|
|
146
|
-
* Atlas's approval trail, when it sent one.
|
|
147
|
-
*
|
|
148
|
-
* Returns `null` — not `[]` — when the key is absent, because an empty trail Atlas DID send
|
|
149
|
-
* ("the relay ran and nothing was asked") is a different fact from no trail at all, and only
|
|
150
|
-
* the second is a reason to fall back to ours.
|
|
151
|
-
*
|
|
152
|
-
* Every entry is rebuilt rather than trusted: a `decision` that is not exactly `"allow"`
|
|
153
|
-
* becomes `"deny"`, so a garbled trail fails closed in the reporting the same way the wire
|
|
154
|
-
* does, and `applied` is carried only when it is genuinely a boolean.
|
|
155
|
-
*/
|
|
156
|
-
export declare function parseAtlasApprovals(payload: Record<string, unknown>): ApprovalRecord[] | null;
|
|
157
|
-
/**
|
|
158
|
-
* One phrase per entry, because "approved, then it failed" and "refused" must not read
|
|
159
|
-
* alike — conflating them is the whole reason D2 mattered.
|
|
160
|
-
*
|
|
161
|
-
* An `allow` with no `applied` key is NOT rendered as a failure: nobody measured it, and
|
|
162
|
-
* saying otherwise would invent the very fact this is meant to report.
|
|
163
|
-
*/
|
|
164
|
-
/**
|
|
165
|
-
* Every ask was refused because no person was there — not because one said no.
|
|
166
|
-
*
|
|
167
|
-
* `cancelled` is what a client with nobody at the terminal returns (measured), and
|
|
168
|
-
* `no_human` is our own word for the same thing. An EMPTY trail is not this case: nothing
|
|
169
|
-
* was asked at all, which the existing sentences already describe correctly.
|
|
170
|
-
*/
|
|
171
|
-
export declare function nobodyAnswered(trail: ApprovalRecord[]): boolean;
|
|
172
|
-
export declare function approvalOutcome(entry: ApprovalRecord): string;
|
|
173
|
-
/**
|
|
174
|
-
* Atlas's own status wins when it declares one; otherwise it is derived from what we can
|
|
175
|
-
* see. Deriving is a last resort, not an interpretation of the answer.
|
|
176
|
-
*/
|
|
177
|
-
export declare function deriveStatus(response: AgentResponse, approvals: ApprovalRecord[], needsApproval: unknown[]): AskStatus;
|
|
178
|
-
/** Assemble CONTRACT §5's result. Atlas's payload is carried, never rewritten. */
|
|
179
|
-
export declare function buildResult(response: AgentResponse, approvals: ApprovalRecord[], mode: RelayMode,
|
|
180
|
-
/** Named in the not-entitled sentence, because entitlement is per product. */
|
|
181
|
-
product?: string): AskResult;
|
|
182
|
-
/**
|
|
183
|
-
* A rejected credential and a refused action are unrelated problems, and a result that lets
|
|
184
|
-
* them read alike sends someone hunting for a human who said no when the real answer is that
|
|
185
|
-
* this server never got through the door.
|
|
186
|
-
*
|
|
187
|
-
* Atlas answers a bad `Authorization` with `401 {"detail": "unauthorized"}` — no `error`
|
|
188
|
-
* string of its own — so without this the caller would see a bare "error" and nothing else.
|
|
189
|
-
* A denial, by contrast, is `status: "blocked"` with a populated `approvals` trail.
|
|
190
|
-
*
|
|
191
|
-
* The token itself is NOT named here, only the variable that should hold it.
|
|
192
|
-
*/
|
|
193
|
-
export declare const UNAUTHENTICATED_DETAIL: string;
|
|
194
|
-
/**
|
|
195
|
-
* A result for a call that never reached, or never got past, Atlas.
|
|
196
|
-
*
|
|
197
|
-
* It keeps §5's shape — including the approval trail — because a failure AFTER an approval
|
|
198
|
-
* was granted is exactly the case where a caller most needs to know something may already
|
|
199
|
-
* have been applied.
|
|
200
|
-
*/
|
|
201
|
-
export declare function errorResult(message: string, approvals: ApprovalRecord[]): AskResult;
|