@browserstack/mcp-server 2.2.0-beta.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +42 -432
  2. package/capability/loadtesting.capability-index.json +25 -2
  3. package/capability/tm.capability-index.json +1 -2
  4. package/dist/config.d.ts +27 -2
  5. package/dist/config.js +19 -6
  6. package/dist/lib/instrumentation.js +29 -3
  7. package/dist/lib/utils.js +8 -0
  8. package/dist/tools/accessiblity-utils/scanner.js +1 -1
  9. package/dist/tools/ask-browserstack/central-oauth.d.ts +46 -25
  10. package/dist/tools/ask-browserstack/central-oauth.js +177 -55
  11. package/dist/tools/ask-browserstack/config.d.ts +0 -14
  12. package/dist/tools/ask-browserstack/config.js +0 -16
  13. package/dist/tools/ask-browserstack/deferred.d.ts +74 -0
  14. package/dist/tools/ask-browserstack/deferred.js +242 -0
  15. package/dist/tools/ask-browserstack/register.d.ts +3 -2
  16. package/dist/tools/ask-browserstack/register.js +219 -72
  17. package/dist/tools/ask-browserstack/relay.js +11 -17
  18. package/dist/tools/ask-browserstack/stream.d.ts +27 -5
  19. package/dist/tools/ask-browserstack/stream.js +25 -14
  20. package/dist/tools/ask-browserstack/types.d.ts +54 -3
  21. package/dist/tools/ask-browserstack/types.js +11 -1
  22. package/dist/tools/automate-utils/fetch-screenshots.js +17 -15
  23. package/dist/tools/capability-registry/bind.js +7 -43
  24. package/dist/tools/capability-registry/discovery.js +1 -3
  25. package/dist/tools/capability-registry/egress.d.ts +5 -12
  26. package/dist/tools/capability-registry/egress.js +1 -24
  27. package/dist/tools/capability-registry/redact.js +7 -5
  28. package/dist/tools/capability-registry/register.js +41 -289
  29. package/dist/tools/capability-registry/resolve.d.ts +1 -1
  30. package/dist/tools/capability-registry/resolve.js +3 -24
  31. package/dist/tools/capability-registry/search.d.ts +1 -28
  32. package/dist/tools/capability-registry/search.js +4 -202
  33. package/dist/tools/capability-registry/types.d.ts +3 -42
  34. package/dist/tools/testmanagement.js +5 -5
  35. package/dist/tools/tool-handoff.d.ts +12 -15
  36. package/dist/tools/tool-handoff.js +18 -21
  37. package/package.json +3 -4
  38. package/capability/tra.capability-index.json +0 -52074
  39. package/dist/lib/central-oauth.d.ts +0 -133
  40. package/dist/lib/central-oauth.js +0 -246
  41. package/dist/tools/capability-registry/oauth.d.ts +0 -63
  42. package/dist/tools/capability-registry/oauth.js +0 -176
  43. package/dist/tools/capability-registry/routing-token.d.ts +0 -25
  44. package/dist/tools/capability-registry/routing-token.js +0 -87
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The two-call shape: ask now, decide later, with nothing held open in between.
3
+ *
4
+ * WHY IT EXISTS. Elicitation is what makes this process stateful — a suspended
5
+ * `elicitation/create` pins an McpServer in one pod's heap until the answer arrives, so
6
+ * the hosted deployment needs sessions, ingress affinity keyed on the caller's
7
+ * credentials, and a cap on how many sessions may be alive. Deferred removes the
8
+ * suspension rather than routing around it: Atlas returns at its first ask, we hand that
9
+ * ask back, and the caller returns with the decision in a SECOND call. Between the two
10
+ * this process holds nothing, so any pod can serve either.
11
+ *
12
+ * WHAT IT COSTS, stated plainly because it is not free:
13
+ * - Approval rides the CLIENT's own permission prompt on the second call, not an
14
+ * elicitation. A client configured not to prompt (`--permission-mode auto`) approves
15
+ * with no human. Elicitation fails closed by construction; this fails closed only if
16
+ * the client asks. That is the trade, and it is why stdio keeps elicitation.
17
+ * - One extra round trip per APPROVAL, not per task. Atlas's gate is serial, so a task
18
+ * with five writes is five approvals and six calls.
19
+ *
20
+ * The run itself is parked on Atlas's side for the gate's own 300s, kept alive exactly as
21
+ * a dropped reader leaves it (CONTRACT v2 §4.2). Nothing here has to manage that.
22
+ */
23
+ import { DeferredTransport } from "./stream.js";
24
+ import { AgentRequest, AskResult, RelayMode } from "./types.js";
25
+ /** What a parked run needs the caller to send back. */
26
+ export interface ParkedAsk {
27
+ perm_id: string;
28
+ description: string;
29
+ product: string;
30
+ mode: string;
31
+ }
32
+ /**
33
+ * Atlas's `needs_approval` payload → our result, or `null` when it is not one.
34
+ *
35
+ * Null rather than a throw: a deferred request whose run never needed approval comes
36
+ * back as an ORDINARY result, and that is a success to pass straight through — not an
37
+ * unexpected shape. A read-only task must cost one call, not two.
38
+ */
39
+ export declare function parkedResult(body: unknown, product: string): {
40
+ runId: string;
41
+ ask: ParkedAsk;
42
+ } | null;
43
+ /** CONTRACT §5's shape, for a run that is parked rather than finished. */
44
+ export declare function needsApprovalResult(runId: string, ask: ParkedAsk, product: string): AskResult;
45
+ /** The FIRST call: run until the first ask, then answer. Nothing is held open. */
46
+ export declare function runDeferred(url: string, headers: Record<string, string>, body: AgentRequest, transport: DeferredTransport, mode: RelayMode, product: string): Promise<AskResult>;
47
+ /**
48
+ * The SECOND call: deliver the decision, then pick the run back up.
49
+ *
50
+ * Two requests, in this order, and the order is the safety property: the decision is
51
+ * posted FIRST so that a reattach which then fails cannot leave the gate waiting on an
52
+ * answer we already have. A lost reattach costs the caller the answer; a lost decision
53
+ * would cost them the run.
54
+ */
55
+ export declare function runResumed(agentUrl: string, headers: Record<string, string>, runId: string, permId: string, decision: "allow" | "deny", transport: DeferredTransport, mode: RelayMode, product: string): Promise<AskResult>;
56
+ /** A resume that did not happen. Every field says "nothing changed", because in each
57
+ * case nothing did — and a caller that cannot tell a failed resume from a refusal
58
+ * either retries a write that landed or abandons one that never did. */
59
+ export declare function resumeFailed(error: string): AskResult;
60
+ /** A decision that went out and whose reply never came back.
61
+ *
62
+ * Deliberately NOT `resumeFailed`: every field there says "nothing changed", and that
63
+ * is a claim this case cannot make. The decision request carries the continuation now,
64
+ * so a lost reply covers everything from "it never arrived" to "the write landed and
65
+ * the answer was lost" — and telling a model nothing happened is what gets the change
66
+ * made twice.
67
+ *
68
+ * `applied_before_stop: null` for the same reason: null is "not measured", which is
69
+ * true, where false would be a measurement nobody took. */
70
+ export declare function decisionUnknown(): AskResult;
71
+ /** Starting a run with no task. Only reachable now that `query` is optional, which it
72
+ * has to be so a model can follow `next_step` — which omits it — literally. */
73
+ export declare function missingQuery(): AskResult;
74
+ export declare function incompleteResume(): AskResult;
@@ -0,0 +1,242 @@
1
+ /**
2
+ * The two-call shape: ask now, decide later, with nothing held open in between.
3
+ *
4
+ * WHY IT EXISTS. Elicitation is what makes this process stateful — a suspended
5
+ * `elicitation/create` pins an McpServer in one pod's heap until the answer arrives, so
6
+ * the hosted deployment needs sessions, ingress affinity keyed on the caller's
7
+ * credentials, and a cap on how many sessions may be alive. Deferred removes the
8
+ * suspension rather than routing around it: Atlas returns at its first ask, we hand that
9
+ * ask back, and the caller returns with the decision in a SECOND call. Between the two
10
+ * this process holds nothing, so any pod can serve either.
11
+ *
12
+ * WHAT IT COSTS, stated plainly because it is not free:
13
+ * - Approval rides the CLIENT's own permission prompt on the second call, not an
14
+ * elicitation. A client configured not to prompt (`--permission-mode auto`) approves
15
+ * with no human. Elicitation fails closed by construction; this fails closed only if
16
+ * the client asks. That is the trade, and it is why stdio keeps elicitation.
17
+ * - One extra round trip per APPROVAL, not per task. Atlas's gate is serial, so a task
18
+ * with five writes is five approvals and six calls.
19
+ *
20
+ * The run itself is parked on Atlas's side for the gate's own 300s, kept alive exactly as
21
+ * a dropped reader leaves it (CONTRACT v2 §4.2). Nothing here has to manage that.
22
+ */
23
+ import logger from "../../logger.js";
24
+ import { RELAY_CONTRACT, decisionUrl, parseAsk, } from "./stream.js";
25
+ import { RELAY_OFF_DETAILS, buildResult } from "./relay.js";
26
+ function asRecord(value) {
27
+ return value && typeof value === "object" && !Array.isArray(value)
28
+ ? value
29
+ : {};
30
+ }
31
+ /**
32
+ * Atlas's `needs_approval` payload → our result, or `null` when it is not one.
33
+ *
34
+ * Null rather than a throw: a deferred request whose run never needed approval comes
35
+ * back as an ORDINARY result, and that is a success to pass straight through — not an
36
+ * unexpected shape. A read-only task must cost one call, not two.
37
+ */
38
+ export function parkedResult(body, product) {
39
+ const payload = asRecord(body);
40
+ if (payload.status !== "needs_approval")
41
+ return null;
42
+ const runId = String(payload.run_id || "");
43
+ const asks = Array.isArray(payload.asks) ? payload.asks : [];
44
+ // Validated, not cast, for the same reason the streaming path validates: an ask with
45
+ // no usable `perm_id` cannot be answered, so handing it to a caller would invite a
46
+ // second call that can only 404.
47
+ const ask = asks.length ? parseAsk(asks[0]) : null;
48
+ if (!runId || !ask) {
49
+ logger.error("askBrowserStackAI: deferred run parked without an answerable ask; treating as a plain result");
50
+ return null;
51
+ }
52
+ return {
53
+ runId,
54
+ ask: {
55
+ perm_id: ask.perm_id,
56
+ description: ask.description,
57
+ product: ask.product || product,
58
+ mode: ask.mode,
59
+ },
60
+ };
61
+ }
62
+ /** CONTRACT §5's shape, for a run that is parked rather than finished. */
63
+ export function needsApprovalResult(runId, ask, product) {
64
+ return {
65
+ // NOT ok, and not an error either: nothing was applied and nothing failed. A caller
66
+ // that branches on `ok` alone must not read a parked run as a finished one.
67
+ ok: false,
68
+ status: "needs_approval",
69
+ // Addressed to the MODEL, and imperative, because the model is what reads it and
70
+ // the one thing that must happen next is not something it can do by itself.
71
+ answer: `A HUMAN MUST APPROVE THIS BEFORE IT CAN CONTINUE. BrowserStack AI stopped at: ` +
72
+ `${ask.description}. Nothing has been changed yet.\n\n` +
73
+ `Put this to the user and WAIT for their answer. If you have a way to ask them a ` +
74
+ `structured question or show a confirmation — any prompt, question or approval ` +
75
+ `affordance your client offers — use that; otherwise ask plainly in your reply. ` +
76
+ `Quote what is being done either way.\n\n` +
77
+ `Do NOT decide on their behalf, and do not treat "it is what they asked for" as ` +
78
+ `approval: they are being asked to confirm this specific change, which is a ` +
79
+ `different question. Once they have answered, follow \`next_step\`.`,
80
+ approvals: [],
81
+ approvals_source: "atlas",
82
+ elicitations: [],
83
+ needs_approval: [{ description: ask.description, mode: ask.mode }],
84
+ applied_before_stop: false,
85
+ permission_relay: {
86
+ used: true,
87
+ reason: "",
88
+ // Terse on purpose: the actionable half lives in `next_step`, and an explanation
89
+ // of the transport here is what the model read instead of the instruction.
90
+ detail: "Approval is pending a human answer; see `next_step`.",
91
+ },
92
+ atlas_response: null,
93
+ run_id: runId,
94
+ perm_id: ask.perm_id,
95
+ next_step: {
96
+ instruction:
97
+ // Deliberately names no tool and no client. The affordance differs everywhere
98
+ // — a structured question, a confirmation dialog, an approval prompt, or
99
+ // nothing at all — and naming one would be wrong on every other client and
100
+ // would rot as clients change. Describe the ACT, let the model pick the means.
101
+ "AFTER the user has answered — not before — call askBrowserStackAI again " +
102
+ "with exactly these params, setting `decision` to what THEY said. If they " +
103
+ 'declined, send "deny": that is a valid answer and stops the task cleanly. ' +
104
+ "If they have not answered yet, do not call anything.",
105
+ call: "askBrowserStackAI",
106
+ params: {
107
+ product,
108
+ run_id: runId,
109
+ perm_id: ask.perm_id,
110
+ decision: "allow | deny — the user's answer, not your own judgement",
111
+ },
112
+ },
113
+ };
114
+ }
115
+ /** The FIRST call: run until the first ask, then answer. Nothing is held open. */
116
+ export async function runDeferred(url, headers, body, transport, mode, product) {
117
+ const response = await transport(url, headers, {
118
+ ...body,
119
+ permission_relay: { mode: "deferred", contract: RELAY_CONTRACT },
120
+ });
121
+ const parked = parkedResult(response.body, product);
122
+ if (parked) {
123
+ logger.info("askBrowserStackAI: run parked awaiting approval (run=%s)", parked.runId);
124
+ return needsApprovalResult(parked.runId, parked.ask, product);
125
+ }
126
+ // No ask: an ordinary result, passed through the same builder the streaming path
127
+ // uses so an entitlement refusal, a 401 and a plain failure all read identically
128
+ // whichever transport produced them.
129
+ return buildResult(response, [], mode, product);
130
+ }
131
+ /**
132
+ * The SECOND call: deliver the decision, then pick the run back up.
133
+ *
134
+ * Two requests, in this order, and the order is the safety property: the decision is
135
+ * posted FIRST so that a reattach which then fails cannot leave the gate waiting on an
136
+ * answer we already have. A lost reattach costs the caller the answer; a lost decision
137
+ * would cost them the run.
138
+ */
139
+ export async function runResumed(agentUrl, headers, runId, permId, decision, transport, mode, product) {
140
+ // ONE CALL. This used to POST the decision, check for a 204, and then reattach to
141
+ // `GET /agent/{run_id}/stream` to collect what the run did next — two requests, and a
142
+ // reattach that only existed because the run was a paused coroutine in one pod. The
143
+ // ask parks in Atlas's checkpoint now, so the decision request IS the one that carries
144
+ // the run forward and the continuation comes back in its own reply, from any replica.
145
+ let response;
146
+ try {
147
+ response = await transport(decisionUrl(agentUrl, runId), headers, {
148
+ perm_id: permId,
149
+ decision,
150
+ reason: "",
151
+ // Required. The run resumes on a fresh Agent that must be scoped before it can be
152
+ // built, and Atlas deliberately will not take that scope from persisted state.
153
+ product,
154
+ });
155
+ }
156
+ catch {
157
+ // CAUGHT HERE rather than left to the handler's outer catch, which cannot tell this
158
+ // apart from a request that never left and so reports `not_reached` — "nothing was
159
+ // changed" — for a decision that may well have completed.
160
+ logger.warn("askBrowserStackAI: no reply to the decision on run %s; outcome unknown", runId);
161
+ return decisionUnknown();
162
+ }
163
+ if (response.status === 404) {
164
+ // The only failure worth its own words: there is no such parked run. It expired, it
165
+ // was already answered, or this is not the caller that owns it. None of those is an
166
+ // approval, and none of them changed anything.
167
+ return resumeFailed(`That approval could not be applied (HTTP 404). The run may have expired — ` +
168
+ `BrowserStack AI stops waiting after five minutes — or it may already have ` +
169
+ `been answered. Nothing was changed by this call. Start the task again if it ` +
170
+ `still needs doing.`);
171
+ }
172
+ // PARKED AGAIN: the task needs more than one approval. Handed straight back rather
173
+ // than answered here, because the whole point is that a human decides each one.
174
+ const parked = parkedResult(response.body, product);
175
+ if (parked) {
176
+ logger.info("askBrowserStackAI: run parked again after a decision (run=%s)", runId);
177
+ return needsApprovalResult(parked.runId, parked.ask, product);
178
+ }
179
+ // Finished. Through the SAME builder the start path uses, so an entitlement refusal,
180
+ // a 401 and a plain failure read identically whichever call produced them.
181
+ return buildResult(response, [], mode, product);
182
+ }
183
+ /** A resume that did not happen. Every field says "nothing changed", because in each
184
+ * case nothing did — and a caller that cannot tell a failed resume from a refusal
185
+ * either retries a write that landed or abandons one that never did. */
186
+ export function resumeFailed(error) {
187
+ return {
188
+ ok: false,
189
+ status: "error",
190
+ answer: null,
191
+ approvals: [],
192
+ approvals_source: "mcp",
193
+ elicitations: [],
194
+ needs_approval: [],
195
+ applied_before_stop: false,
196
+ permission_relay: { used: true, reason: "", detail: "" },
197
+ atlas_response: null,
198
+ error,
199
+ };
200
+ }
201
+ /** A decision that went out and whose reply never came back.
202
+ *
203
+ * Deliberately NOT `resumeFailed`: every field there says "nothing changed", and that
204
+ * is a claim this case cannot make. The decision request carries the continuation now,
205
+ * so a lost reply covers everything from "it never arrived" to "the write landed and
206
+ * the answer was lost" — and telling a model nothing happened is what gets the change
207
+ * made twice.
208
+ *
209
+ * `applied_before_stop: null` for the same reason: null is "not measured", which is
210
+ * true, where false would be a measurement nobody took. */
211
+ export function decisionUnknown() {
212
+ return {
213
+ ok: false,
214
+ status: "error",
215
+ answer: null,
216
+ approvals: [],
217
+ approvals_source: "mcp",
218
+ elicitations: [],
219
+ needs_approval: [],
220
+ applied_before_stop: null,
221
+ permission_relay: {
222
+ used: true,
223
+ reason: "outcome_unknown",
224
+ detail: RELAY_OFF_DETAILS.outcome_unknown,
225
+ },
226
+ atlas_response: null,
227
+ error: "The approval was sent to BrowserStack AI but no reply came back, so it is not " +
228
+ "known whether the step completed. Nothing should be retried on this run.",
229
+ };
230
+ }
231
+ /** Starting a run with no task. Only reachable now that `query` is optional, which it
232
+ * has to be so a model can follow `next_step` — which omits it — literally. */
233
+ export function missingQuery() {
234
+ return resumeFailed("`query` is required to start a run: say what you want in plain language. " +
235
+ "(It is only omitted when answering a pending approval, which also needs " +
236
+ "`run_id`, `perm_id` and `decision`.) Nothing was sent, so nothing changed.");
237
+ }
238
+ export function incompleteResume() {
239
+ return resumeFailed("Resuming needs all three of `run_id`, `perm_id` and `decision`. Nothing was " +
240
+ "sent, so nothing changed and the pending approval is still waiting — ask the " +
241
+ "user whether to allow it and call again with all three.");
242
+ }
@@ -26,7 +26,7 @@
26
26
  import { McpServer, RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js";
27
27
  import { BrowserStackConfig } from "../../lib/types.js";
28
28
  import { Credentials } from "./egress.js";
29
- import type { AgentStreamTransport, DecisionTransport } from "./stream.js";
29
+ import type { AgentStreamTransport, DeferredTransport } from "./stream.js";
30
30
  import { RelayMode } from "./types.js";
31
31
  export interface AskDeps {
32
32
  /** Resolved per call: a deployment's host is configuration, not a constructor argument. */
@@ -52,7 +52,8 @@ export interface AskDeps {
52
52
  * — ask, elicit, decide, result — without a socket.
53
53
  */
54
54
  streamTransport?: AgentStreamTransport;
55
- decisionTransport?: DecisionTransport;
55
+ /** The deferred START (one request, one JSON response). Substituted in tests. */
56
+ deferredTransport?: DeferredTransport;
56
57
  }
57
58
  export declare function relayMode(server: McpServer): RelayMode;
58
59
  export declare function addAskBrowserStackAITool(server: McpServer, deps: AskDeps, config?: BrowserStackConfig): Record<string, RegisteredTool>;