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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "version": "1.2",
4
- "build_id": "tra-v1.2-add-archive-unlink",
3
+ "version": "1.3",
4
+ "build_id": "tra-v1.3-p0-eval-fixes",
5
5
  "tra": {
6
6
  "routing_terms": ["flaky", "flakiness", "workspace", "configuration", "schedule", "suite", "result"],
7
7
  "summary": "BrowserStack Test Reporting & Analytics (TRA, aka Test Observability): ingest, investigate and report on automated test runs. A build groups the test runs of one CI/SDK execution; each run carries status, logs, errors, hooks and AI root-cause analysis. Everything is scoped to a project. Read builds and runs; filter and search them; manage dashboards, custom views and saved reports; configure quality gates, webhooks, notifications, CI triggers and integrations; and read stability, performance, flakiness and unique-error analytics. Tests are REPORTED here, not executed here - execution happens in your runner or another BrowserStack product and is pushed in via the SDK. Access-key auth reaches only the /ext/v1 external API; everything else needs a session or service credential. Each run records its result on the browser, device, OS and environment it ran on, under the app version and the build tags it was pushed with, and a failure can be linked to an issue. Saved reports can be scheduled. Destructive capabilities need explicit human approval.",
@@ -4350,6 +4350,7 @@
4350
4350
  }
4351
4351
  },
4352
4352
  "guidance": [
4353
+ "ALWAYS pass lowerBound/upperBound: omitting them returns the full retention window (tens of thousands of characters) and blows the tool output limit. For a single build's flaky/new-failure/muted/re-run counts read the build row (historyAggregate) via list_project_builds or get_build_details instead; this card is per build NAME over a window, not per run.",
4353
4354
  "Plan-feature-gated: if the flakiness widget is not in the caller's plan the call fails with 403 (FEATURE_INACCESSIBLE), NOT a 200 with empty data - do not treat that as 'no flaky tests'.",
4354
4355
  "The window is clamped to plan retention silently, and the bucket interval is derived from the width of the window you asked for, so a wider range yields coarser buckets.",
4355
4356
  "`insights.count` is the headline percentage as a STRING (one decimal), and `insights.meta` is a human sentence like 'N of M unique tests are flaky'; the per-bucket series is in `data`, each point's `y` being the flaky count for that bucket (null when no tests ran).",
@@ -8057,7 +8058,8 @@
8057
8058
  "All three query params are required.",
8058
8059
  "When no build matches you get a bare HTTP 404 with an EMPTY body (not a structured error object) \u2014 handle the status code, there is nothing to parse.",
8059
8060
  "The lookup is scoped to the caller's group, so a build in another group reads as not-found.",
8060
- "Returns just `{buildId}` (the resolved UUID); use `get_build_coordinates_from_uuid` for the inverse."
8061
+ "Returns just `{buildId}` (the resolved UUID); use `get_build_coordinates_from_uuid` for the inverse.",
8062
+ "`buildSerialId` is the UI #N only. If the user's number does not match (404) it may be the CI run id (`buildRunId`): resolve that with search_entities (entity Build) or list_project_builds, which return both ids."
8061
8063
  ],
8062
8064
  "responses": {
8063
8065
  "200": {
@@ -10087,6 +10089,7 @@
10087
10089
  }
10088
10090
  },
10089
10091
  "guidance": [
10092
+ "Pages are tiny and uneven in practice (2, 5, 6 rows) and there is no name filter, so this is the WRONG route for 'find project X' or 'what projects do I have' on a large group: use list_projects_lite_paginated (`search`, 100 per page, newest activity first) or list_projects_full (whole group, capped at 30 rows).",
10090
10093
  "Requires the PROJECT_VIEW permission; a credential without it is refused. A user OAuth credential that holds it is accepted - a service account is not required.",
10091
10094
  "Up to 10 projects per page, and a page can hold fewer while has_next is still true - a short page is not the last page. There is no page-size parameter. Paginate only with the returned base64 next_page cursor, not a numeric page.",
10092
10095
  "pagination.has_next is computed as 'this page was non-empty', so the last full page still reports has_next=true and you get one extra EMPTY page. Stop when projects comes back empty, not when has_next turns false.",
@@ -13947,7 +13950,8 @@
13947
13950
  "The body is a bare JSON array of log entries, not an object wrapper \u2014 iterate it directly.",
13948
13951
  "`trim=true` narrows the stream to the test's own time window; the default (false) includes the whole session.",
13949
13952
  "Bad ids give TEST_RUN_NOT_FOUND / BUILD_NOT_FOUND (404); locked builds give FORBIDDEN_OPERATION / PLAN_FEATURE_NOT_ALLOWED (403).",
13950
- "On the demo-sandbox environment canned logs are returned, chosen by the test's name."
13953
+ "On the demo-sandbox environment canned logs are returned, chosen by the test's name.",
13954
+ "An unknown or non-existent testRunId returns HTTP 500 'Internal System Exception', not 404: do not pass a user-supplied number straight in; resolve the test run id from the build's test-run listing first (a 'run number' the user quotes is almost always a build serial or CI run id, never a testRunId)."
13951
13955
  ]
13952
13956
  },
13953
13957
  {
@@ -13969,7 +13973,8 @@
13969
13973
  "This is the heaviest of the detail reads: it assembles the run, its captured code, its meta and the related hook code from several sources, so prefer a lighter read when you only need status or metadata.",
13970
13974
  "testCode (and each entry of hooksCode) can have null code/language when no code was captured for that run \u2014 the row is still returned; do not treat a null code as an error.",
13971
13975
  "A run that does not belong to the caller's group, or does not exist, comes back as TEST_RUN_NOT_FOUND \u2014 the same answer for 'wrong group' and 'no such run'.",
13972
- "testMetadata here is the internal test-meta object and is shaped differently from the flatter meta returned by the baseline/similar-error map reads; read its keys from this response rather than assuming they match."
13976
+ "testMetadata here is the internal test-meta object and is shaped differently from the flatter meta returned by the baseline/similar-error map reads; read its keys from this response rather than assuming they match.",
13977
+ "An unknown or non-existent testRunId returns HTTP 500 'Internal System Exception', not 404: do not pass a user-supplied number straight in; resolve the test run id from the build's test-run listing first (a 'run number' the user quotes is almost always a build serial or CI run id, never a testRunId)."
13973
13978
  ],
13974
13979
  "returns": [
13975
13980
  "testCode",
@@ -22052,7 +22057,10 @@
22052
22057
  "Sort is fixed to updated_at-desc and cannot be changed by the caller.",
22053
22058
  "Page size is NOT caller-controlled: overlay=true fixes it to 10 and omits `pagingParams`/`totalCount`; overlay off fixes it to 30 and includes them. Page through with `pageNumber` and `pagingParams.has_next`.",
22054
22059
  "This is a thin pass-through to the backend global-search API; TRA only validates/sanitises the query, fixes the sort, and sets the page size.",
22055
- "Each result row carries its own `id`, entity `type` discriminator, and a `userName` resolved from the record's user id."
22060
+ "Each result row carries its own `id`, entity `type` discriminator, and a `userName` resolved from the record's user id.",
22061
+ "Keep `days` at or below 90: on a large group, windows of 365 or more returned HTTP 500 'Internal System Exception' instead of results.",
22062
+ "entity=\"Build\" is the only cross-project way to find a build by NAME or by CI run id: each hit carries `projectName`/`projectNormalizedName`, `uuid`, `serialId` (the #N) and `buildRunId` (the CI run id). Use it when a user names a build or a run number without a project.",
22063
+ "entity=\"Project\" is unreliable for name lookup (an existing project can return zero hits); find projects by name with list_projects_lite_paginated `search` instead."
22056
22064
  ]
22057
22065
  },
22058
22066
  {
@@ -24721,7 +24729,8 @@
24721
24729
  "guidance": [
24722
24730
  "Pure read: no AI consent/feature checks and no generation queued. Use this to poll for a result after triggering one elsewhere.",
24723
24731
  "When nothing has been generated yet this is a 400 (BAD_REQUEST, 'Test Run RCA not found for the given test run ID'), NOT a 404 or an empty 200 \u2014 treat that 400 as 'not generated yet', not as a bad request.",
24724
- "Group-scoped: the run is looked up within the caller's group, so a foreign run id also surfaces as not-found."
24732
+ "Group-scoped: the run is looked up within the caller's group, so a foreign run id also surfaces as not-found.",
24733
+ "404 'RCA not found' means no RCA has been generated for this test run yet (not a bad id): generating one is a write (get_test_rca / trigger_test_rca_via_public_api) and needs user confirmation."
24725
24734
  ]
24726
24735
  },
24727
24736
  {
@@ -27358,7 +27367,9 @@
27358
27367
  "When `date_range` is omitted the window is the LAST 7 DAYS \u2014 older builds simply will not appear unless you pass an explicit [start,end] in epoch millis.",
27359
27368
  "Within a page builds come back OLDEST-first: the handler reverses the fetched page (Lists.reverse) before returning.",
27360
27369
  "Requires automated-test-run view permission on the project.",
27361
- "Each build carries an `observability_url` deep link and `tcmTestRunIdentifier`; use `build_id` (the hashed uuid; there is no `uuid` key) for follow-up per-build calls."
27370
+ "Each build carries an `observability_url` deep link and `tcmTestRunIdentifier`; use `build_id` (the hashed uuid; there is no `uuid` key) for follow-up per-build calls.",
27371
+ "For 'the latest / last N builds' prefer list_project_builds (newest-first, `pageLimit`, project-scoped `search`): this route is oldest-first within a page, 10 per page and defaults to the last 7 days, so 'last 10 builds' can cost several calls.",
27372
+ "Each item carries `build_number` (the serial #N) and, when the CI set one, the CI run id; a user's 'run <number>' may be the CI run id rather than `build_number`."
27362
27373
  ],
27363
27374
  "responses": {
27364
27375
  "200": {
@@ -27594,6 +27605,7 @@
27594
27605
  "closedCount"
27595
27606
  ],
27596
27607
  "guidance": [
27608
+ "PREFERRED build listing for agents: newest-first, `pageLimit` sets the page size (keep it at or below 50 \u2014 each row is large and a big page overflows the tool output), `search` filters by build name, and rows carry buildNumber, `buildRunId` (CI run id), statusStats, historyAggregate (flaky / newFailure / alwaysFailing counts) and qualityGateStatus, so one call answers 'last N builds with status'.",
27597
27609
  "Takes the project's NORMALISED NAME in the path, not a numeric id. Uses the same large filter object as the build-groups listing.",
27598
27610
  "SIDE EFFECT: this call marks the project as subscribed for the calling user \u2014 a read that quietly changes the user's subscription state. It also applies the group's custom data-retention window for the duration of the request, so builds older than the retention window will not appear regardless of `dateRange`.",
27599
27611
  "Cursor pagination via `pagingParams` / `searchAfter`; page through until the paging params report no next cursor. `pageLimit` defaults to 10.",
@@ -28578,7 +28590,9 @@
28578
28590
  "guidance": [
28579
28591
  "Accepts either the numeric id or the normalised name in the one path segment \u2014 carry whichever you have, no separate lookup needed.",
28580
28592
  "configuration.attributes is always present; configuration.subTypes and configuration.alertConfiguration are omitted when null (the DTO is NON_NULL), so treat their absence as 'none configured', not an error.",
28581
- "permissions is a JSON array node reflecting the caller's rights on this project \u2014 read it to decide which mutating project actions to offer."
28593
+ "permissions is a JSON array node reflecting the caller's rights on this project \u2014 read it to decide which mutating project actions to offer.",
28594
+ "The numeric id must be the TRA project id (e.g. from list_projects_lite_paginated `id`), NOT the TestHub/Test Management id (`thProjectId`): a TestHub id gives 404 and an unknown id can give a bare 500 'Internal System Exception'.",
28595
+ "`permissions` reflects the API role, which can be broader than what the UI shows for the same user; do not tell the user they can edit settings based on it alone."
28582
28596
  ],
28583
28597
  "responses": {
28584
28598
  "200": {
@@ -29185,6 +29199,7 @@
29185
29199
  "pagingParams"
29186
29200
  ],
29187
29201
  "guidance": [
29202
+ "PREFERRED route to resolve a project by name: pass `search` with the user's words; the exact match is usually on the first page. The row carries the TRA `id` (for integer-id routes), `normalisedName` (for name routes) and `thProjectId` (TestHub/TM id, NOT accepted by TRA routes).",
29188
29203
  "searchAfter is an OFFSET, not a cursor \u2014 the name is misleading. Page by reading pagingParams.searchAfter back into the next request; hasNext is computed by over-fetching one row.",
29189
29204
  "Page size is hardcoded at 100 and cannot be changed.",
29190
29205
  "In demo-sandbox environments the offset is forced to 0, so you always get the first page there regardless of searchAfter.",
@@ -31239,7 +31254,8 @@
31239
31254
  "runInfo is a NESTED object ({duration, browser, os, device}); duration, browser, os and device do NOT appear at the top level even though they read like headline attributes \u2014 read them under runInfo.",
31240
31255
  "sessionId is not always a plain BrowserStack Automate session id: for App Automate non-SDK frameworks it is resolved differently, so do not assume it addresses the Automate session API directly.",
31241
31256
  "bsTags is the BrowserStack-side tag list (array of objects); userTags is the plain string array the user set \u2014 they are two different fields.",
31242
- "An unknown id is TEST_RUN_NOT_FOUND; a run whose build is retention-locked is refused with FORBIDDEN_OPERATION rather than returning partial data."
31257
+ "An unknown id is TEST_RUN_NOT_FOUND; a run whose build is retention-locked is refused with FORBIDDEN_OPERATION rather than returning partial data.",
31258
+ "An unknown or non-existent testRunId returns HTTP 500 'Internal System Exception', not 404: do not pass a user-supplied number straight in; resolve the test run id from the build's test-run listing first (a 'run number' the user quotes is almost always a build serial or CI run id, never a testRunId)."
31243
31259
  ],
31244
31260
  "returns": [
31245
31261
  "runInfo",
@@ -31350,7 +31366,9 @@
31350
31366
  "This is the URL-returning counterpart of the merged-logs read (get_test_run_merged_logs): browserstackLogs, sdkLogs and hookRunLogs are URLs the browser fetches itself, while failureLogs and stepLogs are returned INLINE. Use the merged-logs read when you need the log text assembled server-side.",
31351
31367
  "browserstackLogs is a per-channel object (text, console, selenium, appium, playwright, performance, network, device, video, terminal, telemetry, browserprofiling, ...); each present channel is a presigned URL/descriptor, not content, and presence does not prove the artefact exists. Null fields are serialised as explicit nulls (testStartedAt/testFinishedAt can be null).",
31352
31368
  "Retention-locked builds are refused with FORBIDDEN_OPERATION / PLAN_FEATURE_NOT_ALLOWED, not an empty body.",
31353
- "Whether logs are served from S3 or the BrowserStack session API is decided internally by a Redis flag and the framework \u2014 the caller cannot influence it."
31369
+ "Whether logs are served from S3 or the BrowserStack session API is decided internally by a Redis flag and the framework \u2014 the caller cannot influence it.",
31370
+ "An unknown or non-existent testRunId returns HTTP 500 'Internal System Exception', not 404: do not pass a user-supplied number straight in; resolve the test run id from the build's test-run listing first (a 'run number' the user quotes is almost always a build serial or CI run id, never a testRunId).",
31371
+ "Channels are session-type specific: `appium` and `device` exist only for App Automate sessions; a web (Automate) or report-upload build has no Appium/device logs \u2014 say so rather than promising the link."
31354
31372
  ],
31355
31373
  "returns": [
31356
31374
  "testStartedAt",
@@ -33626,6 +33644,8 @@
33626
33644
  }
33627
33645
  },
33628
33646
  "guidance": [
33647
+ "OBSERVED LIVE: `buildNames` and `query` are IGNORED by the product (200 with the unfiltered page, even when `projects` is set). Do not use this route to find a build by name; use list_project_builds (project-scoped, `search`) or search_entities (cross-project).",
33648
+ "Only the body fields declared here are sent; the inherited filter surface mentioned in the field description (users, teams, statuses, tags, dateRange, search...) is not bindable through this capability.",
33629
33649
  "POST but a pure READ \u2014 the body is a filter; nothing is written.",
33630
33650
  "Page size is fixed server-side (BUILDS_PAGESIZE); it is NOT caller-controllable, so there is no page-size body field. Page with the afterKey/beforeKey cursors from pagingParams (hasNext/hasPrev tell you when to stop).",
33631
33651
  "Honours UDAC project scoping: with projects null and UDAC on, only the caller's accessible projects are returned.",
@@ -50533,9 +50553,11 @@
50533
50553
  "automation build",
50534
50554
  "test build",
50535
50555
  "build run",
50536
- "launch"
50556
+ "launch",
50557
+ "ci run id",
50558
+ "build run id"
50537
50559
  ],
50538
- "id_convention": "Addressed by build UUID (an opaque string) on most routes. A few reads take the internal NUMERIC build id instead (e.g. the AI failure summary), and the two are not interchangeable. A build can also be located by its human coordinates (project normalised name + build normalised name + build serial number), which resolve to the UUID. There is no prefixed display identifier.",
50560
+ "id_convention": "Addressed by build UUID (an opaque string) on most routes. A few reads take the internal NUMERIC build id instead (e.g. the AI failure summary), and the two are not interchangeable. A build can also be located by its human coordinates (project normalised name + build normalised name + build serial number), which resolve to the UUID. There is no prefixed display identifier. A build ALSO carries a CI run id (`buildRunId`, a string set by the CI/upload client), which is NOT the serial number (#N): users often say \"run <number>\" meaning buildRunId. Resolve a bare run number with search_entities (entity=\"Build\", days<=90): each hit carries both `buildRunId` and `serialId` plus the project; get_build_id_from_build_coordinates only understands the serial.",
50539
50561
  "parents": [
50540
50562
  "project"
50541
50563
  ],
@@ -52069,6 +52091,6 @@
52069
52091
  ]
52070
52092
  }
52071
52093
  },
52072
- "version": "1.2"
52094
+ "version": "1.3"
52073
52095
  }
52074
52096
  }
package/dist/config.d.ts CHANGED
@@ -11,10 +11,35 @@ export declare class Config {
11
11
  readonly O11Y_TFA_RCA_BASE_URL: string;
12
12
  readonly BROWSERSTACK_AUTOMATION_BASE_URL: string;
13
13
  readonly BROWSERSTACK_O11Y_UI_BASE_URL: string;
14
- readonly ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY: boolean;
15
14
  readonly ASK_BROWSERSTACK_ATLAS_URL: string | undefined;
16
15
  readonly ASK_BROWSERSTACK_AUTH_TOKEN_URL: string | undefined;
17
- constructor(DEV_MODE: boolean, browserstackLocalOptions: Record<string, any>, USE_OWN_LOCAL_BINARY_PROCESS: boolean, REMOTE_MCP: boolean, UPLOAD_BASE_DIR: string | undefined, O11Y_TFA_RCA_BASE_URL: string, BROWSERSTACK_AUTOMATION_BASE_URL: string, BROWSERSTACK_O11Y_UI_BASE_URL: string, ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY: boolean, ASK_BROWSERSTACK_ATLAS_URL: string | undefined, ASK_BROWSERSTACK_AUTH_TOKEN_URL: string | undefined);
16
+ /**
17
+ * Which relay transport to use, overriding what the deployment implies.
18
+ *
19
+ * `relayMode` otherwise derives this from `REMOTE_MCP`, which conflates two
20
+ * different questions: "am I the hosted process" and "can I hold a connection open
21
+ * across a human". Only the second decides the transport, and they come apart the
22
+ * moment anyone wants to exercise the deferred, two-call flow from a local stdio
23
+ * install — `REMOTE_MCP=true` cannot do it, because this entrypoint exits on that
24
+ * flag by design (the hosted deployment runs remote-mcp-server instead).
25
+ *
26
+ * "deferred" | "elicit". Anything else is ignored.
27
+ */
28
+ readonly ASK_BROWSERSTACK_RELAY_TRANSPORT: string | undefined;
29
+ constructor(DEV_MODE: boolean, browserstackLocalOptions: Record<string, any>, USE_OWN_LOCAL_BINARY_PROCESS: boolean, REMOTE_MCP: boolean, UPLOAD_BASE_DIR: string | undefined, O11Y_TFA_RCA_BASE_URL: string, BROWSERSTACK_AUTOMATION_BASE_URL: string, BROWSERSTACK_O11Y_UI_BASE_URL: string, ASK_BROWSERSTACK_ATLAS_URL: string | undefined, ASK_BROWSERSTACK_AUTH_TOKEN_URL: string | undefined,
30
+ /**
31
+ * Which relay transport to use, overriding what the deployment implies.
32
+ *
33
+ * `relayMode` otherwise derives this from `REMOTE_MCP`, which conflates two
34
+ * different questions: "am I the hosted process" and "can I hold a connection open
35
+ * across a human". Only the second decides the transport, and they come apart the
36
+ * moment anyone wants to exercise the deferred, two-call flow from a local stdio
37
+ * install — `REMOTE_MCP=true` cannot do it, because this entrypoint exits on that
38
+ * flag by design (the hosted deployment runs remote-mcp-server instead).
39
+ *
40
+ * "deferred" | "elicit". Anything else is ignored.
41
+ */
42
+ ASK_BROWSERSTACK_RELAY_TRANSPORT: string | undefined);
18
43
  }
19
44
  declare const config: Config;
20
45
  export default config;
package/dist/config.js CHANGED
@@ -47,9 +47,9 @@ export class Config {
47
47
  O11Y_TFA_RCA_BASE_URL;
48
48
  BROWSERSTACK_AUTOMATION_BASE_URL;
49
49
  BROWSERSTACK_O11Y_UI_BASE_URL;
50
- ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY;
51
50
  ASK_BROWSERSTACK_ATLAS_URL;
52
51
  ASK_BROWSERSTACK_AUTH_TOKEN_URL;
52
+ ASK_BROWSERSTACK_RELAY_TRANSPORT;
53
53
  constructor(DEV_MODE, browserstackLocalOptions, USE_OWN_LOCAL_BINARY_PROCESS, REMOTE_MCP, UPLOAD_BASE_DIR, O11Y_TFA_RCA_BASE_URL, BROWSERSTACK_AUTOMATION_BASE_URL, BROWSERSTACK_O11Y_UI_BASE_URL,
54
54
  // askBrowserStackAI's process-startup settings. Declared here rather than read from
55
55
  // process.env inside src/tools/, per rules/tool-design.md — and so the remote wrapper,
@@ -58,7 +58,20 @@ export class Config {
58
58
  // ASK_BROWSERSTACK_DISABLED is deliberately NOT here: it is a kill switch, and reading
59
59
  // it per call keeps it effective without a restart. Fixing it at boot would mean a pod
60
60
  // roll to disable the tool, which is slowest exactly when you need it fastest.
61
- ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY, ASK_BROWSERSTACK_ATLAS_URL, ASK_BROWSERSTACK_AUTH_TOKEN_URL) {
61
+ ASK_BROWSERSTACK_ATLAS_URL, ASK_BROWSERSTACK_AUTH_TOKEN_URL,
62
+ /**
63
+ * Which relay transport to use, overriding what the deployment implies.
64
+ *
65
+ * `relayMode` otherwise derives this from `REMOTE_MCP`, which conflates two
66
+ * different questions: "am I the hosted process" and "can I hold a connection open
67
+ * across a human". Only the second decides the transport, and they come apart the
68
+ * moment anyone wants to exercise the deferred, two-call flow from a local stdio
69
+ * install — `REMOTE_MCP=true` cannot do it, because this entrypoint exits on that
70
+ * flag by design (the hosted deployment runs remote-mcp-server instead).
71
+ *
72
+ * "deferred" | "elicit". Anything else is ignored.
73
+ */
74
+ ASK_BROWSERSTACK_RELAY_TRANSPORT) {
62
75
  this.DEV_MODE = DEV_MODE;
63
76
  this.browserstackLocalOptions = browserstackLocalOptions;
64
77
  this.USE_OWN_LOCAL_BINARY_PROCESS = USE_OWN_LOCAL_BINARY_PROCESS;
@@ -67,9 +80,9 @@ export class Config {
67
80
  this.O11Y_TFA_RCA_BASE_URL = O11Y_TFA_RCA_BASE_URL;
68
81
  this.BROWSERSTACK_AUTOMATION_BASE_URL = BROWSERSTACK_AUTOMATION_BASE_URL;
69
82
  this.BROWSERSTACK_O11Y_UI_BASE_URL = BROWSERSTACK_O11Y_UI_BASE_URL;
70
- this.ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY = ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY;
71
83
  this.ASK_BROWSERSTACK_ATLAS_URL = ASK_BROWSERSTACK_ATLAS_URL;
72
84
  this.ASK_BROWSERSTACK_AUTH_TOKEN_URL = ASK_BROWSERSTACK_AUTH_TOKEN_URL;
85
+ this.ASK_BROWSERSTACK_RELAY_TRANSPORT = ASK_BROWSERSTACK_RELAY_TRANSPORT;
73
86
  }
74
87
  }
75
88
  const config = new Config(process.env.DEV_MODE === "true", browserstackLocalOptions, process.env.USE_OWN_LOCAL_BINARY_PROCESS === "true", process.env.REMOTE_MCP === "true", process.env.MCP_UPLOAD_BASE_DIR && process.env.MCP_UPLOAD_BASE_DIR.length > 0
@@ -83,12 +96,12 @@ const config = new Config(process.env.DEV_MODE === "true", browserstackLocalOpti
83
96
  : DEFAULT_BROWSERSTACK_AUTOMATION_BASE_URL, process.env.BROWSERSTACK_O11Y_UI_BASE_URL &&
84
97
  process.env.BROWSERSTACK_O11Y_UI_BASE_URL.length > 0
85
98
  ? process.env.BROWSERSTACK_O11Y_UI_BASE_URL
86
- : DEFAULT_BROWSERSTACK_O11Y_UI_BASE_URL, (process.env.ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY || "").toLowerCase() ===
87
- "true", process.env.ASK_BROWSERSTACK_ATLAS_URL &&
99
+ : DEFAULT_BROWSERSTACK_O11Y_UI_BASE_URL, process.env.ASK_BROWSERSTACK_ATLAS_URL &&
88
100
  process.env.ASK_BROWSERSTACK_ATLAS_URL.trim().length > 0
89
101
  ? process.env.ASK_BROWSERSTACK_ATLAS_URL
90
102
  : undefined, process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL &&
91
103
  process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL.trim().length > 0
92
104
  ? process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL
93
- : undefined);
105
+ : undefined, (process.env.ASK_BROWSERSTACK_RELAY_TRANSPORT || "").trim().toLowerCase() ||
106
+ undefined);
94
107
  export default config;
@@ -6,6 +6,7 @@ const require = createRequire(import.meta.url);
6
6
  const packageJson = require("../../package.json");
7
7
  import { apiClient } from "./apiClient.js";
8
8
  import globalConfig from "../config.js";
9
+ import { redact } from "../tools/capability-registry/redact.js";
9
10
  const INSTRUMENTATION_ENDPOINT = "https://api.browserstack.com/sdk/v1/event";
10
11
  function baseProperties(toolName, clientInfo) {
11
12
  return {
@@ -25,8 +26,13 @@ function errorProperties(error) {
25
26
  function sendEvent(event, config) {
26
27
  let authHeader;
27
28
  if (config) {
28
- const authString = getBrowserStackAuth(config);
29
- authHeader = `Basic ${Buffer.from(authString).toString("base64")}`;
29
+ try {
30
+ const authString = getBrowserStackAuth(config);
31
+ authHeader = `Basic ${Buffer.from(authString).toString("base64")}`;
32
+ }
33
+ catch {
34
+ // noop
35
+ }
30
36
  }
31
37
  apiClient
32
38
  .post({
@@ -80,6 +86,19 @@ export function trackMCP(toolName, clientInfo, error, config, extras) {
80
86
  };
81
87
  sendEvent(event, config);
82
88
  }
89
+ /** The text a tool showed the user. */
90
+ function errorTextOf(result) {
91
+ const content = result?.content;
92
+ if (!Array.isArray(content))
93
+ return undefined;
94
+ const text = content
95
+ .map((part) => typeof part?.text === "string"
96
+ ? part.text
97
+ : "")
98
+ .filter(Boolean)
99
+ .join(" ");
100
+ return redact(text);
101
+ }
83
102
  function isErrorResult(result) {
84
103
  return (typeof result === "object" &&
85
104
  result !== null &&
@@ -93,10 +112,14 @@ export async function withToolCall(toolName, getClientInfo, config, fn) {
93
112
  const ctx = { toolName, clientInfo: {}, config };
94
113
  const startedAt = performance.now();
95
114
  let outcome = "ok";
115
+ let errorText;
96
116
  try {
97
117
  const result = await callContext.run(ctx, fn);
98
- if (isErrorResult(result))
118
+ if (isErrorResult(result)) {
99
119
  outcome = "error_result";
120
+ // A returned failure never sets ctx.error, so the reason is read off the result.
121
+ errorText ??= errorTextOf(result);
122
+ }
100
123
  return result;
101
124
  }
102
125
  catch (error) {
@@ -123,6 +146,9 @@ export async function withToolCall(toolName, getClientInfo, config, fn) {
123
146
  duration_ms: Math.max(0, Math.round(performance.now() - startedAt)),
124
147
  outcome,
125
148
  ...(ctx.error !== undefined ? errorProperties(ctx.error) : {}),
149
+ ...(outcome === "error_result" && ctx.error === undefined && errorText
150
+ ? { error_message: errorText }
151
+ : {}),
126
152
  ...(ctx.extras ?? {}),
127
153
  },
128
154
  };
package/dist/lib/utils.js CHANGED
@@ -1,11 +1,13 @@
1
1
  import { getBrowserStackAuth } from "./get-auth.js";
2
2
  import { trackMCP } from "../index.js";
3
3
  import logger from "../logger.js";
4
+ import globalConfig from "../config.js";
4
5
  export function sanitizeUrlParam(param) {
5
6
  // Remove any characters that could be used for command injection
6
7
  return param.replace(/[;&|`$(){}[\]<>]/g, "");
7
8
  }
8
9
  const ONE_MB = 1048576;
10
+ let sharpConfigured = false;
9
11
  //Compresses a base64 image intelligently to keep it under 1 MB if needed.
10
12
  export async function maybeCompressBase64(base64) {
11
13
  const buffer = Buffer.from(base64, "base64");
@@ -17,6 +19,12 @@ export async function maybeCompressBase64(base64) {
17
19
  const quality = Math.min(95, Math.max(30, estimatedQuality));
18
20
  try {
19
21
  const { default: sharp } = await import("sharp");
22
+ if (!sharpConfigured && globalConfig.REMOTE_MCP) {
23
+ // Bound memory on constrained hosted pods: no libvips cache, single worker thread.
24
+ sharp.cache(false);
25
+ sharp.concurrency(1);
26
+ sharpConfigured = true;
27
+ }
20
28
  const compressedBuffer = await sharp(buffer).png({ quality }).toBuffer();
21
29
  return compressedBuffer.toString("base64");
22
30
  }
@@ -74,7 +74,7 @@ export class AccessibilityScanner {
74
74
  });
75
75
  const data = response.data;
76
76
  if (!data.success)
77
- throw new Error(`Unable to start scan: ${data.errors?.join(", ")}`);
77
+ throw new Error(`Unable to start scan: ${data.errors?.join(", ") || JSON.stringify(data)}`);
78
78
  return data;
79
79
  }
80
80
  catch (err) {
@@ -30,20 +30,6 @@ export declare class AskError extends Error {
30
30
  * other three are on the config singleton (rules/tool-design.md).
31
31
  */
32
32
  export declare function isEnabled(): boolean;
33
- /**
34
- * May the relay be offered in the hosted (`REMOTE_MCP`) deployment?
35
- *
36
- * OFF BY DEFAULT, because it depends on something outside this package: the host has to
37
- * keep one `McpServer` alive per session. Stateless hosts build a fresh server per POST,
38
- * and an elicitation answer — which arrives as a SEPARATE POST — then reaches an instance
39
- * that never asked anything, leaving the real one suspended until it times out. So this
40
- * must stay opt-in per deployment rather than become a default that silently hangs.
41
- *
42
- * Turning it on does NOT force the relay on: `relayMode` still asks whether THIS client
43
- * declared the `elicitation` capability, and a client that did not still gets a read-only
44
- * run. This flag only removes the blanket refusal.
45
- */
46
- export declare function allowRemoteRelay(): boolean;
47
33
  /**
48
34
  * ============================================================================
49
35
  * PRODUCTION DEFAULTS
@@ -34,22 +34,6 @@ export class AskError extends Error {
34
34
  export function isEnabled() {
35
35
  return (process.env.ASK_BROWSERSTACK_DISABLED || "").toLowerCase() !== "true";
36
36
  }
37
- /**
38
- * May the relay be offered in the hosted (`REMOTE_MCP`) deployment?
39
- *
40
- * OFF BY DEFAULT, because it depends on something outside this package: the host has to
41
- * keep one `McpServer` alive per session. Stateless hosts build a fresh server per POST,
42
- * and an elicitation answer — which arrives as a SEPARATE POST — then reaches an instance
43
- * that never asked anything, leaving the real one suspended until it times out. So this
44
- * must stay opt-in per deployment rather than become a default that silently hangs.
45
- *
46
- * Turning it on does NOT force the relay on: `relayMode` still asks whether THIS client
47
- * declared the `elicitation` capability, and a client that did not still gets a read-only
48
- * run. This flag only removes the blanket refusal.
49
- */
50
- export function allowRemoteRelay() {
51
- return appConfig.ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY;
52
- }
53
37
  /**
54
38
  * ============================================================================
55
39
  * PRODUCTION DEFAULTS
@@ -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;