@browserstack/mcp-server 1.2.25-beta.1 → 1.2.27-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -334,7 +334,7 @@ As of now we support 20 tools.
334
334
  List all test runs from the 'Shopping App' project that were executed last week and are currently marked in-progress
335
335
  ```
336
336
 
337
- 6. `updateTestRun` — Partially update a test run (status, tags, notes, associated test cases).
337
+ 6. `updateTestRun` — Update a test run's name/state and/or add test cases to it.
338
338
  **Prompt example**
339
339
 
340
340
  ```text
package/dist/config.d.ts CHANGED
@@ -8,7 +8,10 @@ export declare class Config {
8
8
  readonly USE_OWN_LOCAL_BINARY_PROCESS: boolean;
9
9
  readonly REMOTE_MCP: boolean;
10
10
  readonly UPLOAD_BASE_DIR: string | undefined;
11
- constructor(DEV_MODE: boolean, browserstackLocalOptions: Record<string, any>, USE_OWN_LOCAL_BINARY_PROCESS: boolean, REMOTE_MCP: boolean, UPLOAD_BASE_DIR: string | undefined);
11
+ readonly O11Y_TFA_RCA_BASE_URL: string;
12
+ readonly BROWSERSTACK_AUTOMATION_BASE_URL: string;
13
+ readonly BROWSERSTACK_O11Y_UI_BASE_URL: string;
14
+ 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);
12
15
  }
13
16
  declare const config: Config;
14
17
  export default config;
package/dist/config.js CHANGED
@@ -28,6 +28,31 @@ for (const key of BROWSERSTACK_LOCAL_OPTION_KEYS) {
28
28
  browserstackLocalOptions[key] = envVar;
29
29
  }
30
30
  }
31
+ /**
32
+ * Default o11y base URL for the `tfaRcaTurn` collaborative-RCA tool —
33
+ * PRODUCTION (verified: api-automation.browserstack.com routes the
34
+ * /ext/v1 testRca + rcaChat endpoints). Same value for every user served by
35
+ * the process; overridable at startup via `O11Y_TFA_RCA_BASE_URL` to target a
36
+ * staging tenant (e.g. api-observability-<tenant>.bsstag.com) where a build's
37
+ * representatives actually live. Per-process config, never a per-call arg.
38
+ */
39
+ const DEFAULT_O11Y_TFA_RCA_BASE_URL = "https://api-automation.browserstack.com";
40
+ /**
41
+ * Base URL for the Automate test-runs API (`/ext/v1/builds/{id}/testRuns`) that
42
+ * `listTestIds` calls. Overridable at startup via `BROWSERSTACK_AUTOMATION_BASE_URL`
43
+ * so the tool can target a non-prod env (e.g. rengg-tfa staging) where a build
44
+ * actually lives, instead of the prod default. Per-process config, never a
45
+ * per-call arg.
46
+ */
47
+ const DEFAULT_BROWSERSTACK_AUTOMATION_BASE_URL = "https://api-automation.browserstack.com";
48
+ /**
49
+ * Base URL of the Test Observability (TRA) web UI used to build the
50
+ * human-facing "view the full report" links returned by the RCA tools.
51
+ * Same value for every user served by the process; overridable at startup via
52
+ * `BROWSERSTACK_O11Y_UI_BASE_URL` (e.g. to point at a staging UI). Per-process
53
+ * config, never a per-call arg.
54
+ */
55
+ const DEFAULT_BROWSERSTACK_O11Y_UI_BASE_URL = "https://automation.browserstack.com";
31
56
  /**
32
57
  * USE_OWN_LOCAL_BINARY_PROCESS:
33
58
  * If true, the system will not start a new local binary process, but will use the user's own process.
@@ -38,15 +63,30 @@ export class Config {
38
63
  USE_OWN_LOCAL_BINARY_PROCESS;
39
64
  REMOTE_MCP;
40
65
  UPLOAD_BASE_DIR;
41
- constructor(DEV_MODE, browserstackLocalOptions, USE_OWN_LOCAL_BINARY_PROCESS, REMOTE_MCP, UPLOAD_BASE_DIR) {
66
+ O11Y_TFA_RCA_BASE_URL;
67
+ BROWSERSTACK_AUTOMATION_BASE_URL;
68
+ BROWSERSTACK_O11Y_UI_BASE_URL;
69
+ 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) {
42
70
  this.DEV_MODE = DEV_MODE;
43
71
  this.browserstackLocalOptions = browserstackLocalOptions;
44
72
  this.USE_OWN_LOCAL_BINARY_PROCESS = USE_OWN_LOCAL_BINARY_PROCESS;
45
73
  this.REMOTE_MCP = REMOTE_MCP;
46
74
  this.UPLOAD_BASE_DIR = UPLOAD_BASE_DIR;
75
+ this.O11Y_TFA_RCA_BASE_URL = O11Y_TFA_RCA_BASE_URL;
76
+ this.BROWSERSTACK_AUTOMATION_BASE_URL = BROWSERSTACK_AUTOMATION_BASE_URL;
77
+ this.BROWSERSTACK_O11Y_UI_BASE_URL = BROWSERSTACK_O11Y_UI_BASE_URL;
47
78
  }
48
79
  }
49
80
  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
50
81
  ? process.env.MCP_UPLOAD_BASE_DIR
51
- : undefined);
82
+ : undefined, process.env.O11Y_TFA_RCA_BASE_URL &&
83
+ process.env.O11Y_TFA_RCA_BASE_URL.length > 0
84
+ ? process.env.O11Y_TFA_RCA_BASE_URL
85
+ : DEFAULT_O11Y_TFA_RCA_BASE_URL, process.env.BROWSERSTACK_AUTOMATION_BASE_URL &&
86
+ process.env.BROWSERSTACK_AUTOMATION_BASE_URL.length > 0
87
+ ? process.env.BROWSERSTACK_AUTOMATION_BASE_URL
88
+ : DEFAULT_BROWSERSTACK_AUTOMATION_BASE_URL, process.env.BROWSERSTACK_O11Y_UI_BASE_URL &&
89
+ process.env.BROWSERSTACK_O11Y_UI_BASE_URL.length > 0
90
+ ? process.env.BROWSERSTACK_O11Y_UI_BASE_URL
91
+ : DEFAULT_BROWSERSTACK_O11Y_UI_BASE_URL);
52
92
  export default config;
@@ -16,6 +16,7 @@ import addAppLiveTools from "./tools/applive.js";
16
16
  import addBuildInsightsTools from "./tools/build-insights.js";
17
17
  import { setupOnInitialized } from "./oninitialized.js";
18
18
  import addRCATools from "./tools/rca-agent.js";
19
+ import addTfaRcaCollaborationTools from "./tools/tfa-rca-collaboration.js";
19
20
  /**
20
21
  * Wrapper class for BrowserStack MCP Server
21
22
  * Stores a map of registered tools by name
@@ -51,6 +52,7 @@ export class BrowserStackMcpServer {
51
52
  addSelfHealTools,
52
53
  addBuildInsightsTools,
53
54
  addRCATools,
55
+ addTfaRcaCollaborationTools,
54
56
  ];
55
57
  toolAdders.forEach((adder) => {
56
58
  // Each adder now returns a Record<string, Tool>
@@ -1,5 +1,13 @@
1
1
  import { z } from "zod";
2
2
  import { TestStatus } from "./types.js";
3
+ /**
4
+ * Base URL for the Automate test-runs API used by `listTestIds`. Process-startup
5
+ * config resolved in `src/config.ts` from `BROWSERSTACK_AUTOMATION_BASE_URL`
6
+ * (default prod). Set it to a rengg/staging host to list a build that lives
7
+ * there. Read per call so a per-server-instance config is honored; never read
8
+ * `process.env` here.
9
+ */
10
+ export declare function getAutomationBaseUrl(): string;
3
11
  export declare const FETCH_RCA_PARAMS: {
4
12
  testId: z.ZodArray<z.ZodNumber>;
5
13
  };
@@ -10,4 +18,5 @@ export declare const GET_BUILD_ID_PARAMS: {
10
18
  export declare const LIST_TEST_IDS_PARAMS: {
11
19
  buildId: z.ZodString;
12
20
  status: z.ZodEnum<typeof TestStatus>;
21
+ includeFailureDetail: z.ZodOptional<z.ZodBoolean>;
13
22
  };
@@ -1,5 +1,16 @@
1
1
  import { z } from "zod";
2
+ import appConfig from "../../config.js";
2
3
  import { TestStatus } from "./types.js";
4
+ /**
5
+ * Base URL for the Automate test-runs API used by `listTestIds`. Process-startup
6
+ * config resolved in `src/config.ts` from `BROWSERSTACK_AUTOMATION_BASE_URL`
7
+ * (default prod). Set it to a rengg/staging host to list a build that lives
8
+ * there. Read per call so a per-server-instance config is honored; never read
9
+ * `process.env` here.
10
+ */
11
+ export function getAutomationBaseUrl() {
12
+ return appConfig.BROWSERSTACK_AUTOMATION_BASE_URL;
13
+ }
3
14
  export const FETCH_RCA_PARAMS = {
4
15
  testId: z
5
16
  .array(z.number().int())
@@ -21,4 +32,8 @@ export const LIST_TEST_IDS_PARAMS = {
21
32
  status: z
22
33
  .nativeEnum(TestStatus)
23
34
  .describe("Filter tests by status. If not provided, all tests are returned. Example for RCA usecase always use failed status"),
35
+ includeFailureDetail: z
36
+ .boolean()
37
+ .optional()
38
+ .describe("Add per-test failure signature for clustering. Default false."),
24
39
  };
@@ -1,3 +1,3 @@
1
1
  import { TestStatus, FailedTestInfo, TestDetails } from "./types.js";
2
- export declare function getTestIds(buildId: string, authString: string, status?: TestStatus): Promise<FailedTestInfo[]>;
3
- export declare function extractFailedTestIds(hierarchy: TestDetails[], status?: TestStatus): FailedTestInfo[];
2
+ export declare function getTestIds(buildId: string, authString: string, status?: TestStatus, includeFailureDetail?: boolean): Promise<FailedTestInfo[]>;
3
+ export declare function extractFailedTestIds(hierarchy: TestDetails[], status?: TestStatus, includeFailureDetail?: boolean): FailedTestInfo[];
@@ -1,6 +1,10 @@
1
1
  import logger from "../../logger.js";
2
- export async function getTestIds(buildId, authString, status) {
3
- const baseUrl = `https://api-automation.browserstack.com/ext/v1/builds/${buildId}/testRuns`;
2
+ import { getAutomationBaseUrl } from "./constants.js";
3
+ // Cap on the failure summary line — keep the response payload lean (we never
4
+ // return full stack traces into the MCP client's context window).
5
+ const ERROR_SUMMARY_MAX = 200;
6
+ export async function getTestIds(buildId, authString, status, includeFailureDetail = false) {
7
+ const baseUrl = `${getAutomationBaseUrl()}/ext/v1/builds/${buildId}/testRuns`;
4
8
  let url = status ? `${baseUrl}?test_statuses=${status}` : baseUrl;
5
9
  let allFailedTests = [];
6
10
  let requestNumber = 0;
@@ -22,7 +26,7 @@ export async function getTestIds(buildId, authString, status) {
22
26
  const data = (await response.json());
23
27
  // Extract failed IDs from current page
24
28
  if (data.hierarchy && data.hierarchy.length > 0) {
25
- const currentFailedTests = extractFailedTestIds(data.hierarchy, status);
29
+ const currentFailedTests = extractFailedTestIds(data.hierarchy, status, includeFailureDetail);
26
30
  allFailedTests = allFailedTests.concat(currentFailedTests);
27
31
  }
28
32
  // Check for pagination termination conditions
@@ -46,23 +50,82 @@ export async function getTestIds(buildId, authString, status) {
46
50
  throw error;
47
51
  }
48
52
  }
49
- export function extractFailedTestIds(hierarchy, status) {
53
+ export function extractFailedTestIds(hierarchy, status, includeFailureDetail = false) {
50
54
  let failedTests = [];
51
55
  for (const node of hierarchy) {
56
+ // Match on status alone — the observability_url `details=<id>` check below
57
+ // already filters to real test nodes (suite/hook nodes carry no status and
58
+ // no such URL). Do NOT also require run_count: JUnit-uploaded builds report
59
+ // run_count=0 even for genuinely failed tests, which would drop them all.
52
60
  if (node.details?.status === status) {
53
61
  if (node.details?.observability_url) {
54
62
  const idMatch = node.details.observability_url.match(/details=(\d+)/);
55
63
  if (idMatch) {
56
- failedTests.push({
64
+ const entry = {
57
65
  test_id: idMatch[1],
58
66
  test_name: node.display_name || `Test ${idMatch[1]}`,
59
- });
67
+ };
68
+ if (includeFailureDetail) {
69
+ const signature = buildFailureSignature(node.details);
70
+ if (signature)
71
+ entry.failure = signature;
72
+ }
73
+ failedTests.push(entry);
60
74
  }
61
75
  }
62
76
  }
63
77
  if (node.children && node.children.length > 0) {
64
- failedTests = failedTests.concat(extractFailedTestIds(node.children, status));
78
+ failedTests = failedTests.concat(extractFailedTestIds(node.children, status, includeFailureDetail));
65
79
  }
66
80
  }
67
81
  return failedTests;
68
82
  }
83
+ // Build a trimmed failure signature from a test node's `details`. Returns
84
+ // undefined when no signal is available so the field is simply omitted.
85
+ function buildFailureSignature(details) {
86
+ if (!details)
87
+ return undefined;
88
+ const signature = {};
89
+ if (details.failure_categories != null) {
90
+ signature.category = Array.isArray(details.failure_categories)
91
+ ? details.failure_categories.filter(Boolean).join(", ")
92
+ : String(details.failure_categories);
93
+ }
94
+ const errorSummary = extractFirstFailureLine(details);
95
+ if (errorSummary)
96
+ signature.error_summary = errorSummary;
97
+ if (details.file_path)
98
+ signature.file_path = String(details.file_path);
99
+ if (typeof details.is_flaky === "boolean")
100
+ signature.is_flaky = details.is_flaky;
101
+ if (typeof details.is_always_failing === "boolean")
102
+ signature.is_always_failing = details.is_always_failing;
103
+ if (typeof details.is_new_failure === "boolean")
104
+ signature.is_new_failure = details.is_new_failure;
105
+ return Object.keys(signature).length > 0 ? signature : undefined;
106
+ }
107
+ // First non-empty line of the first retry's TEST_FAILURE log, capped. Handles
108
+ // both string entries and object entries ({ message } / { text }).
109
+ function extractFirstFailureLine(details) {
110
+ const retries = details?.retries;
111
+ if (!Array.isArray(retries))
112
+ return undefined;
113
+ for (const retry of retries) {
114
+ const failures = retry?.logs?.TEST_FAILURE;
115
+ if (!failures)
116
+ continue;
117
+ const entries = Array.isArray(failures) ? failures : [failures];
118
+ for (const failure of entries) {
119
+ const text = typeof failure === "string"
120
+ ? failure
121
+ : (failure?.message ?? failure?.text ?? "");
122
+ const firstLine = String(text)
123
+ .split("\n")
124
+ .map((line) => line.trim())
125
+ .find((line) => line.length > 0);
126
+ if (firstLine)
127
+ return firstLine.slice(0, ERROR_SUMMARY_MAX);
128
+ }
129
+ }
130
+ return undefined;
131
+ }
@@ -17,9 +17,18 @@ export interface TestRun {
17
17
  next_page: string | null;
18
18
  };
19
19
  }
20
+ export interface TestFailureSignature {
21
+ category?: string;
22
+ error_summary?: string;
23
+ file_path?: string;
24
+ is_flaky?: boolean;
25
+ is_always_failing?: boolean;
26
+ is_new_failure?: boolean;
27
+ }
20
28
  export interface FailedTestInfo {
21
29
  test_id: number;
22
30
  test_name: string;
31
+ failure?: TestFailureSignature;
23
32
  }
24
33
  export declare enum RCAState {
25
34
  PENDING = "pending",
@@ -11,5 +11,6 @@ export declare function fetchRCADataTool(args: {
11
11
  export declare function listTestIdsTool(args: {
12
12
  buildId: string;
13
13
  status?: TestStatus;
14
+ includeFailureDetail?: boolean;
14
15
  }, config: BrowserStackConfig): Promise<CallToolResult>;
15
16
  export default function addRCATools(server: McpServer, config: BrowserStackConfig): Record<string, any>;
@@ -101,10 +101,10 @@ export async function fetchRCADataTool(args, config) {
101
101
  }
102
102
  export async function listTestIdsTool(args, config) {
103
103
  try {
104
- const { buildId, status } = args;
104
+ const { buildId, status, includeFailureDetail } = args;
105
105
  const authString = getBrowserStackAuth(config);
106
106
  // Get test IDs
107
- const testIds = await getTestIds(buildId, authString, status);
107
+ const testIds = await getTestIds(buildId, authString, status, includeFailureDetail);
108
108
  return {
109
109
  content: [
110
110
  {
@@ -65,7 +65,13 @@ export async function listTestCases(args, config) {
65
65
  const count = info?.count ?? test_cases.length;
66
66
  // Summary for more focused output
67
67
  const summary = test_cases
68
- .map((tc) => `• ${tc.identifier}: ${tc.title} [${tc.case_type} | ${tc.priority}]`)
68
+ .map((tc) => {
69
+ const links = (tc.issues ?? [])
70
+ .filter((i) => i?.issue_type && i?.jira_id)
71
+ .map((i) => `${i.issue_type}:${i.jira_id}`)
72
+ .join(", ");
73
+ return `• ${tc.identifier}: ${tc.title} [${tc.case_type} | ${tc.priority}]${links ? ` {linked: ${links}}` : ""}`;
74
+ })
69
75
  .join("\n");
70
76
  return {
71
77
  content: [
@@ -17,11 +17,26 @@ export declare const UpdateTestRunSchema: z.ZodObject<{
17
17
  rejected: "rejected";
18
18
  closed: "closed";
19
19
  }>>;
20
+ add_test_cases: z.ZodOptional<z.ZodArray<z.ZodObject<{
21
+ test_case_ids: z.ZodArray<z.ZodString>;
22
+ configuration_ids: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
23
+ }, z.core.$strip>>>;
24
+ preserve_existing_results: z.ZodOptional<z.ZodBoolean>;
20
25
  }, z.core.$strip>;
21
26
  }, z.core.$strip>;
22
27
  type UpdateTestRunArgs = z.infer<typeof UpdateTestRunSchema>;
23
28
  /**
24
29
  * Partially updates an existing test run.
30
+ *
31
+ * Dispatches to one of two BrowserStack endpoints based on the fields provided,
32
+ * mirroring how the platform splits these concerns across two endpoints:
33
+ * - metadata (name / run_state) -> PATCH .../test-runs/{id}/update
34
+ * - adding test cases -> PATCH .../test-runs/{id}/test-cases
35
+ *
36
+ * Either or both may be supplied in one call; each provided concern hits its
37
+ * own endpoint and both outcomes are reported. At least one must be provided.
38
+ * Removing test cases is intentionally not exposed — this tool is
39
+ * non-destructive.
25
40
  */
26
41
  export declare function updateTestRun(args: UpdateTestRunArgs, config: BrowserStackConfig): Promise<CallToolResult>;
27
42
  export {};
@@ -3,6 +3,19 @@ import { getBrowserStackAuth } from "../../lib/get-auth.js";
3
3
  import { z } from "zod";
4
4
  import { formatAxiosError } from "../../lib/error.js";
5
5
  import { getTMBaseURL } from "../../lib/tm-base-url.js";
6
+ /**
7
+ * Selection of test cases (with optional configurations) to add.
8
+ */
9
+ const TestCaseSelectionSchema = z.object({
10
+ test_case_ids: z
11
+ .array(z.string())
12
+ .min(1)
13
+ .describe("Test case IDs, e.g. TC-123"),
14
+ configuration_ids: z
15
+ .array(z.number())
16
+ .optional()
17
+ .describe("Configuration IDs to apply"),
18
+ });
6
19
  /**
7
20
  * Schema for updating a test run with partial fields.
8
21
  */
@@ -24,22 +37,82 @@ export const UpdateTestRunSchema = z.object({
24
37
  ])
25
38
  .optional()
26
39
  .describe("Updated state of the test run"),
40
+ add_test_cases: z
41
+ .array(TestCaseSelectionSchema)
42
+ .optional()
43
+ .describe("Test cases to add to the run"),
44
+ preserve_existing_results: z
45
+ .boolean()
46
+ .optional()
47
+ .describe("Keep existing results when adding cases (default true)"),
27
48
  }),
28
49
  });
50
+ /**
51
+ * Builds the HTTP Basic auth header from per-request config credentials.
52
+ */
53
+ function buildAuthHeader(config) {
54
+ return "Basic " + Buffer.from(getBrowserStackAuth(config)).toString("base64");
55
+ }
29
56
  /**
30
57
  * Partially updates an existing test run.
58
+ *
59
+ * Dispatches to one of two BrowserStack endpoints based on the fields provided,
60
+ * mirroring how the platform splits these concerns across two endpoints:
61
+ * - metadata (name / run_state) -> PATCH .../test-runs/{id}/update
62
+ * - adding test cases -> PATCH .../test-runs/{id}/test-cases
63
+ *
64
+ * Either or both may be supplied in one call; each provided concern hits its
65
+ * own endpoint and both outcomes are reported. At least one must be provided.
66
+ * Removing test cases is intentionally not exposed — this tool is
67
+ * non-destructive.
31
68
  */
32
69
  export async function updateTestRun(args, config) {
70
+ const { name, run_state, add_test_cases } = args.test_run;
71
+ const addIds = add_test_cases?.flatMap((s) => s.test_case_ids) ?? [];
72
+ const hasTestCases = addIds.length > 0;
73
+ const hasMetadata = name !== undefined || run_state !== undefined;
74
+ if (!hasTestCases && !hasMetadata) {
75
+ return {
76
+ content: [
77
+ {
78
+ type: "text",
79
+ text: "Nothing to update: provide name/run_state and/or add_test_cases.",
80
+ },
81
+ ],
82
+ isError: true,
83
+ };
84
+ }
85
+ const tmBaseUrl = await getTMBaseURL(config);
86
+ const authHeader = buildAuthHeader(config);
87
+ const tasks = [];
88
+ if (hasMetadata) {
89
+ tasks.push(updateTestRunMetadata(args, tmBaseUrl, authHeader));
90
+ }
91
+ if (hasTestCases) {
92
+ tasks.push(updateTestRunTestCases(args, tmBaseUrl, authHeader));
93
+ }
94
+ const results = await Promise.all(tasks);
95
+ if (results.length === 1) {
96
+ return results[0];
97
+ }
98
+ // Both concerns updated: aggregate outcomes; surface an error if either failed.
99
+ return {
100
+ content: results.flatMap((r) => r.content),
101
+ isError: results.some((r) => r.isError),
102
+ };
103
+ }
104
+ /**
105
+ * Updates test run metadata (name / run_state) via the /update endpoint.
106
+ */
107
+ async function updateTestRunMetadata(args, baseUrl, authHeader) {
33
108
  try {
34
- const body = { test_run: args.test_run };
35
- const tmBaseUrl = await getTMBaseURL(config);
36
- const url = `${tmBaseUrl}/api/v2/projects/${encodeURIComponent(args.project_identifier)}/test-runs/${encodeURIComponent(args.test_run_id)}/update`;
37
- const authString = getBrowserStackAuth(config);
38
- const [username, password] = authString.split(":");
109
+ const { name, run_state } = args.test_run;
110
+ const body = { test_run: { name, run_state } };
111
+ const url = `${baseUrl}/api/v2/projects/${encodeURIComponent(args.project_identifier)}/test-runs/${encodeURIComponent(args.test_run_id)}/update`;
39
112
  const resp = await apiClient.patch({
40
113
  url,
41
114
  headers: {
42
- Authorization: "Basic " + Buffer.from(`${username}:${password}`).toString("base64"),
115
+ Authorization: authHeader,
43
116
  "Content-Type": "application/json",
44
117
  },
45
118
  body,
@@ -70,3 +143,52 @@ export async function updateTestRun(args, config) {
70
143
  return formatAxiosError(err, "Failed to update test run");
71
144
  }
72
145
  }
146
+ /**
147
+ * Adds test cases to a run via the /test-cases endpoint.
148
+ * This call is applied asynchronously by the backend.
149
+ */
150
+ async function updateTestRunTestCases(args, baseUrl, authHeader) {
151
+ try {
152
+ const { add_test_cases, preserve_existing_results } = args.test_run;
153
+ const body = {
154
+ test_run: {
155
+ add_test_cases,
156
+ preserve_existing_results: preserve_existing_results ?? true,
157
+ },
158
+ };
159
+ const url = `${baseUrl}/api/v2/projects/${encodeURIComponent(args.project_identifier)}/test-runs/${encodeURIComponent(args.test_run_id)}/test-cases`;
160
+ const resp = await apiClient.patch({
161
+ url,
162
+ headers: {
163
+ Authorization: authHeader,
164
+ "Content-Type": "application/json",
165
+ },
166
+ body,
167
+ });
168
+ const data = resp.data;
169
+ if (!data.success) {
170
+ return {
171
+ content: [
172
+ {
173
+ type: "text",
174
+ text: `Failed to update test run test cases: ${JSON.stringify(data)}`,
175
+ },
176
+ ],
177
+ isError: true,
178
+ };
179
+ }
180
+ const added = add_test_cases?.flatMap((s) => s.test_case_ids) ?? [];
181
+ return {
182
+ content: [
183
+ {
184
+ type: "text",
185
+ text: `Queued test-case update for ${args.test_run_id} (added ${added.length}); changes apply asynchronously.`,
186
+ },
187
+ { type: "text", text: JSON.stringify(data, null, 2) },
188
+ ],
189
+ };
190
+ }
191
+ catch (err) {
192
+ return formatAxiosError(err, "Failed to update test run test cases");
193
+ }
194
+ }
@@ -400,7 +400,7 @@ export default function addTestManagementTools(server, config) {
400
400
  tools.listTestCaseTemplates = server.tool("listTestCaseTemplates", "List test-case templates with their numeric template_id. Use the id with createTestCase to apply a custom template (the 'template' slug only selects system templates).", ListTemplatesSchema.shape, (args) => listTemplatesTool(args, config, server));
401
401
  tools.createTestRun = server.tool("createTestRun", "Create a test run in BrowserStack Test Management.", CreateTestRunSchema.shape, (args) => createTestRunTool(args, config, server));
402
402
  tools.listTestRuns = server.tool("listTestRuns", "List test runs in a project with optional filters (date ranges, assignee, state, etc.)", ListTestRunsSchema.shape, (args) => listTestRunsTool(args, config, server));
403
- tools.updateTestRun = server.tool("updateTestRun", "Update a test run in BrowserStack Test Management.", UpdateTestRunSchema.shape, (args) => updateTestRunTool(args, config, server));
403
+ tools.updateTestRun = server.tool("updateTestRun", "Update a test run's metadata and/or add test cases to it.", UpdateTestRunSchema.shape, (args) => updateTestRunTool(args, config, server));
404
404
  tools.addTestResult = server.tool("addTestResult", "Add a test result to a specific test run via BrowserStack Test Management API.", AddTestResultSchema.shape, (args) => addTestResultTool(args, config, server));
405
405
  tools.uploadProductRequirementFile = server.tool("uploadProductRequirementFile", "Upload files (e.g., PDRs, PDFs) to BrowserStack Test Management and retrieve a file mapping ID. This is utilized for generating test cases from files and is part of the Test Case Generator AI Agent in BrowserStack.", UploadFileSchema.shape, (args) => uploadProductRequirementFileTool(args, config, server));
406
406
  tools.createTestCasesFromFile = server.tool("createTestCasesFromFile", "Generate test cases from a file in BrowserStack Test Management using the Test Case Generator AI Agent.", CreateTestCasesFromFileSchema.shape, (args, context) => createTestCasesFromFileTool(args, context, config, server));
@@ -0,0 +1,8 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
3
+ import { BrowserStackConfig } from "../lib/types.js";
4
+ import { TfaRcaTurnArgs } from "./tfa-rca-utils/submit-turn.js";
5
+ import { TriggerRcaReportArgs } from "./tfa-rca-utils/trigger-report.js";
6
+ export declare function tfaRcaTurnTool(args: TfaRcaTurnArgs, config: BrowserStackConfig, context?: any): Promise<CallToolResult>;
7
+ export declare function triggerRcaReportTool(args: TriggerRcaReportArgs, config: BrowserStackConfig): Promise<CallToolResult>;
8
+ export default function addTfaRcaCollaborationTools(server: McpServer, config: BrowserStackConfig): Record<string, any>;
@@ -0,0 +1,78 @@
1
+ import { trackMCP } from "../lib/instrumentation.js";
2
+ import { handleMCPError } from "../lib/utils.js";
3
+ import { TFA_RCA_TURN_PARAMS, TRIGGER_RCA_REPORT_PARAMS, } from "./tfa-rca-utils/constants.js";
4
+ import { submitTfaRcaTurn, TfaRcaTurnError, } from "./tfa-rca-utils/submit-turn.js";
5
+ import { triggerRcaReport, TriggerRcaReportError, } from "./tfa-rca-utils/trigger-report.js";
6
+ const TOOL_NAME = "tfaRcaTurn";
7
+ const TRIGGER_TOOL_NAME = "triggerRcaReport";
8
+ /** Wrap a domain error into the standard `{ isError: true }` envelope. */
9
+ function domainErrorResult(toolName, error) {
10
+ const readable = toolName.replace(/([A-Z])/g, " $1").toLowerCase();
11
+ return {
12
+ content: [
13
+ {
14
+ type: "text",
15
+ text: `Failed to ${readable}: ${error.message}`,
16
+ },
17
+ ],
18
+ isError: true,
19
+ };
20
+ }
21
+ export async function tfaRcaTurnTool(args, config, context) {
22
+ // The util returns the trimmed, status-discriminated contract; JSON.stringify
23
+ // drops the undefined slots, so the wrapper stays a plain serializer.
24
+ const result = await submitTfaRcaTurn(args, config, context);
25
+ return {
26
+ content: [
27
+ {
28
+ type: "text",
29
+ text: JSON.stringify(result, null, 2),
30
+ },
31
+ ],
32
+ };
33
+ }
34
+ export async function triggerRcaReportTool(args, config) {
35
+ const glimpse = await triggerRcaReport(args, config);
36
+ return {
37
+ content: [
38
+ {
39
+ type: "text",
40
+ text: JSON.stringify(glimpse, null, 2),
41
+ },
42
+ ],
43
+ };
44
+ }
45
+ export default function addTfaRcaCollaborationTools(server, config) {
46
+ const tools = {};
47
+ tools.tfaRcaTurn = server.tool(TOOL_NAME, "Submit one collaborative RCA turn for a test run to the TFA agent; returns status, asks, and RCA.", TFA_RCA_TURN_PARAMS, async (args, context) => {
48
+ try {
49
+ const result = await tfaRcaTurnTool(args, config, context);
50
+ trackMCP(TOOL_NAME, server.server.getClientVersion(), undefined, config);
51
+ return result;
52
+ }
53
+ catch (error) {
54
+ // Domain failures carry a client-safe, group-scope-safe message.
55
+ if (error instanceof TfaRcaTurnError) {
56
+ trackMCP(TOOL_NAME, server.server.getClientVersion(), error, config);
57
+ return domainErrorResult(TOOL_NAME, error);
58
+ }
59
+ return handleMCPError(TOOL_NAME, server, config, error);
60
+ }
61
+ });
62
+ tools.triggerRcaReport = server.tool(TRIGGER_TOOL_NAME, "Trigger or read a build's Release Readiness report; returns a verdict glimpse and a UI link.", TRIGGER_RCA_REPORT_PARAMS, async (args) => {
63
+ try {
64
+ const result = await triggerRcaReportTool(args, config);
65
+ trackMCP(TRIGGER_TOOL_NAME, server.server.getClientVersion(), undefined, config);
66
+ return result;
67
+ }
68
+ catch (error) {
69
+ // Domain failures carry a client-safe, group-scope-safe message.
70
+ if (error instanceof TriggerRcaReportError) {
71
+ trackMCP(TRIGGER_TOOL_NAME, server.server.getClientVersion(), error, config);
72
+ return domainErrorResult(TRIGGER_TOOL_NAME, error);
73
+ }
74
+ return handleMCPError(TRIGGER_TOOL_NAME, server, config, error);
75
+ }
76
+ });
77
+ return tools;
78
+ }
@@ -0,0 +1,65 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * O11y base for the `rcaChat` proxy. The MCP server talks ONLY to o11y-api
4
+ * (boundary discipline, R9). The value is process-startup config resolved in
5
+ * `src/config.ts` from `O11Y_TFA_RCA_BASE_URL` (default: production) — never read
6
+ * `process.env` here. Resolved per call so a config built per server instance
7
+ * (multi-tenant) is honored.
8
+ */
9
+ export declare function getO11yBaseUrl(): string;
10
+ /**
11
+ * Test Observability (TRA) web UI base for human-facing "view report" links.
12
+ * Startup config (`BROWSERSTACK_O11Y_UI_BASE_URL` in `src/config.ts`, default
13
+ * observability.browserstack.com) — never read `process.env` here.
14
+ */
15
+ export declare function getO11yUiBaseUrl(): string;
16
+ /**
17
+ * TRA UI deep-link for a build's AI report (confirmed shape, 2026-07-13):
18
+ * `<UI_BASE>/builds/<buildUuid>?tab=ai_report&subTab=aitfa` — the AI-TFA
19
+ * sub-tab of the build's AI report. `{buildUuid}` is replaced with the
20
+ * caller-supplied build id.
21
+ */
22
+ export declare const O11Y_UI_BUILD_PATH = "/builds/{buildUuid}?tab=ai_report&subTab=aitfa";
23
+ /** Human-facing TRA UI link for one build's full report. */
24
+ export declare function getO11yUiBuildUrl(buildUuid: string): string;
25
+ /**
26
+ * Generic TRA UI pointer used on RESOLVED turns where only a testRunId is
27
+ * known (no buildUuid to deep-link). The full RCA lives on the dashboard
28
+ * (build page → AI report → AI TFA sub-tab).
29
+ */
30
+ export declare function getRcaViewGuidance(): string;
31
+ /** Trigger (or read, when already complete) a build's Release Readiness report. */
32
+ export declare const RELEASE_READINESS_TRIGGER_PATH = "/ext/v1/ai/builds/{buildUuid}/releaseReadiness/trigger";
33
+ /** Submit one collaborative turn for a test run. */
34
+ export declare const RCA_CHAT_SUBMIT_PATH = "/ext/v1/testRuns/{testRunId}/rcaChat";
35
+ /** Poll a submitted turn to completion. */
36
+ export declare const RCA_CHAT_POLL_PATH = "/ext/v1/testRuns/{testRunId}/rcaChat/{turnId}";
37
+ /** Initial wait before the first poll GET. */
38
+ export declare const POLL_INITIAL_DELAY_MS = 2000;
39
+ /** Interval between poll GETs. */
40
+ export declare const POLL_INTERVAL_MS = 3000;
41
+ /** Wall-clock cap for the in-call poll; exceeding it yields a soft PENDING. */
42
+ export declare const POLL_MAX_WAIT_MS: number;
43
+ /** Max length of the digest message, matching o11y's request `@Size`. */
44
+ export declare const MESSAGE_MAX_LENGTH = 5000;
45
+ /** Max chars of `root_cause` surfaced in the RESOLVED glimpse. */
46
+ export declare const RCA_GLIMPSE_ROOT_CAUSE_MAX = 220;
47
+ /**
48
+ * Zod param shapes for the `tfaRcaTurn` tool, exported as a
49
+ * `Record<string, ZodType>` mirroring `rca-agent-utils/constants.ts`.
50
+ * No credential fields (security rule). Each `.describe()` ≤ 60 chars.
51
+ */
52
+ export declare const TFA_RCA_TURN_PARAMS: {
53
+ testRunId: z.ZodString;
54
+ message: z.ZodString;
55
+ threadId: z.ZodOptional<z.ZodString>;
56
+ turnId: z.ZodOptional<z.ZodString>;
57
+ };
58
+ /**
59
+ * Zod param shapes for the `triggerRcaReport` tool. No credential fields
60
+ * (security rule). Each `.describe()` ≤ 60 chars.
61
+ */
62
+ export declare const TRIGGER_RCA_REPORT_PARAMS: {
63
+ buildUuid: z.ZodString;
64
+ force: z.ZodOptional<z.ZodBoolean>;
65
+ };
@@ -0,0 +1,87 @@
1
+ import { z } from "zod";
2
+ import appConfig from "../../config.js";
3
+ /**
4
+ * O11y base for the `rcaChat` proxy. The MCP server talks ONLY to o11y-api
5
+ * (boundary discipline, R9). The value is process-startup config resolved in
6
+ * `src/config.ts` from `O11Y_TFA_RCA_BASE_URL` (default: production) — never read
7
+ * `process.env` here. Resolved per call so a config built per server instance
8
+ * (multi-tenant) is honored.
9
+ */
10
+ export function getO11yBaseUrl() {
11
+ return appConfig.O11Y_TFA_RCA_BASE_URL;
12
+ }
13
+ /**
14
+ * Test Observability (TRA) web UI base for human-facing "view report" links.
15
+ * Startup config (`BROWSERSTACK_O11Y_UI_BASE_URL` in `src/config.ts`, default
16
+ * observability.browserstack.com) — never read `process.env` here.
17
+ */
18
+ export function getO11yUiBaseUrl() {
19
+ return appConfig.BROWSERSTACK_O11Y_UI_BASE_URL;
20
+ }
21
+ /**
22
+ * TRA UI deep-link for a build's AI report (confirmed shape, 2026-07-13):
23
+ * `<UI_BASE>/builds/<buildUuid>?tab=ai_report&subTab=aitfa` — the AI-TFA
24
+ * sub-tab of the build's AI report. `{buildUuid}` is replaced with the
25
+ * caller-supplied build id.
26
+ */
27
+ export const O11Y_UI_BUILD_PATH = "/builds/{buildUuid}?tab=ai_report&subTab=aitfa";
28
+ /** Human-facing TRA UI link for one build's full report. */
29
+ export function getO11yUiBuildUrl(buildUuid) {
30
+ return (getO11yUiBaseUrl() +
31
+ O11Y_UI_BUILD_PATH.replace("{buildUuid}", encodeURIComponent(buildUuid)));
32
+ }
33
+ /**
34
+ * Generic TRA UI pointer used on RESOLVED turns where only a testRunId is
35
+ * known (no buildUuid to deep-link). The full RCA lives on the dashboard
36
+ * (build page → AI report → AI TFA sub-tab).
37
+ */
38
+ export function getRcaViewGuidance() {
39
+ return `${getO11yUiBaseUrl()} — open the build's AI report (tab=ai_report, subTab=aitfa) to view the full RCA`;
40
+ }
41
+ /** Trigger (or read, when already complete) a build's Release Readiness report. */
42
+ export const RELEASE_READINESS_TRIGGER_PATH = "/ext/v1/ai/builds/{buildUuid}/releaseReadiness/trigger";
43
+ /** Submit one collaborative turn for a test run. */
44
+ export const RCA_CHAT_SUBMIT_PATH = "/ext/v1/testRuns/{testRunId}/rcaChat";
45
+ /** Poll a submitted turn to completion. */
46
+ export const RCA_CHAT_POLL_PATH = "/ext/v1/testRuns/{testRunId}/rcaChat/{turnId}";
47
+ /** Initial wait before the first poll GET. */
48
+ export const POLL_INITIAL_DELAY_MS = 2000;
49
+ /** Interval between poll GETs. */
50
+ export const POLL_INTERVAL_MS = 3000;
51
+ /** Wall-clock cap for the in-call poll; exceeding it yields a soft PENDING. */
52
+ export const POLL_MAX_WAIT_MS = 90 * 1000;
53
+ /** Max length of the digest message, matching o11y's request `@Size`. */
54
+ export const MESSAGE_MAX_LENGTH = 5000;
55
+ /** Max chars of `root_cause` surfaced in the RESOLVED glimpse. */
56
+ export const RCA_GLIMPSE_ROOT_CAUSE_MAX = 220;
57
+ /**
58
+ * Zod param shapes for the `tfaRcaTurn` tool, exported as a
59
+ * `Record<string, ZodType>` mirroring `rca-agent-utils/constants.ts`.
60
+ * No credential fields (security rule). Each `.describe()` ≤ 60 chars.
61
+ */
62
+ export const TFA_RCA_TURN_PARAMS = {
63
+ testRunId: z.string().describe("Test run id to run RCA collaboration on."),
64
+ message: z
65
+ .string()
66
+ .max(MESSAGE_MAX_LENGTH)
67
+ .describe("Digested analysis to send this turn; no raw logs."),
68
+ threadId: z
69
+ .string()
70
+ .optional()
71
+ .describe("Thread id from prior turn; omit on first turn."),
72
+ turnId: z
73
+ .string()
74
+ .optional()
75
+ .describe("Turn id to resume a pending poll; usually omit."),
76
+ };
77
+ /**
78
+ * Zod param shapes for the `triggerRcaReport` tool. No credential fields
79
+ * (security rule). Each `.describe()` ≤ 60 chars.
80
+ */
81
+ export const TRIGGER_RCA_REPORT_PARAMS = {
82
+ buildUuid: z.string().describe("Automate build UUID to analyze."),
83
+ force: z
84
+ .boolean()
85
+ .optional()
86
+ .describe("Re-run even if a completed report exists."),
87
+ };
@@ -0,0 +1,28 @@
1
+ import { BrowserStackConfig } from "../../lib/types.js";
2
+ import { TfaRcaTurnResult } from "./types.js";
3
+ interface TurnContext {
4
+ sendNotification?: (notification: any) => Promise<void>;
5
+ _meta?: {
6
+ progressToken?: string | number;
7
+ };
8
+ }
9
+ export interface TfaRcaTurnArgs {
10
+ testRunId: string;
11
+ message: string;
12
+ threadId?: string;
13
+ /** Resume polling an already-submitted turn without re-submitting. */
14
+ turnId?: string;
15
+ }
16
+ /**
17
+ * Domain error carrying a client-safe message. The tool maps these to a
18
+ * `{ isError: true }` envelope; the message never contains credentials.
19
+ */
20
+ export declare class TfaRcaTurnError extends Error {
21
+ }
22
+ /**
23
+ * Submit one collaborative RCA turn to the o11y `rcaChat` proxy and poll to
24
+ * completion, returning a trimmed structured result. Stateless: all identifiers
25
+ * are function-scoped; nothing persists between calls.
26
+ */
27
+ export declare function submitTfaRcaTurn(args: TfaRcaTurnArgs, config: BrowserStackConfig, context?: TurnContext): Promise<TfaRcaTurnResult>;
28
+ export {};
@@ -0,0 +1,222 @@
1
+ import { apiClient } from "../../lib/apiClient.js";
2
+ import { getBrowserStackAuth } from "../../lib/get-auth.js";
3
+ import { getO11yBaseUrl, getRcaViewGuidance, POLL_INITIAL_DELAY_MS, POLL_INTERVAL_MS, POLL_MAX_WAIT_MS, RCA_CHAT_POLL_PATH, RCA_CHAT_SUBMIT_PATH, RCA_GLIMPSE_ROOT_CAUSE_MAX, } from "./constants.js";
4
+ import { PENDING_STATUS, TfaStatus, } from "./types.js";
5
+ /**
6
+ * Domain error carrying a client-safe message. The tool maps these to a
7
+ * `{ isError: true }` envelope; the message never contains credentials.
8
+ */
9
+ export class TfaRcaTurnError extends Error {
10
+ }
11
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
12
+ function buildAuthHeader(config) {
13
+ const authString = getBrowserStackAuth(config);
14
+ return `Basic ${Buffer.from(authString).toString("base64")}`;
15
+ }
16
+ async function notify(context, message, progress) {
17
+ if (!context?.sendNotification)
18
+ return;
19
+ await context.sendNotification({
20
+ method: "notifications/progress",
21
+ params: {
22
+ progressToken: context._meta?.progressToken?.toString() ?? "tfa-rca-turn",
23
+ message,
24
+ progress,
25
+ total: 100,
26
+ },
27
+ });
28
+ }
29
+ /** Map a raw status string from the wire onto the `TfaStatus` enum. */
30
+ function toTfaStatus(raw) {
31
+ switch (raw) {
32
+ case "RESOLVED":
33
+ return TfaStatus.RESOLVED;
34
+ case "BLOCKED":
35
+ return TfaStatus.BLOCKED;
36
+ default:
37
+ return TfaStatus.NEEDS_INFO;
38
+ }
39
+ }
40
+ /** Map one wire ask (snake_case) to the client `TfaAsk` (camelCase). */
41
+ function toAsk(raw) {
42
+ return {
43
+ what: raw?.what ?? "",
44
+ why: raw?.why ?? "",
45
+ evidenceType: raw?.evidence_type ?? "other",
46
+ priority: raw?.priority ?? "medium",
47
+ };
48
+ }
49
+ /**
50
+ * Read the LLM-enforced structured turn from the completed `rcaChat` poll body.
51
+ *
52
+ * The poll envelope's own `status` field is the lifecycle state
53
+ * (`working`/`completed`/`failed`); the agent's `TurnResponse` is passed
54
+ * through under `turn` so its `status` (NEEDS_INFO/RESOLVED/BLOCKED) never
55
+ * collides with the lifecycle one. The agent's model validator guarantees the
56
+ * sub-object matching the turn status is present; we still default-fill the
57
+ * lists so the skill never sees `undefined`.
58
+ */
59
+ function readStructuredTurn(data) {
60
+ const turn = data.turn ?? {};
61
+ const status = toTfaStatus(turn.status);
62
+ const needsInfo = turn.needs_info ?? {};
63
+ const blocked = turn.blocked ?? {};
64
+ return {
65
+ status,
66
+ confidence: turn.confidence ?? "unknown",
67
+ questions: Array.isArray(needsInfo.questions) ? needsInfo.questions : [],
68
+ asks: Array.isArray(needsInfo.asks) ? needsInfo.asks.map(toAsk) : [],
69
+ suggestions: Array.isArray(needsInfo.suggestions)
70
+ ? needsInfo.suggestions
71
+ : [],
72
+ hypotheses: Array.isArray(needsInfo.hypotheses) ? needsInfo.hypotheses : [],
73
+ rca: status === TfaStatus.RESOLVED ? (turn.rca ?? undefined) : undefined,
74
+ reason: status === TfaStatus.BLOCKED ? blocked.reason : undefined,
75
+ unmetAsks: status === TfaStatus.BLOCKED && Array.isArray(blocked.unmet_asks)
76
+ ? blocked.unmet_asks
77
+ : undefined,
78
+ };
79
+ }
80
+ /** Truncate to `max` chars total (ellipsis included when cut). */
81
+ function truncate(text, max) {
82
+ if (text === undefined)
83
+ return undefined;
84
+ return text.length > max ? text.slice(0, max - 1) + "…" : text;
85
+ }
86
+ /**
87
+ * Trim a completed turn to the status-discriminated contract:
88
+ * - NEEDS_INFO: questions/asks/suggestions/hypotheses VERBATIM (the client
89
+ * loop consumes them).
90
+ * - RESOLVED: glimpse only (root_cause truncated, failure_type, related_prs)
91
+ * + a `viewRca` pointer — the full RCA lives on the TRA dashboard.
92
+ * - BLOCKED: reason + unmetAsks.
93
+ */
94
+ function toTrimmedResult(turn, threadId) {
95
+ switch (turn.status) {
96
+ case TfaStatus.RESOLVED: {
97
+ const rca = turn.rca ?? {};
98
+ return {
99
+ status: turn.status,
100
+ confidence: turn.confidence,
101
+ threadId,
102
+ glimpse: {
103
+ root_cause: truncate(rca.root_cause, RCA_GLIMPSE_ROOT_CAUSE_MAX),
104
+ failure_type: rca.failure_type,
105
+ related_prs: rca.related_prs,
106
+ },
107
+ viewRca: getRcaViewGuidance(),
108
+ };
109
+ }
110
+ case TfaStatus.BLOCKED:
111
+ return {
112
+ status: turn.status,
113
+ confidence: turn.confidence,
114
+ threadId,
115
+ reason: turn.reason,
116
+ unmetAsks: turn.unmetAsks,
117
+ };
118
+ default:
119
+ return {
120
+ status: turn.status,
121
+ confidence: turn.confidence,
122
+ threadId,
123
+ questions: turn.questions,
124
+ asks: turn.asks,
125
+ suggestions: turn.suggestions,
126
+ hypotheses: turn.hypotheses,
127
+ };
128
+ }
129
+ }
130
+ /** Map a submit (POST) non-2xx into a clean, group-scope-safe domain error. */
131
+ function mapSubmitError(status) {
132
+ if (status === 403) {
133
+ return new TfaRcaTurnError("AI consent not enabled for this group");
134
+ }
135
+ if (status === 404) {
136
+ return new TfaRcaTurnError("test run not found for your group");
137
+ }
138
+ return new TfaRcaTurnError(`failed to submit RCA turn (status ${status})`);
139
+ }
140
+ /**
141
+ * Submit one collaborative RCA turn to the o11y `rcaChat` proxy and poll to
142
+ * completion, returning a trimmed structured result. Stateless: all identifiers
143
+ * are function-scoped; nothing persists between calls.
144
+ */
145
+ export async function submitTfaRcaTurn(args, config, context) {
146
+ const authHeader = buildAuthHeader(config);
147
+ const headers = {
148
+ "Content-Type": "application/json",
149
+ Authorization: authHeader,
150
+ };
151
+ const baseUrl = getO11yBaseUrl();
152
+ let turnId = args.turnId;
153
+ let threadId = args.threadId;
154
+ // Submit only when we are not resuming an existing turn.
155
+ if (!turnId) {
156
+ const submitUrl = baseUrl + RCA_CHAT_SUBMIT_PATH.replace("{testRunId}", args.testRunId);
157
+ await notify(context, "Submitting RCA turn to TFA agent...", 5);
158
+ const body = {
159
+ message: args.message,
160
+ client_context: args.message,
161
+ };
162
+ if (args.threadId) {
163
+ body.thread_id = args.threadId;
164
+ }
165
+ const submitResponse = await apiClient.post({
166
+ url: submitUrl,
167
+ headers,
168
+ body,
169
+ raise_error: false,
170
+ });
171
+ if (!submitResponse.ok) {
172
+ throw mapSubmitError(submitResponse.status);
173
+ }
174
+ const data = submitResponse.data ?? {};
175
+ turnId = data.turnId;
176
+ threadId = data.threadId ?? threadId;
177
+ if (!turnId) {
178
+ throw new TfaRcaTurnError("turn expired or not found");
179
+ }
180
+ }
181
+ // Poll to completion, soft-PENDING on wall-clock cap.
182
+ const pollUrl = baseUrl +
183
+ RCA_CHAT_POLL_PATH.replace("{testRunId}", args.testRunId).replace("{turnId}", turnId);
184
+ await delay(POLL_INITIAL_DELAY_MS);
185
+ const startTime = Date.now();
186
+ while (true) {
187
+ const pollResponse = await apiClient.get({
188
+ url: pollUrl,
189
+ headers,
190
+ raise_error: false,
191
+ });
192
+ if (pollResponse.status === 404) {
193
+ throw new TfaRcaTurnError("turn expired or not found");
194
+ }
195
+ if (pollResponse.ok) {
196
+ const data = pollResponse.data ?? {};
197
+ const status = data.status;
198
+ threadId = data.threadId ?? threadId;
199
+ if (status === "failed") {
200
+ throw new TfaRcaTurnError(data.error || "TFA agent run failed");
201
+ }
202
+ if (status === "completed") {
203
+ const turn = readStructuredTurn(data);
204
+ await notify(context, "TFA agent turn complete.", 100);
205
+ return toTrimmedResult(turn, threadId);
206
+ }
207
+ // status === "working" (or any other in-progress value) → keep polling.
208
+ }
209
+ // Transient non-2xx (other than 404) during polling: classify and continue.
210
+ if (Date.now() - startTime >= POLL_MAX_WAIT_MS) {
211
+ await notify(context, "TFA agent still working; will resume later.", 90);
212
+ // PENDING keeps only what the skill needs to resume polling.
213
+ return {
214
+ status: PENDING_STATUS,
215
+ threadId,
216
+ turnId,
217
+ };
218
+ }
219
+ await notify(context, "Waiting for TFA agent reply...", 50);
220
+ await delay(POLL_INTERVAL_MS);
221
+ }
222
+ }
@@ -0,0 +1,36 @@
1
+ import { BrowserStackConfig } from "../../lib/types.js";
2
+ export interface TriggerRcaReportArgs {
3
+ buildUuid: string;
4
+ /** Re-run even if a completed report already exists. */
5
+ force?: boolean;
6
+ }
7
+ /**
8
+ * Domain error carrying a client-safe message. The tool maps these to a
9
+ * `{ isError: true }` envelope; the message never contains credentials.
10
+ */
11
+ export declare class TriggerRcaReportError extends Error {
12
+ }
13
+ /**
14
+ * Trimmed glimpse of the Release Readiness report. The raw o11y response —
15
+ * including the `prs[]` and `workflows[]` arrays — is NEVER echoed; the full
16
+ * report lives on the Test Observability dashboard (`viewReport`).
17
+ */
18
+ export interface RcaReportGlimpse {
19
+ state?: string;
20
+ verdict?: string;
21
+ verdictProvisional?: boolean;
22
+ partial?: boolean;
23
+ analyzedCount?: number;
24
+ totalFailedCount?: number;
25
+ totalPrs?: number;
26
+ faultyPrNumbers?: unknown[];
27
+ failureReason?: string;
28
+ /** TRA UI link where the full report lives. */
29
+ viewReport: string;
30
+ }
31
+ /**
32
+ * Trigger (or read, when one already exists) the Release Readiness report for
33
+ * a build via the o11y external API, returning a trimmed glimpse. Stateless:
34
+ * nothing persists between calls.
35
+ */
36
+ export declare function triggerRcaReport(args: TriggerRcaReportArgs, config: BrowserStackConfig): Promise<RcaReportGlimpse>;
@@ -0,0 +1,70 @@
1
+ import { apiClient } from "../../lib/apiClient.js";
2
+ import { getBrowserStackAuth } from "../../lib/get-auth.js";
3
+ import { getO11yBaseUrl, getO11yUiBuildUrl, RELEASE_READINESS_TRIGGER_PATH, } from "./constants.js";
4
+ /**
5
+ * Domain error carrying a client-safe message. The tool maps these to a
6
+ * `{ isError: true }` envelope; the message never contains credentials.
7
+ */
8
+ export class TriggerRcaReportError extends Error {
9
+ }
10
+ /** Pull a machine error code out of a non-2xx body, wherever it rides. */
11
+ function extractErrorCode(data) {
12
+ const candidate = data?.code ?? data?.error ?? data?.errorCode ?? data?.message;
13
+ return typeof candidate === "string" ? candidate : "";
14
+ }
15
+ /** Map a trigger (POST) non-2xx into a clean, group-scope-safe domain error. */
16
+ function mapTriggerError(status, data) {
17
+ const code = extractErrorCode(data);
18
+ if (code.includes("REPO_NOT_CONFIGURED")) {
19
+ return new TriggerRcaReportError("repository not configured for Release Readiness; connect the repo in Test Observability settings");
20
+ }
21
+ if (code.includes("RELEASE_READINESS_NOT_FOUND")) {
22
+ return new TriggerRcaReportError("no Release Readiness report found for this build");
23
+ }
24
+ if (status === 403) {
25
+ return new TriggerRcaReportError("Release Readiness AI is not enabled for this group (plan or feature flag)");
26
+ }
27
+ if (status === 404) {
28
+ return new TriggerRcaReportError("build not found for your group");
29
+ }
30
+ return new TriggerRcaReportError(`failed to trigger Release Readiness report (status ${status})`);
31
+ }
32
+ /**
33
+ * Trigger (or read, when one already exists) the Release Readiness report for
34
+ * a build via the o11y external API, returning a trimmed glimpse. Stateless:
35
+ * nothing persists between calls.
36
+ */
37
+ export async function triggerRcaReport(args, config) {
38
+ const authString = getBrowserStackAuth(config);
39
+ const headers = {
40
+ "Content-Type": "application/json",
41
+ Authorization: `Basic ${Buffer.from(authString).toString("base64")}`,
42
+ };
43
+ const url = getO11yBaseUrl() +
44
+ RELEASE_READINESS_TRIGGER_PATH.replace("{buildUuid}", encodeURIComponent(args.buildUuid)) +
45
+ `?force=${args.force === true}`;
46
+ const response = await apiClient.post({
47
+ url,
48
+ headers,
49
+ body: {},
50
+ raise_error: false,
51
+ });
52
+ if (!response.ok) {
53
+ throw mapTriggerError(response.status, response.data);
54
+ }
55
+ const data = response.data ?? {};
56
+ const summary = data.summary ?? {};
57
+ // Trimmed glimpse only — never the raw response, never prs[]/workflows[].
58
+ return {
59
+ state: summary.state ?? data.state,
60
+ verdict: summary.verdict,
61
+ verdictProvisional: summary.verdictProvisional,
62
+ partial: summary.partial,
63
+ analyzedCount: summary.analyzedCount,
64
+ totalFailedCount: summary.totalFailedCount,
65
+ totalPrs: summary.totalPrs,
66
+ faultyPrNumbers: summary.faultyPrNumbers,
67
+ failureReason: summary.failureReason,
68
+ viewReport: getO11yUiBuildUrl(args.buildUuid),
69
+ };
70
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Collaboration status emitted by the TFA agent for one RCA turn.
3
+ * - NEEDS_INFO: TFA needs more digested input; the skill should execute the
4
+ * returned asks/questions and submit another turn on the same thread.
5
+ * - RESOLVED: an agreed root cause was reached (carries `rca`); the loop stops.
6
+ * - BLOCKED: TFA cannot proceed; the loop stops with a best-effort result.
7
+ */
8
+ export declare enum TfaStatus {
9
+ NEEDS_INFO = "NEEDS_INFO",
10
+ RESOLVED = "RESOLVED",
11
+ BLOCKED = "BLOCKED"
12
+ }
13
+ /**
14
+ * Soft, tool-emitted status used when an in-call poll exceeds its wall-clock
15
+ * cap. Not produced by the agent — produced by the turn util so the skill can
16
+ * resume polling on a later turn via the optional `turnId` arg.
17
+ */
18
+ export declare const PENDING_STATUS: "PENDING";
19
+ /**
20
+ * Confidence the agent attaches to a turn. Mirrors the misc-services
21
+ * `TurnResponse.confidence` Literal; PENDING turns carry `unknown`.
22
+ */
23
+ export type Confidence = "low" | "medium" | "high" | "unknown";
24
+ /**
25
+ * Evidence category the skill routes an ask to. Mirrors the misc-services
26
+ * `Ask.evidence_type` Literal.
27
+ */
28
+ export type EvidenceType = "test_logs" | "product_code" | "k8s" | "kibana" | "metrics" | "deploy" | "ci" | "other";
29
+ /** A typed request for evidence; the skill routes it by `evidenceType`. */
30
+ export interface TfaAsk {
31
+ what: string;
32
+ why: string;
33
+ evidenceType: EvidenceType;
34
+ priority: "high" | "medium" | "low";
35
+ }
36
+ /**
37
+ * The agreed root-cause analysis carried on a RESOLVED turn. Mirrors the
38
+ * misc-services `TestFailureAnalysis` schema; passed through verbatim from the
39
+ * o11y `rcaChat` response and never reshaped client-side.
40
+ */
41
+ export interface TfaRca {
42
+ root_cause?: string;
43
+ description?: string;
44
+ possible_fix?: string;
45
+ failure_type?: string;
46
+ alternatives_considered?: string[];
47
+ related_prs?: unknown[];
48
+ [key: string]: unknown;
49
+ }
50
+ /**
51
+ * Structured turn the o11y `rcaChat` poll returns once `status === "completed"`.
52
+ * Mirrors the misc-services `TurnResponse`; sub-objects are status-discriminated
53
+ * (the agent's model validator guarantees the matching one is present).
54
+ */
55
+ export interface TurnResponse {
56
+ status: TfaStatus;
57
+ confidence: Confidence;
58
+ questions: string[];
59
+ asks: TfaAsk[];
60
+ suggestions: string[];
61
+ hypotheses: string[];
62
+ rca?: TfaRca;
63
+ /** Present on BLOCKED turns: why TFA cannot proceed. */
64
+ reason?: string;
65
+ /** Present on BLOCKED turns: the asks that went unmet. */
66
+ unmetAsks?: string[];
67
+ }
68
+ /**
69
+ * Trimmed glimpse of a RESOLVED turn's RCA. The full `TfaRca` payload
70
+ * (analysis, log_evidence, alternatives, ...) is intentionally dropped — the
71
+ * complete report lives on the Test Observability dashboard (`viewRca`).
72
+ */
73
+ export interface TfaRcaGlimpse {
74
+ /** Truncated to `RCA_GLIMPSE_ROOT_CAUSE_MAX` chars. */
75
+ root_cause?: string;
76
+ failure_type?: string;
77
+ related_prs?: unknown[];
78
+ }
79
+ /**
80
+ * Trimmed, status-discriminated result returned by the turn util / tool. The
81
+ * raw o11y envelope, `meta` blob, and full RCA payload are never echoed:
82
+ * - NEEDS_INFO carries questions/asks/suggestions/hypotheses VERBATIM (the
83
+ * client loop executes them).
84
+ * - RESOLVED carries only a `glimpse` + a `viewRca` UI pointer.
85
+ * - BLOCKED carries reason/unmetAsks.
86
+ * - PENDING carries only turnId/threadId to resume polling.
87
+ */
88
+ export interface TfaRcaTurnResult {
89
+ status: TfaStatus | typeof PENDING_STATUS;
90
+ confidence?: Confidence;
91
+ threadId?: string;
92
+ /** Present on PENDING turns: resume via the tool's `turnId` arg. */
93
+ turnId?: string;
94
+ questions?: string[];
95
+ asks?: TfaAsk[];
96
+ suggestions?: string[];
97
+ hypotheses?: string[];
98
+ /** Present on RESOLVED turns: trimmed root-cause glimpse. */
99
+ glimpse?: TfaRcaGlimpse;
100
+ /** Present on RESOLVED turns: where the full RCA report lives. */
101
+ viewRca?: string;
102
+ reason?: string;
103
+ unmetAsks?: string[];
104
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Collaboration status emitted by the TFA agent for one RCA turn.
3
+ * - NEEDS_INFO: TFA needs more digested input; the skill should execute the
4
+ * returned asks/questions and submit another turn on the same thread.
5
+ * - RESOLVED: an agreed root cause was reached (carries `rca`); the loop stops.
6
+ * - BLOCKED: TFA cannot proceed; the loop stops with a best-effort result.
7
+ */
8
+ export var TfaStatus;
9
+ (function (TfaStatus) {
10
+ TfaStatus["NEEDS_INFO"] = "NEEDS_INFO";
11
+ TfaStatus["RESOLVED"] = "RESOLVED";
12
+ TfaStatus["BLOCKED"] = "BLOCKED";
13
+ })(TfaStatus || (TfaStatus = {}));
14
+ /**
15
+ * Soft, tool-emitted status used when an in-call poll exceeds its wall-clock
16
+ * cap. Not produced by the agent — produced by the turn util so the skill can
17
+ * resume polling on a later turn via the optional `turnId` arg.
18
+ */
19
+ export const PENDING_STATUS = "PENDING";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@browserstack/mcp-server",
3
- "version": "1.2.25-beta.1",
3
+ "version": "1.2.27-beta.1",
4
4
  "description": "BrowserStack's Official MCP Server",
5
5
  "mcpName": "io.github.browserstack/mcp-server",
6
6
  "main": "dist/index.js",