@browserstack/mcp-server 1.4.0-beta.2 → 1.5.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.
Files changed (44) hide show
  1. package/capability/loadtesting.capability-index.json +1754 -0
  2. package/capability/tm.capability-index.json +19793 -0
  3. package/dist/index.js +2 -5
  4. package/dist/server-factory.js +5 -5
  5. package/dist/tools/accessibility.js +2 -5
  6. package/dist/tools/capability-registry/bind.d.ts +29 -0
  7. package/dist/tools/capability-registry/bind.js +134 -0
  8. package/dist/tools/capability-registry/config.d.ts +62 -0
  9. package/dist/tools/capability-registry/config.js +218 -0
  10. package/dist/tools/capability-registry/discovery.d.ts +44 -0
  11. package/dist/tools/capability-registry/discovery.js +99 -0
  12. package/dist/tools/capability-registry/egress.d.ts +44 -0
  13. package/dist/tools/capability-registry/egress.js +128 -0
  14. package/dist/tools/capability-registry/index-loader.d.ts +119 -0
  15. package/dist/tools/capability-registry/index-loader.js +314 -0
  16. package/dist/tools/capability-registry/register.d.ts +34 -0
  17. package/dist/tools/capability-registry/register.js +354 -0
  18. package/dist/tools/capability-registry/resolve.d.ts +38 -0
  19. package/dist/tools/capability-registry/resolve.js +45 -0
  20. package/dist/tools/capability-registry/search.d.ts +65 -0
  21. package/dist/tools/capability-registry/search.js +342 -0
  22. package/dist/tools/capability-registry/types.d.ts +208 -0
  23. package/dist/tools/capability-registry/types.js +33 -0
  24. package/dist/tools/get-failure-logs.js +1 -3
  25. package/dist/tools/rca-agent.js +2 -5
  26. package/dist/tools/selfheal.js +2 -5
  27. package/dist/tools/testmanagement.js +15 -33
  28. package/package.json +3 -2
  29. package/dist/tools/ask-browserstack/central-oauth.d.ts +0 -114
  30. package/dist/tools/ask-browserstack/central-oauth.js +0 -271
  31. package/dist/tools/ask-browserstack/config.d.ts +0 -96
  32. package/dist/tools/ask-browserstack/config.js +0 -134
  33. package/dist/tools/ask-browserstack/egress.d.ts +0 -34
  34. package/dist/tools/ask-browserstack/egress.js +0 -31
  35. package/dist/tools/ask-browserstack/register.d.ts +0 -61
  36. package/dist/tools/ask-browserstack/register.js +0 -403
  37. package/dist/tools/ask-browserstack/relay.d.ts +0 -201
  38. package/dist/tools/ask-browserstack/relay.js +0 -577
  39. package/dist/tools/ask-browserstack/stream.d.ts +0 -116
  40. package/dist/tools/ask-browserstack/stream.js +0 -237
  41. package/dist/tools/ask-browserstack/types.d.ts +0 -196
  42. package/dist/tools/ask-browserstack/types.js +0 -10
  43. package/dist/tools/tool-handoff.d.ts +0 -37
  44. package/dist/tools/tool-handoff.js +0 -47
@@ -1,134 +0,0 @@
1
- import logger from "../../logger.js";
2
- /**
3
- * Where Atlas lives, and the timeout ladder.
4
- *
5
- * The host IS compiled in, matching every other tool here — `TM_BASE_URLS`, the
6
- * instrumentation endpoint — so an install needs no configuration to work. One env var
7
- * overrides it. See the warning on `DEFAULT_ATLAS_URL`: the compiled-in value is currently
8
- * STAGING and is a deliberate placeholder.
9
- */
10
- /**
11
- * CONTRACT §4 — the timeout ladder, outermost first. EACH LAYER MUST EXCEED THE ONE INSIDE
12
- * IT, or a layer dies before the layer it is waiting on can answer:
13
- *
14
- * MCP client -> tool call longest, client-side, not ours
15
- * POST /agent HTTP request 330s <- here
16
- * Atlas gate -> stream ask 300s Atlas's `permission_relay_timeout`
17
- * elicitInput 270s <- here
18
- *
19
- * 300s is the browser path's existing PERMISSION_TIMEOUT, which also auto-rejects.
20
- */
21
- export const AGENT_TIMEOUT_MS = 330_000;
22
- export const ELICITATION_TIMEOUT_MS = 270_000;
23
- /** Thrown for anything this tool refuses to attempt. Never carries a credential. */
24
- export class AskError extends Error {
25
- }
26
- /** Off by default is wrong for a shipped feature, but a kill switch is not. */
27
- export function isEnabled() {
28
- return (process.env.ASK_BROWSERSTACK_DISABLED || "").toLowerCase() !== "true";
29
- }
30
- /**
31
- * May the relay be offered in the hosted (`REMOTE_MCP`) deployment?
32
- *
33
- * OFF BY DEFAULT, because it depends on something outside this package: the host has to
34
- * keep one `McpServer` alive per session. Stateless hosts build a fresh server per POST,
35
- * and an elicitation answer — which arrives as a SEPARATE POST — then reaches an instance
36
- * that never asked anything, leaving the real one suspended until it times out. So this
37
- * must stay opt-in per deployment rather than become a default that silently hangs.
38
- *
39
- * Turning it on does NOT force the relay on: `relayMode` still asks whether THIS client
40
- * declared the `elicitation` capability, and a client that did not still gets a read-only
41
- * run. This flag only removes the blanket refusal.
42
- */
43
- export function allowRemoteRelay() {
44
- return ((process.env.ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY || "").toLowerCase() ===
45
- "true");
46
- }
47
- /**
48
- * ============================================================================
49
- * PRODUCTION DEFAULTS
50
- * ============================================================================
51
- *
52
- * These hosts are PRODUCTION. They replace the interim staging placeholders that this
53
- * package shipped with while the relay was being built ("for now lets hardcode the
54
- * base_url to staging only then we will point this to prod url later") — that step is
55
- * now done.
56
- *
57
- * `https://workflows.browserstack.com` was verified, not guessed: its `/api/profiles`
58
- * answers `401 {"detail":"authentication required"}`, byte-identical to staging Atlas.
59
- * The production auth endpoint is `https://auth.browserstack.com/oauth2/v2/token`.
60
- *
61
- * WHY THIS MATTERS: this package publishes to npm as `@browserstack/mcp-server`, so an
62
- * install with no environment variables set now talks to PRODUCTION. That is correct for
63
- * a production deployment, but it removes the old safety property — a misconfigured or
64
- * test deployment that forgets `ASK_BROWSERSTACK_ATLAS_URL` no longer fails safe onto
65
- * staging, it reads and writes REAL customer data. Non-production deployments MUST set
66
- * that variable explicitly. The resolved host is logged at info on first use, naming
67
- * whether it came from the env var or from here, so a deployment pointing at the wrong
68
- * Atlas is visible in a log line rather than inferred later from confusing data.
69
- *
70
- * Staging hosts, for anyone setting the override:
71
- * ASK_BROWSERSTACK_ATLAS_URL = https://ai-platform-service.bsstag.com
72
- * ASK_BROWSERSTACK_AUTH_TOKEN_URL = https://auth-preprod.bsstag.com/oauth2/v2/token
73
- *
74
- * The tests assert these literals precisely so that repointing has to be deliberate
75
- * rather than something that slips through.
76
- *
77
- * grep: DEFAULT-PROD-HOSTS
78
- */
79
- export const DEFAULT_ATLAS_URL = "https://workflows.browserstack.com";
80
- export const DEFAULT_AUTH_TOKEN_URL = "https://auth.browserstack.com/oauth2/v2/token";
81
- /** An operator's override may carry a trailing slash; the constants above do not. */
82
- function trimUrl(value) {
83
- return value.trim().replace(/\/+$/, "");
84
- }
85
- /**
86
- * Announced ONCE per distinct resolution, not per tool call.
87
- *
88
- * The point is that a deployment talking to the wrong Atlas shows up in the log; repeating it
89
- * on every call would only make it easier to scroll past.
90
- */
91
- const announced = new Set();
92
- /** For tests, and for anything that legitimately re-resolves. */
93
- export function resetHostAnnouncements() {
94
- announced.clear();
95
- }
96
- function announce(what, url, source) {
97
- const line = `${what}|${url}|${source}`;
98
- if (announced.has(line))
99
- return;
100
- announced.add(line);
101
- logger.info("askBrowserStackAI: %s is %s (source: %s)", what, url, source);
102
- }
103
- /**
104
- * Resolve Atlas's base URL:
105
- *
106
- * 1. ASK_BROWSERSTACK_ATLAS_URL explicit override
107
- * 2. the built-in staging default (see the warning above)
108
- *
109
- * Matching every other tool here, which ships its host in the code and treats the env var as
110
- * an override — `TM_BASE_URLS`, the instrumentation endpoint. There is no environment map and
111
- * no selector: one default, one override.
112
- */
113
- export function atlasBaseUrl() {
114
- const explicit = process.env.ASK_BROWSERSTACK_ATLAS_URL;
115
- const url = explicit && explicit.trim() ? trimUrl(explicit) : DEFAULT_ATLAS_URL;
116
- announce("Atlas", url, explicit && explicit.trim() ? "env" : "default");
117
- return url;
118
- }
119
- /** Resolved per call, never captured at construction. */
120
- export function agentUrl() {
121
- return `${atlasBaseUrl()}/agent`;
122
- }
123
- /**
124
- * Where a central-OAuth JWT is minted (CONTRACT v1.2 §I, as amended by task 7).
125
- *
126
- * The shared `delegation.token` path is gone from Atlas, so a user-attested central JWT is
127
- * the only way in. Same two rungs as the host, and the same staging default.
128
- */
129
- export function authTokenUrl() {
130
- const explicit = process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL;
131
- const url = explicit && explicit.trim() ? trimUrl(explicit) : DEFAULT_AUTH_TOKEN_URL;
132
- announce("auth token endpoint", url, explicit && explicit.trim() ? "env" : "default");
133
- return url;
134
- }
@@ -1,34 +0,0 @@
1
- /**
2
- * The pieces of the outbound `POST /agent` that are not the transport itself.
3
- *
4
- * The transport moved to `stream.ts` when A2 was removed: `/agent` is read as an event
5
- * stream now, so a one-request-one-response `AgentTransport` has nothing left to describe.
6
- * What stays here is what both halves always shared — the header set, the credential pair,
7
- * and the response shape `relay.ts` reads to tell a refusal from an unreachable service
8
- * apart, which the stream's JSON-degrade path still produces.
9
- *
10
- * AUTH HERE IS NOT THE PRODUCT-API AUTH. `/agent` accepts exactly two credentials, both in
11
- * `Authorization`: the shared delegation token or a BrowserStack central JWT. There is no
12
- * `Api-Token` path on this route (CONTRACT v1.2 §I), so sending one would not merely be
13
- * useless — it would push the user's `access_key` across a trust boundary to an endpoint
14
- * that has no use for it, and into every request log on the way. The capability registry's
15
- * `authHeaders` remains right for PRODUCT calls; it is simply not the header set for this
16
- * one, and is deliberately not imported here.
17
- */
18
- export interface Credentials {
19
- username: string;
20
- accessKey: string;
21
- }
22
- /**
23
- * The complete header set for `POST /agent` (CONTRACT v1.2 §4). Three headers, no more.
24
- *
25
- * The token is a secret and appears nowhere else: not in a log line, not in a result, not in
26
- * an error message.
27
- */
28
- export declare function agentHeaders(token: string): Record<string, string>;
29
- export interface AgentResponse {
30
- status: number;
31
- body: unknown;
32
- /** Only when there was no response at all to speak for itself. */
33
- error?: string;
34
- }
@@ -1,31 +0,0 @@
1
- /**
2
- * The pieces of the outbound `POST /agent` that are not the transport itself.
3
- *
4
- * The transport moved to `stream.ts` when A2 was removed: `/agent` is read as an event
5
- * stream now, so a one-request-one-response `AgentTransport` has nothing left to describe.
6
- * What stays here is what both halves always shared — the header set, the credential pair,
7
- * and the response shape `relay.ts` reads to tell a refusal from an unreachable service
8
- * apart, which the stream's JSON-degrade path still produces.
9
- *
10
- * AUTH HERE IS NOT THE PRODUCT-API AUTH. `/agent` accepts exactly two credentials, both in
11
- * `Authorization`: the shared delegation token or a BrowserStack central JWT. There is no
12
- * `Api-Token` path on this route (CONTRACT v1.2 §I), so sending one would not merely be
13
- * useless — it would push the user's `access_key` across a trust boundary to an endpoint
14
- * that has no use for it, and into every request log on the way. The capability registry's
15
- * `authHeaders` remains right for PRODUCT calls; it is simply not the header set for this
16
- * one, and is deliberately not imported here.
17
- */
18
- /**
19
- * The complete header set for `POST /agent` (CONTRACT v1.2 §4). Three headers, no more.
20
- *
21
- * The token is a secret and appears nowhere else: not in a log line, not in a result, not in
22
- * an error message.
23
- */
24
- export function agentHeaders(token) {
25
- return {
26
- Authorization: `Bearer ${token}`,
27
- "Content-Type": "application/json",
28
- // Attribution, so the downstream service can see the call came from an agent.
29
- "request-source": "ai-chatbot",
30
- };
31
- }
@@ -1,61 +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 { McpServer, RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js";
27
- import { BrowserStackConfig } from "../../lib/types.js";
28
- import { Credentials } from "./egress.js";
29
- import type { AgentStreamTransport, DecisionTransport } from "./stream.js";
30
- import { RelayMode } from "./types.js";
31
- export interface AskDeps {
32
- /** Resolved per call: a deployment's host is configuration, not a constructor argument. */
33
- agentUrl: () => string;
34
- /**
35
- * Sign in and return a bearer for `POST /agent`. Cached behind this, not minted per call.
36
- *
37
- * A function rather than a value because it is resolved per call for the same reason the
38
- * host is, and because it can fail in ways a caller needs told apart.
39
- */
40
- mintToken: () => Promise<string>;
41
- /**
42
- * Read per call, not captured: the remote server rebuilds config per session, so a
43
- * captured credential would outlive the session it belongs to.
44
- *
45
- * These are now THE AUTH CREDENTIAL, not merely attribution: the access key is exchanged
46
- * for a user-attested central JWT, which is what lets Atlas run the approved product call
47
- * as the human rather than as a shared service account.
48
- */
49
- credentialsFor: () => Credentials;
50
- /**
51
- * The two transport seams, injectable so a test can drive a whole approval round trip
52
- * — ask, elicit, decide, result — without a socket.
53
- */
54
- streamTransport?: AgentStreamTransport;
55
- decisionTransport?: DecisionTransport;
56
- }
57
- export declare function relayMode(server: McpServer): RelayMode;
58
- export declare function addAskBrowserStackAITool(server: McpServer, deps: AskDeps, config?: BrowserStackConfig): Record<string, RegisteredTool>;
59
- /** The tool-adder the server factory calls. */
60
- export declare function addAskBrowserStackAIToolFromConfig(server: McpServer, config: BrowserStackConfig): Record<string, RegisteredTool>;
61
- export default addAskBrowserStackAIToolFromConfig;
@@ -1,403 +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), a11y (Accessibility), " +
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
- title: "Ask BrowserStack AI (Alpha)",
327
- }, async ({ product, query }, extra) => {
328
- track("askBrowserStackAI");
329
- const approvals = [];
330
- // Negotiated before anything else so the failure paths below report the mode they
331
- // would have run in.
332
- const mode = relayMode(server);
333
- try {
334
- const url = deps.agentUrl();
335
- // Signed in BEFORE the listener is opened and before the run starts. Minting
336
- // lazily mid-call would put a token round-trip inside the window where a human is
337
- // being prompted, and a mint that failed there would strand an open port.
338
- const headers = agentHeaders(await deps.mintToken());
339
- const body = { task: query, product };
340
- // Attribution, and now belt-and-braces rather than the source of truth: the minted
341
- // JWT is user-attested, so Atlas sets `principal_verified=True` and takes the acting
342
- // user from signed claims instead of this field. It is still sent because it is part
343
- // of the frozen wire format (CONTRACT v1.2 §3) and dropping it would be a one-sided
344
- // change — but nothing should trust it, and Atlas no longer does.
345
- // Omitted ENTIRELY when unset, never sent as "".
346
- const username = (deps.credentialsFor().username || "").trim();
347
- if (username)
348
- body.user_id = username;
349
- // A1: asking for a stream costs nothing to set up — no port, no listener, no
350
- // per-run bearer, because nothing dials in. Which is the whole point: the
351
- // callback this replaces could never reach a laptop behind NAT, so the feature
352
- // was read-only for every real user regardless of what was configured.
353
- if (mode === "offered") {
354
- body.permission_relay = { mode: "stream" };
355
- }
356
- else {
357
- // Omitted ENTIRELY, not sent empty: its absence is what selects Atlas's
358
- // read-only HeadlessGate.
359
- logger.info("askBrowserStackAI: no permission relay (%s); running read-only", mode);
360
- }
361
- // `product` reaches the result so an entitlement refusal can name it: the flags
362
- // are per product, and a bare "not enabled" sends the user to their admin
363
- // asking about the wrong thing.
364
- return toResult(await runStreamed(server, streamTransport, decisionTransport, url, headers, body, approvals, mode, product,
365
- // The tool call's own id, so each elicitation is routed onto THIS request's
366
- // stream. Over Streamable HTTP there is nowhere else for it to go.
367
- extra?.requestId));
368
- }
369
- catch (error) {
370
- const message = error instanceof AskError || error instanceof Error
371
- ? error.message
372
- : String(error);
373
- logger.error("askBrowserStackAI failed: %s", message);
374
- // No `canElicit` argument: the request never left this process, so whether the
375
- // client could have been prompted is not what the reader needs to know.
376
- return toResult(errorResult(message, approvals));
377
- }
378
- // No teardown: A1 opens no port and binds nothing, so there is nothing that can
379
- // leak across calls or survive an error. The stream is closed by its own
380
- // iteration ending, and Atlas drops the run when the response completes.
381
- });
382
- return tools;
383
- }
384
- /** The tool-adder the server factory calls. */
385
- export function addAskBrowserStackAIToolFromConfig(server, config) {
386
- if (!isEnabled()) {
387
- logger.info("askBrowserStackAI disabled by ASK_BROWSERSTACK_DISABLED");
388
- return {};
389
- }
390
- const credentials = () => ({
391
- username: config["browserstack-username"],
392
- accessKey: config["browserstack-access-key"],
393
- });
394
- const tokenTransport = fetchTokenTransport();
395
- return addAskBrowserStackAITool(server, {
396
- // Both resolved per call. An unconfigured host surfaces as a named error from the
397
- // tool rather than as a missing tool, so the cause is visible to whoever hits it.
398
- agentUrl,
399
- mintToken: () => mintCentralToken(authTokenUrl(), credentials(), tokenTransport),
400
- credentialsFor: credentials,
401
- }, config);
402
- }
403
- export default addAskBrowserStackAIToolFromConfig;