@browserstack/mcp-server 1.3.0 → 1.3.1-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/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,12 @@ for (const key of BROWSERSTACK_LOCAL_OPTION_KEYS) {
28
28
  browserstackLocalOptions[key] = envVar;
29
29
  }
30
30
  }
31
+ // Overridable via O11Y_TFA_RCA_BASE_URL to target a staging tenant.
32
+ const DEFAULT_O11Y_TFA_RCA_BASE_URL = "https://api-automation.browserstack.com";
33
+ // Overridable via BROWSERSTACK_AUTOMATION_BASE_URL to target a non-prod env.
34
+ const DEFAULT_BROWSERSTACK_AUTOMATION_BASE_URL = "https://api-automation.browserstack.com";
35
+ // Overridable via BROWSERSTACK_O11Y_UI_BASE_URL to point at a staging UI.
36
+ const DEFAULT_BROWSERSTACK_O11Y_UI_BASE_URL = "https://automation.browserstack.com";
31
37
  /**
32
38
  * USE_OWN_LOCAL_BINARY_PROCESS:
33
39
  * If true, the system will not start a new local binary process, but will use the user's own process.
@@ -38,15 +44,30 @@ export class Config {
38
44
  USE_OWN_LOCAL_BINARY_PROCESS;
39
45
  REMOTE_MCP;
40
46
  UPLOAD_BASE_DIR;
41
- constructor(DEV_MODE, browserstackLocalOptions, USE_OWN_LOCAL_BINARY_PROCESS, REMOTE_MCP, UPLOAD_BASE_DIR) {
47
+ O11Y_TFA_RCA_BASE_URL;
48
+ BROWSERSTACK_AUTOMATION_BASE_URL;
49
+ BROWSERSTACK_O11Y_UI_BASE_URL;
50
+ 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
51
  this.DEV_MODE = DEV_MODE;
43
52
  this.browserstackLocalOptions = browserstackLocalOptions;
44
53
  this.USE_OWN_LOCAL_BINARY_PROCESS = USE_OWN_LOCAL_BINARY_PROCESS;
45
54
  this.REMOTE_MCP = REMOTE_MCP;
46
55
  this.UPLOAD_BASE_DIR = UPLOAD_BASE_DIR;
56
+ this.O11Y_TFA_RCA_BASE_URL = O11Y_TFA_RCA_BASE_URL;
57
+ this.BROWSERSTACK_AUTOMATION_BASE_URL = BROWSERSTACK_AUTOMATION_BASE_URL;
58
+ this.BROWSERSTACK_O11Y_UI_BASE_URL = BROWSERSTACK_O11Y_UI_BASE_URL;
47
59
  }
48
60
  }
49
61
  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
62
  ? process.env.MCP_UPLOAD_BASE_DIR
51
- : undefined);
63
+ : undefined, process.env.O11Y_TFA_RCA_BASE_URL &&
64
+ process.env.O11Y_TFA_RCA_BASE_URL.length > 0
65
+ ? process.env.O11Y_TFA_RCA_BASE_URL
66
+ : DEFAULT_O11Y_TFA_RCA_BASE_URL, process.env.BROWSERSTACK_AUTOMATION_BASE_URL &&
67
+ process.env.BROWSERSTACK_AUTOMATION_BASE_URL.length > 0
68
+ ? process.env.BROWSERSTACK_AUTOMATION_BASE_URL
69
+ : DEFAULT_BROWSERSTACK_AUTOMATION_BASE_URL, process.env.BROWSERSTACK_O11Y_UI_BASE_URL &&
70
+ process.env.BROWSERSTACK_O11Y_UI_BASE_URL.length > 0
71
+ ? process.env.BROWSERSTACK_O11Y_UI_BASE_URL
72
+ : DEFAULT_BROWSERSTACK_O11Y_UI_BASE_URL);
52
73
  export default config;
package/dist/index.d.ts CHANGED
@@ -3,4 +3,5 @@ import "dotenv/config";
3
3
  export { setLogger } from "./logger.js";
4
4
  export { BrowserStackMcpServer } from "./server-factory.js";
5
5
  export { trackMCP } from "./lib/instrumentation.js";
6
+ export { default as addTfaRcaCollaborationTools } from "./tools/tfa-rca-collaboration.js";
6
7
  export declare const PackageJsonVersion: any;
package/dist/index.js CHANGED
@@ -36,4 +36,5 @@ process.on("exit", () => {
36
36
  export { setLogger } from "./logger.js";
37
37
  export { BrowserStackMcpServer } from "./server-factory.js";
38
38
  export { trackMCP } from "./lib/instrumentation.js";
39
+ export { default as addTfaRcaCollaborationTools } from "./tools/tfa-rca-collaboration.js";
39
40
  export const PackageJsonVersion = packageJson.version;
@@ -7,9 +7,13 @@ export async function fetchBuildInsightsTool(args, config) {
7
7
  try {
8
8
  const buildUrl = `https://api-automation.browserstack.com/ext/v1/builds/${args.buildId}`;
9
9
  const qualityGateUrl = `https://api-automation.browserstack.com/ext/v1/quality-gates/${args.buildId}`;
10
+ // Quality gate data is optional — a failure there should not block build insights
10
11
  const [buildData, qualityData] = await Promise.all([
11
12
  fetchFromBrowserStackAPI(buildUrl, config),
12
- fetchFromBrowserStackAPI(qualityGateUrl, config),
13
+ fetchFromBrowserStackAPI(qualityGateUrl, config).catch((error) => {
14
+ logger.warn("Failed to fetch quality gate data", error);
15
+ return null;
16
+ }),
13
17
  ]);
14
18
  // Select useful fields for users
15
19
  const insights = {
@@ -25,9 +29,12 @@ export async function fetchBuildInsightsTool(args, config) {
25
29
  unique_errors: buildData.unique_errors?.overview,
26
30
  observability_url: buildData?.observability_url,
27
31
  ci_build_url: buildData.ci_info?.build_url,
28
- quality_gate_result: qualityData.quality_gate_result,
32
+ branch: buildData.vcs_info?.branch,
33
+ commit_sha: buildData.vcs_info?.sha,
34
+ vcs_name: buildData.vcs_info?.name,
35
+ quality_gate_result: qualityData?.quality_gate_result,
29
36
  };
30
- const qualityProfiles = qualityData.quality_profiles?.map((profile) => ({
37
+ const qualityProfiles = qualityData?.quality_profiles?.map((profile) => ({
31
38
  name: profile.name,
32
39
  result: profile.result,
33
40
  }));
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { TestStatus } from "./types.js";
3
+ export declare function getAutomationBaseUrl(): string;
3
4
  export declare const FETCH_RCA_PARAMS: {
4
5
  testId: z.ZodArray<z.ZodNumber>;
5
6
  };
@@ -10,4 +11,5 @@ export declare const GET_BUILD_ID_PARAMS: {
10
11
  export declare const LIST_TEST_IDS_PARAMS: {
11
12
  buildId: z.ZodString;
12
13
  status: z.ZodEnum<typeof TestStatus>;
14
+ includeFailureDetail: z.ZodOptional<z.ZodBoolean>;
13
15
  };
@@ -1,5 +1,9 @@
1
1
  import { z } from "zod";
2
+ import appConfig from "../../config.js";
2
3
  import { TestStatus } from "./types.js";
4
+ export function getAutomationBaseUrl() {
5
+ return appConfig.BROWSERSTACK_AUTOMATION_BASE_URL;
6
+ }
3
7
  export const FETCH_RCA_PARAMS = {
4
8
  testId: z
5
9
  .array(z.number().int())
@@ -21,4 +25,8 @@ export const LIST_TEST_IDS_PARAMS = {
21
25
  status: z
22
26
  .nativeEnum(TestStatus)
23
27
  .describe("Filter tests by status. If not provided, all tests are returned. Example for RCA usecase always use failed status"),
28
+ includeFailureDetail: z
29
+ .boolean()
30
+ .optional()
31
+ .describe("Add per-test failure signature for clustering. Default false."),
24
32
  };
@@ -1,3 +1,4 @@
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 extractTestIds(hierarchy: TestDetails[], status?: TestStatus, includeFailureDetail?: boolean): FailedTestInfo[];
4
+ export declare const extractFailedTestIds: typeof extractTestIds;
@@ -1,8 +1,15 @@
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
+ import { TestStatus, } from "./types.js";
4
+ // Keeps the response payload lean — never full stack traces.
5
+ const ERROR_SUMMARY_MAX = 200;
6
+ // Safety bound on pagination; hitting it is logged, never silent.
7
+ const MAX_PAGES = 100;
8
+ export async function getTestIds(buildId, authString, status, includeFailureDetail = false) {
9
+ // No `status` → returns ALL tests; a `status` narrows the query.
10
+ const baseUrl = `${getAutomationBaseUrl()}/ext/v1/builds/${buildId}/testRuns`;
4
11
  let url = status ? `${baseUrl}?test_statuses=${status}` : baseUrl;
5
- let allFailedTests = [];
12
+ let allTests = [];
6
13
  let requestNumber = 0;
7
14
  // Construct Basic auth header
8
15
  const encodedCredentials = Buffer.from(authString).toString("base64");
@@ -20,15 +27,16 @@ export async function getTestIds(buildId, authString, status) {
20
27
  throw new Error(`Failed to fetch test runs: ${response.status} ${response.statusText}`);
21
28
  }
22
29
  const data = (await response.json());
23
- // Extract failed IDs from current page
30
+ // Extract test IDs from the current page (all tests unless narrowed).
24
31
  if (data.hierarchy && data.hierarchy.length > 0) {
25
- const currentFailedTests = extractFailedTestIds(data.hierarchy, status);
26
- allFailedTests = allFailedTests.concat(currentFailedTests);
32
+ const currentTests = extractTestIds(data.hierarchy, status, includeFailureDetail);
33
+ allTests = allTests.concat(currentTests);
27
34
  }
28
- // Check for pagination termination conditions
29
- if (!data.pagination?.has_next ||
30
- !data.pagination.next_page ||
31
- requestNumber >= 5) {
35
+ if (!data.pagination?.has_next || !data.pagination.next_page) {
36
+ break;
37
+ }
38
+ if (requestNumber >= MAX_PAGES) {
39
+ logger.warn(`listTestIds: hit MAX_PAGES (${MAX_PAGES}) for build ${buildId}; result may be partial`);
32
40
  break;
33
41
  }
34
42
  const params = {
@@ -38,31 +46,92 @@ export async function getTestIds(buildId, authString, status) {
38
46
  params.test_statuses = status;
39
47
  url = `${baseUrl}?${new URLSearchParams(params).toString()}`;
40
48
  }
41
- // Return unique failed test IDs
42
- return allFailedTests;
49
+ return allTests;
43
50
  }
44
51
  catch (error) {
45
- logger.error("Error fetching failed tests:", error);
52
+ logger.error("Error fetching test runs:", error);
46
53
  throw error;
47
54
  }
48
55
  }
49
- export function extractFailedTestIds(hierarchy, status) {
50
- let failedTests = [];
56
+ export function extractTestIds(hierarchy, status, includeFailureDetail = false) {
57
+ let tests = [];
51
58
  for (const node of hierarchy) {
52
- if (node.details?.status === status) {
53
- if (node.details?.observability_url) {
54
- const idMatch = node.details.observability_url.match(/details=(\d+)/);
55
- if (idMatch) {
56
- failedTests.push({
57
- test_id: idMatch[1],
58
- test_name: node.display_name || `Test ${idMatch[1]}`,
59
- });
59
+ // observability_url `details=<id>` filters out suite/hook nodes. run_count
60
+ // is not required — JUnit-uploaded builds report run_count=0 for real tests too.
61
+ const nodeStatus = node.details?.status;
62
+ const statusMatches = status === undefined || nodeStatus === status;
63
+ if (statusMatches && node.details?.observability_url) {
64
+ const idMatch = node.details.observability_url.match(/details=(\d+)/);
65
+ if (idMatch) {
66
+ const entry = {
67
+ test_id: idMatch[1],
68
+ test_name: node.display_name || `Test ${idMatch[1]}`,
69
+ status: nodeStatus,
70
+ };
71
+ // Failure signatures only exist for failed tests; include when asked.
72
+ if (includeFailureDetail && nodeStatus === TestStatus.FAILED) {
73
+ const signature = buildFailureSignature(node.details);
74
+ if (signature)
75
+ entry.failure = signature;
60
76
  }
77
+ tests.push(entry);
61
78
  }
62
79
  }
63
80
  if (node.children && node.children.length > 0) {
64
- failedTests = failedTests.concat(extractFailedTestIds(node.children, status));
81
+ tests = tests.concat(extractTestIds(node.children, status, includeFailureDetail));
82
+ }
83
+ }
84
+ return tests;
85
+ }
86
+ // Back-compat alias — prefer extractTestIds. Kept so existing imports/tests
87
+ // referencing the old name continue to resolve.
88
+ export const extractFailedTestIds = extractTestIds;
89
+ // Build a trimmed failure signature from a test node's `details`. Returns
90
+ // undefined when no signal is available so the field is simply omitted.
91
+ function buildFailureSignature(details) {
92
+ if (!details)
93
+ return undefined;
94
+ const signature = {};
95
+ if (details.failure_categories != null) {
96
+ signature.category = Array.isArray(details.failure_categories)
97
+ ? details.failure_categories.filter(Boolean).join(", ")
98
+ : String(details.failure_categories);
99
+ }
100
+ const errorSummary = extractFirstFailureLine(details);
101
+ if (errorSummary)
102
+ signature.error_summary = errorSummary;
103
+ if (details.file_path)
104
+ signature.file_path = String(details.file_path);
105
+ if (typeof details.is_flaky === "boolean")
106
+ signature.is_flaky = details.is_flaky;
107
+ if (typeof details.is_always_failing === "boolean")
108
+ signature.is_always_failing = details.is_always_failing;
109
+ if (typeof details.is_new_failure === "boolean")
110
+ signature.is_new_failure = details.is_new_failure;
111
+ return Object.keys(signature).length > 0 ? signature : undefined;
112
+ }
113
+ // First non-empty line of the first retry's TEST_FAILURE log, capped. Handles
114
+ // both string entries and object entries ({ message } / { text }).
115
+ function extractFirstFailureLine(details) {
116
+ const retries = details?.retries;
117
+ if (!Array.isArray(retries))
118
+ return undefined;
119
+ for (const retry of retries) {
120
+ const failures = retry?.logs?.TEST_FAILURE;
121
+ if (!failures)
122
+ continue;
123
+ const entries = Array.isArray(failures) ? failures : [failures];
124
+ for (const failure of entries) {
125
+ const text = typeof failure === "string"
126
+ ? failure
127
+ : (failure?.message ?? failure?.text ?? "");
128
+ const firstLine = String(text)
129
+ .split("\n")
130
+ .map((line) => line.trim())
131
+ .find((line) => line.length > 0);
132
+ if (firstLine)
133
+ return firstLine.slice(0, ERROR_SUMMARY_MAX);
65
134
  }
66
135
  }
67
- return failedTests;
136
+ return undefined;
68
137
  }
@@ -17,9 +17,19 @@ 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
+ status?: TestStatus;
32
+ failure?: TestFailureSignature;
23
33
  }
24
34
  export declare enum RCAState {
25
35
  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
  {
@@ -175,7 +175,7 @@ export default function addRCATools(server, config) {
175
175
  return handleMCPError("listBuildId", server, config, error);
176
176
  }
177
177
  });
178
- tools.listTestIds = server.tool("listTestIds", "List test IDs from a BrowserStack Automate build, optionally filtered by status", LIST_TEST_IDS_PARAMS, {
178
+ tools.listTestIds = server.tool("listTestIds", "List all tests of a BrowserStack build (each with its status); optional status filter.", LIST_TEST_IDS_PARAMS, {
179
179
  title: "List Test IDs",
180
180
  readOnlyHint: true,
181
181
  openWorldHint: false,
@@ -0,0 +1,15 @@
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 { GetTfaTurnResultArgs } from "./tfa-rca-utils/turn-result.js";
6
+ import { TriggerRcaReportArgs } from "./tfa-rca-utils/trigger-report.js";
7
+ import { ListTestsInFailureThemeArgs } from "./tfa-rca-utils/build-failure-themes.js";
8
+ export declare function tfaRcaTurnTool(args: TfaRcaTurnArgs, config: BrowserStackConfig, context?: any): Promise<CallToolResult>;
9
+ export declare function getTfaTurnResultTool(args: GetTfaTurnResultArgs, config: BrowserStackConfig): Promise<CallToolResult>;
10
+ export declare function triggerRcaReportTool(args: TriggerRcaReportArgs, config: BrowserStackConfig): Promise<CallToolResult>;
11
+ export declare function getBuildFailureThemesTool(args: {
12
+ buildUuid: string;
13
+ }, config: BrowserStackConfig): Promise<CallToolResult>;
14
+ export declare function listTestsInFailureThemeTool(args: ListTestsInFailureThemeArgs, config: BrowserStackConfig): Promise<CallToolResult>;
15
+ export default function addTfaRcaCollaborationTools(server: McpServer, config: BrowserStackConfig): Record<string, any>;
@@ -0,0 +1,162 @@
1
+ import { trackMCP } from "../lib/instrumentation.js";
2
+ import { handleMCPError } from "../lib/utils.js";
3
+ import { GET_BUILD_FAILURE_THEMES_PARAMS, GET_TFA_TURN_RESULT_PARAMS, LIST_TESTS_IN_FAILURE_THEME_PARAMS, TFA_RCA_TURN_PARAMS, TRIGGER_RCA_REPORT_PARAMS, } from "./tfa-rca-utils/constants.js";
4
+ import { submitTfaRcaTurn, } from "./tfa-rca-utils/submit-turn.js";
5
+ import { getTfaTurnResult, TfaRcaTurnError, } from "./tfa-rca-utils/turn-result.js";
6
+ import { triggerRcaReport, TriggerRcaReportError, } from "./tfa-rca-utils/trigger-report.js";
7
+ import { BuildFailureThemesError, fetchBuildFailureThemes, fetchTestsInFailureTheme, } from "./tfa-rca-utils/build-failure-themes.js";
8
+ const TOOL_NAME = "tfaRcaTurn";
9
+ const GET_RESULT_TOOL_NAME = "getTfaTurnResult";
10
+ const TRIGGER_TOOL_NAME = "triggerRcaReport";
11
+ const GET_BUILD_FAILURE_THEMES_TOOL_NAME = "getBuildFailureThemes";
12
+ const LIST_TESTS_IN_FAILURE_THEME_TOOL_NAME = "listTestsInFailureTheme";
13
+ /** Wrap a domain error into the standard `{ isError: true }` envelope. */
14
+ function domainErrorResult(toolName, error) {
15
+ const readable = toolName.replace(/([A-Z])/g, " $1").toLowerCase();
16
+ return {
17
+ content: [
18
+ {
19
+ type: "text",
20
+ text: `Failed to ${readable}: ${error.message}`,
21
+ },
22
+ ],
23
+ isError: true,
24
+ };
25
+ }
26
+ export async function tfaRcaTurnTool(args, config, context) {
27
+ // The util returns the trimmed, status-discriminated contract; JSON.stringify
28
+ // drops the undefined slots, so the wrapper stays a plain serializer.
29
+ const result = await submitTfaRcaTurn(args, config, context);
30
+ return {
31
+ content: [
32
+ {
33
+ type: "text",
34
+ text: JSON.stringify(result, null, 2),
35
+ },
36
+ ],
37
+ };
38
+ }
39
+ export async function getTfaTurnResultTool(args, config) {
40
+ // Same trimmed contract as `tfaRcaTurn`, read once without submitting.
41
+ const result = await getTfaTurnResult(args, config);
42
+ return {
43
+ content: [
44
+ {
45
+ type: "text",
46
+ text: JSON.stringify(result, null, 2),
47
+ },
48
+ ],
49
+ };
50
+ }
51
+ export async function triggerRcaReportTool(args, config) {
52
+ const glimpse = await triggerRcaReport(args, config);
53
+ return {
54
+ content: [
55
+ {
56
+ type: "text",
57
+ text: JSON.stringify(glimpse, null, 2),
58
+ },
59
+ ],
60
+ };
61
+ }
62
+ export async function getBuildFailureThemesTool(args, config) {
63
+ const result = await fetchBuildFailureThemes(args.buildUuid, config);
64
+ return {
65
+ content: [
66
+ {
67
+ type: "text",
68
+ text: JSON.stringify(result, null, 2),
69
+ },
70
+ ],
71
+ };
72
+ }
73
+ export async function listTestsInFailureThemeTool(args, config) {
74
+ const result = await fetchTestsInFailureTheme(args, config);
75
+ return {
76
+ content: [
77
+ {
78
+ type: "text",
79
+ text: JSON.stringify(result, null, 2),
80
+ },
81
+ ],
82
+ };
83
+ }
84
+ export default function addTfaRcaCollaborationTools(server, config) {
85
+ const tools = {};
86
+ tools.tfaRcaTurn = server.tool(TOOL_NAME, "Submit one collaborative RCA turn for a test run to the TFA agent, then poll in-call for a result. Omit threadId on the first turn for a test run — this starts a new investigation thread; every following turn on that same test MUST pass back the threadId from the previous response, since a test run should have only one active thread at a time. Returns status RESOLVED (terminal, includes root_cause/related_prs), NEEDS_INFO (fulfill the asks and submit the next turn with the same threadId), or PENDING (still working — poll it with getTfaTurnResult using the returned turnId; do NOT call tfaRcaTurn again for the same turn, that submits a duplicate).", TFA_RCA_TURN_PARAMS, async (args, context) => {
87
+ try {
88
+ const result = await tfaRcaTurnTool(args, config, context);
89
+ trackMCP(TOOL_NAME, server.server.getClientVersion(), undefined, config);
90
+ return result;
91
+ }
92
+ catch (error) {
93
+ // Domain failures carry a client-safe, group-scope-safe message.
94
+ if (error instanceof TfaRcaTurnError) {
95
+ trackMCP(TOOL_NAME, server.server.getClientVersion(), error, config);
96
+ return domainErrorResult(TOOL_NAME, error);
97
+ }
98
+ return handleMCPError(TOOL_NAME, server, config, error);
99
+ }
100
+ });
101
+ tools.getTfaTurnResult = server.tool(GET_RESULT_TOOL_NAME, "Read a previously submitted RCA turn's status once, given the testRunId and the turnId a PENDING tfaRcaTurn response returned — this never resubmits or duplicates the turn. Returns PENDING again if the TFA agent is still working, or the same RESOLVED/NEEDS_INFO contract as tfaRcaTurn once it finishes. On NEEDS_INFO, gather the requested evidence and continue via tfaRcaTurn with the same threadId; RESOLVED is terminal for this test — do not call this tool or tfaRcaTurn again for it.", GET_TFA_TURN_RESULT_PARAMS, async (args) => {
102
+ try {
103
+ const result = await getTfaTurnResultTool(args, config);
104
+ trackMCP(GET_RESULT_TOOL_NAME, server.server.getClientVersion(), undefined, config);
105
+ return result;
106
+ }
107
+ catch (error) {
108
+ // Domain failures carry a client-safe, group-scope-safe message.
109
+ if (error instanceof TfaRcaTurnError) {
110
+ trackMCP(GET_RESULT_TOOL_NAME, server.server.getClientVersion(), error, config);
111
+ return domainErrorResult(GET_RESULT_TOOL_NAME, error);
112
+ }
113
+ return handleMCPError(GET_RESULT_TOOL_NAME, server, config, error);
114
+ }
115
+ });
116
+ tools.triggerRcaReport = server.tool(TRIGGER_TOOL_NAME, "Trigger (or read, if one already exists) a build's Release Readiness report, returning a trimmed verdict glimpse and a Test Observability UI link — never the raw report body. Without force, a completed report is read as-is at no extra analysis cost, so it's safe to call this repeatedly just to check status. Pass force:true only to force a fresh re-analysis of an already-completed report (e.g. after new evidence changed the picture) — do NOT set force on every call, since that discards the cached report and always pays the full re-analysis cost.", TRIGGER_RCA_REPORT_PARAMS, async (args) => {
117
+ try {
118
+ const result = await triggerRcaReportTool(args, config);
119
+ trackMCP(TRIGGER_TOOL_NAME, server.server.getClientVersion(), undefined, config);
120
+ return result;
121
+ }
122
+ catch (error) {
123
+ // Domain failures carry a client-safe, group-scope-safe message.
124
+ if (error instanceof TriggerRcaReportError) {
125
+ trackMCP(TRIGGER_TOOL_NAME, server.server.getClientVersion(), error, config);
126
+ return domainErrorResult(TRIGGER_TOOL_NAME, error);
127
+ }
128
+ return handleMCPError(TRIGGER_TOOL_NAME, server, config, error);
129
+ }
130
+ });
131
+ tools.getBuildFailureThemes = server.tool(GET_BUILD_FAILURE_THEMES_TOOL_NAME, "Get a build's server-computed failure-theme clusters (buildThemes + buildWorkflows), the preferred grouping source for representative/sibling fan-out — call this once per build, not per test. Triggers server-side computation if nothing has ever run for this build, and polls in-call up to its own budget for the result to finish; never blocks indefinitely. ready:false means either the computation is still running past the poll budget or the trigger itself failed (status:'trigger-unavailable') — either way, fall back to client-side clustering rather than waiting longer or retrying this call.", GET_BUILD_FAILURE_THEMES_PARAMS, async (args) => {
132
+ try {
133
+ const result = await getBuildFailureThemesTool(args, config);
134
+ trackMCP(GET_BUILD_FAILURE_THEMES_TOOL_NAME, server.server.getClientVersion(), undefined, config);
135
+ return result;
136
+ }
137
+ catch (error) {
138
+ // Domain failures carry a client-safe, group-scope-safe message.
139
+ if (error instanceof BuildFailureThemesError) {
140
+ trackMCP(GET_BUILD_FAILURE_THEMES_TOOL_NAME, server.server.getClientVersion(), error, config);
141
+ return domainErrorResult(GET_BUILD_FAILURE_THEMES_TOOL_NAME, error);
142
+ }
143
+ return handleMCPError(GET_BUILD_FAILURE_THEMES_TOOL_NAME, server, config, error);
144
+ }
145
+ });
146
+ tools.listTestsInFailureTheme = server.tool(LIST_TESTS_IN_FAILURE_THEME_TOOL_NAME, "List the test runs belonging to one failure theme or workflow from a prior getBuildFailureThemes call, filtered by themeId or workflowId. Results are paginated — pass the previous response's nextCursor back as cursor and keep calling until no nextCursor is returned; do not assume a single page covers all members. This is the representative/sibling grouping source: every test in a theme's list becomes either the cluster's representative or a pre-seeded sibling confirm.", LIST_TESTS_IN_FAILURE_THEME_PARAMS, async (args) => {
147
+ try {
148
+ const result = await listTestsInFailureThemeTool(args, config);
149
+ trackMCP(LIST_TESTS_IN_FAILURE_THEME_TOOL_NAME, server.server.getClientVersion(), undefined, config);
150
+ return result;
151
+ }
152
+ catch (error) {
153
+ // Domain failures carry a client-safe, group-scope-safe message.
154
+ if (error instanceof BuildFailureThemesError) {
155
+ trackMCP(LIST_TESTS_IN_FAILURE_THEME_TOOL_NAME, server.server.getClientVersion(), error, config);
156
+ return domainErrorResult(LIST_TESTS_IN_FAILURE_THEME_TOOL_NAME, error);
157
+ }
158
+ return handleMCPError(LIST_TESTS_IN_FAILURE_THEME_TOOL_NAME, server, config, error);
159
+ }
160
+ });
161
+ return tools;
162
+ }
@@ -0,0 +1,61 @@
1
+ import { BrowserStackConfig } from "../../lib/types.js";
2
+ export declare class BuildFailureThemesError extends Error {
3
+ }
4
+ export interface BuildFailureTheme {
5
+ themeId: string;
6
+ buildFailureThemeId: number;
7
+ themeData: {
8
+ name: string;
9
+ description: string;
10
+ };
11
+ affectedWorkflows: unknown[];
12
+ affectedWorkflowCount?: number;
13
+ testRunCount?: number;
14
+ testPercentage?: number;
15
+ }
16
+ export interface BuildFailureWorkflow {
17
+ workflowIdentifier: string;
18
+ buildFailureWorkflowId: number;
19
+ workflowData: {
20
+ name: string;
21
+ description: string;
22
+ };
23
+ testRunCount?: number;
24
+ testPercentage?: number;
25
+ themesIdentified?: unknown[];
26
+ themeIdentifiedCount?: number;
27
+ }
28
+ export interface BuildFailureThemesResult {
29
+ ready: boolean;
30
+ /** Last observed `buildThemeWorkflow.status`, or "PENDING" if budget spent with no status yet. */
31
+ status: string;
32
+ buildId?: string;
33
+ buildThemes?: BuildFailureTheme[];
34
+ buildWorkflows?: BuildFailureWorkflow[];
35
+ stats?: {
36
+ totalThemes: number;
37
+ newThemes: number;
38
+ existingThemes: number;
39
+ };
40
+ }
41
+ export interface TestInFailureTheme {
42
+ testRunId: string | number | undefined;
43
+ title?: string;
44
+ status?: string;
45
+ raw: unknown;
46
+ }
47
+ export interface ListTestsInFailureThemeArgs {
48
+ buildUuid: string;
49
+ themeId?: number;
50
+ workflowId?: number;
51
+ limit?: number;
52
+ cursor?: string;
53
+ }
54
+ export interface ListTestsInFailureThemeResult {
55
+ tests: TestInFailureTheme[];
56
+ /** Cursor for the next page, if any. */
57
+ nextCursor?: string;
58
+ }
59
+ export declare function fetchBuildFailureThemes(buildUuid: string, config: BrowserStackConfig): Promise<BuildFailureThemesResult>;
60
+ /** Paginated test-run membership for one failure theme or workflow. */
61
+ export declare function fetchTestsInFailureTheme(args: ListTestsInFailureThemeArgs, config: BrowserStackConfig): Promise<ListTestsInFailureThemeResult>;