From 8c3a224d90d9493445a30ed57bfd65344c0a1666 Mon Sep 17 00:00:00 2001 From: tech-sushant Date: Fri, 5 Sep 2025 17:17:59 +0530 Subject: [PATCH 01/12] feat: Add RCA tools for fetching and formatting Root Cause Analysis data --- src/server-factory.ts | 2 + src/tools/rca-agent-utils/format-rca.ts | 35 +++ src/tools/rca-agent-utils/get-build-id.ts | 32 ++ .../rca-agent-utils/get-failed-test-id.ts | 104 +++++++ src/tools/rca-agent-utils/rca-data.ts | 281 ++++++++++++++++++ src/tools/rca-agent.ts | 88 ++++++ 6 files changed, 542 insertions(+) create mode 100644 src/tools/rca-agent-utils/format-rca.ts create mode 100644 src/tools/rca-agent-utils/get-build-id.ts create mode 100644 src/tools/rca-agent-utils/get-failed-test-id.ts create mode 100644 src/tools/rca-agent-utils/rca-data.ts create mode 100644 src/tools/rca-agent.ts diff --git a/src/server-factory.ts b/src/server-factory.ts index 82f93730..8381f24b 100644 --- a/src/server-factory.ts +++ b/src/server-factory.ts @@ -17,6 +17,7 @@ import addSelfHealTools from "./tools/selfheal.js"; import addAppLiveTools from "./tools/applive.js"; import { setupOnInitialized } from "./oninitialized.js"; import { BrowserStackConfig } from "./lib/types.js"; +import addRCATools from "./tools/rca-agent.js"; /** * Wrapper class for BrowserStack MCP Server @@ -55,6 +56,7 @@ export class BrowserStackMcpServer { addFailureLogsTools, addAutomateTools, addSelfHealTools, + addRCATools, ]; toolAdders.forEach((adder) => { diff --git a/src/tools/rca-agent-utils/format-rca.ts b/src/tools/rca-agent-utils/format-rca.ts new file mode 100644 index 00000000..d7961040 --- /dev/null +++ b/src/tools/rca-agent-utils/format-rca.ts @@ -0,0 +1,35 @@ +// Utility function to format RCA data for better readability +export function formatRCAData(rcaData: any): string { + if (!rcaData || !rcaData.testCases || rcaData.testCases.length === 0) { + return "No RCA data available."; + } + + let output = "## Root Cause Analysis Report\n\n"; + + rcaData.testCases.forEach((testCase: any, index: number) => { + // Show test case name first with smaller heading + output += `### ${testCase.displayName || `Test Case ${index + 1}`}\n`; + output += `**Test ID:** ${testCase.id}\n`; + output += `**Status:** ${testCase.state}\n\n`; + + if (testCase.rcaData?.originalResponse?.rcaData) { + const rca = testCase.rcaData.originalResponse.rcaData; + + if (rca.root_cause) { + output += `**Root Cause:** ${rca.root_cause}\n\n`; + } + + if (rca.description) { + output += `**Description:**\n${rca.description}\n\n`; + } + + if (rca.possible_fix) { + output += `**Recommended Fix:**\n${rca.possible_fix}\n\n`; + } + } + + output += "---\n\n"; + }); + + return output; +} diff --git a/src/tools/rca-agent-utils/get-build-id.ts b/src/tools/rca-agent-utils/get-build-id.ts new file mode 100644 index 00000000..6f3dffe7 --- /dev/null +++ b/src/tools/rca-agent-utils/get-build-id.ts @@ -0,0 +1,32 @@ +export async function getBuildId( + projectName: string, + buildName: string, + username: string, + accessKey: string, +): Promise { + const url = new URL( + "https://api-automation.browserstack.com/ext/v1/builds/latest", + ); + url.searchParams.append("project_name", projectName); + url.searchParams.append("build_name", buildName); + url.searchParams.append("user_name", username); + + const authHeader = + "Basic " + Buffer.from(`${username}:${accessKey}`).toString("base64"); + + const response = await fetch(url.toString(), { + headers: { + Authorization: authHeader, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch build ID: ${response.status} ${response.statusText}`, + ); + } + + const data = await response.json(); + return data.build_id; +} diff --git a/src/tools/rca-agent-utils/get-failed-test-id.ts b/src/tools/rca-agent-utils/get-failed-test-id.ts new file mode 100644 index 00000000..46b8ddd1 --- /dev/null +++ b/src/tools/rca-agent-utils/get-failed-test-id.ts @@ -0,0 +1,104 @@ +interface TestDetails { + status: string; + details: any; + children?: TestDetails[]; + display_name?: string; +} + +interface TestRun { + hierarchy: TestDetails[]; + pagination?: { + has_next: boolean; + next_page: string | null; + }; +} + +export interface FailedTestInfo { + id: string; + displayName: string; +} + +export async function getFailedTestIds( + buildId: string, + authString: string, +): Promise { + const baseUrl = `https://api-automation.browserstack.com/ext/v1/builds/${buildId}/testRuns?test_statuses=failed`; + let nextUrl = baseUrl; + let allFailedTests: FailedTestInfo[] = []; + let requestNumber = 0; + + // Construct Basic auth header + const encodedCredentials = Buffer.from(authString).toString("base64"); + const authHeader = `Basic ${encodedCredentials}`; + + try { + while (true) { + requestNumber++; + + const response = await fetch(nextUrl, { + headers: { + Authorization: authHeader, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch test runs: ${response.status} ${response.statusText}`, + ); + } + + const data = (await response.json()) as TestRun; + + // Extract failed IDs from current page + if (data.hierarchy && data.hierarchy.length > 0) { + const currentFailedTests = extractFailedTestIds(data.hierarchy); + allFailedTests = allFailedTests.concat(currentFailedTests); + } + + // Check for pagination termination conditions + if (!data.pagination?.has_next || !data.pagination.next_page) { + break; + } + + // Safety limit to prevent runaway requests + if (requestNumber >= 5) { + break; + } + + // Prepare next request + nextUrl = `${baseUrl}?next_page=${encodeURIComponent(data.pagination.next_page)}`; + } + + // Return unique failed test IDs + return allFailedTests; + } catch (error) { + console.error("Error fetching failed tests:", error); + throw error; + } +} + +// Recursive function to extract failed test IDs from hierarchy +function extractFailedTestIds(hierarchy: TestDetails[]): FailedTestInfo[] { + let failedTests: FailedTestInfo[] = []; + + for (const node of hierarchy) { + if (node.details?.status === "failed" && node.details?.run_count) { + if (node.details?.observability_url) { + const idMatch = node.details.observability_url.match(/details=(\d+)/); + if (idMatch) { + failedTests.push({ + id: idMatch[1], + displayName: node.display_name || `Test ${idMatch[1]}` + }); + } + } + } + + if (node.children && node.children.length > 0) { + failedTests = failedTests.concat(extractFailedTestIds(node.children)); + } + } + + return failedTests; +} diff --git a/src/tools/rca-agent-utils/rca-data.ts b/src/tools/rca-agent-utils/rca-data.ts new file mode 100644 index 00000000..95f34afe --- /dev/null +++ b/src/tools/rca-agent-utils/rca-data.ts @@ -0,0 +1,281 @@ +import { FailedTestInfo } from "./get-failed-test-id.js"; + +export enum RCAState { + PENDING = "pending", + COMPLETED = "completed", + FAILED = "failed", +} + +export interface RCATestCase { + id: string; + testRunId: string; + displayName?: string; + state: RCAState; + rcaData?: any; +} + +export interface RCAResponse { + testCases: RCATestCase[]; +} + +interface ScanProgressContext { + sendNotification: (notification: any) => Promise; + _meta?: { + progressToken?: string | number; + }; +} + +// --- Utility functions --- + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +function calculateProgress( + resolvedCount: number, + totalCount: number, + baseProgress: number = 10, +): number { + const progressRange = 90 - baseProgress; + const completionProgress = (resolvedCount / totalCount) * progressRange; + return Math.min(100, baseProgress + completionProgress); +} + +async function notifyProgress( + context: ScanProgressContext | undefined, + message: string, + progress: number, +) { + if (!context?.sendNotification) return; + + await context.sendNotification({ + method: "notifications/progress", + params: { + progressToken: context._meta?.progressToken?.toString(), + message, + progress, + total: 100, + }, + }); +} + +// Helper to send progress based on current test cases +async function updateProgress( + context: ScanProgressContext | undefined, + testCases: RCATestCase[], + message?: string, +) { + const pending = testCases.filter((tc) => tc.state === RCAState.PENDING); + const resolvedCount = testCases.length - pending.length; + await notifyProgress( + context, + message ?? + (pending.length === 0 + ? "RCA analysis completed for all test cases" + : `RCA analysis in progress (${resolvedCount}/${testCases.length} resolved)`), + pending.length === 0 + ? 100 + : calculateProgress(resolvedCount, testCases.length), + ); +} + +// --- Fetch initial RCA for a test case --- +async function fetchInitialRCA( + testInfo: FailedTestInfo, + headers: Record, + baseUrl: string, +): Promise { + const url = baseUrl.replace("{testId}", testInfo.id); + + try { + const response = await fetch(url, { headers }); + if (!response.ok) { + return { + id: testInfo.id, + testRunId: testInfo.id, + displayName: testInfo.displayName, + state: RCAState.FAILED, + rcaData: { + error: `HTTP ${response.status}: Failed to start RCA analysis`, + }, + }; + } + + const data = await response.json(); + if (data.state && !["pending", "completed"].includes(data.state)) { + return { + id: data.id ?? testInfo.id, + testRunId: data.testRunId ?? testInfo.id, + displayName: testInfo.displayName, + state: RCAState.FAILED, + rcaData: { + error: `API returned error state: ${data.state}`, + originalResponse: data, + }, + }; + } + + return { + id: data.id ?? testInfo.id, + testRunId: data.testRunId ?? testInfo.id, + displayName: testInfo.displayName, + state: RCAState.PENDING, + }; + } catch (error) { + return { + id: testInfo.id, + testRunId: testInfo.id, + displayName: testInfo.displayName, + state: RCAState.FAILED, + rcaData: { + error: + error instanceof Error ? error.message : "Network or parsing error", + }, + }; + } +} + +// --- Poll all test cases until completion or timeout --- +async function pollRCAResults( + testCases: RCATestCase[], + headers: Record, + baseUrl: string, + context: ScanProgressContext | undefined, + pollInterval: number, + timeout: number, + initialDelay: number, +): Promise { + const startTime = Date.now(); + + await delay(initialDelay); + + try { + while (true) { + const pendingCases = testCases.filter( + (tc) => tc.state === RCAState.PENDING, + ); + await updateProgress(context, testCases); + + if (pendingCases.length === 0) break; + + if (Date.now() - startTime >= timeout) { + pendingCases.forEach((tc) => { + tc.state = RCAState.FAILED; + tc.rcaData = { error: `Timeout after ${timeout}ms` }; + }); + await updateProgress(context, testCases, "RCA analysis timed out"); + break; + } + + // Poll all pending cases in parallel + await Promise.allSettled( + pendingCases.map(async (tc) => { + try { + const response = await fetch(baseUrl.replace("{testId}", tc.id), { + headers, + }); + if (!response.ok) { + tc.state = RCAState.FAILED; + tc.rcaData = { error: `HTTP ${response.status}: Polling failed` }; + return; + } + const data = await response.json(); + if (tc.state === RCAState.PENDING) { + if (data.state === "completed") { + tc.state = RCAState.COMPLETED; + tc.rcaData = data; + } else if (data.state && data.state !== "pending") { + tc.state = RCAState.FAILED; + tc.rcaData = { + error: `API returned error state: ${data.state}`, + originalResponse: data, + }; + } + } + } catch (err) { + if (tc.state === RCAState.PENDING) { + tc.state = RCAState.FAILED; + tc.rcaData = { + error: + err instanceof Error + ? err.message + : "Network or parsing error", + }; + } + } + }), + ); + + await delay(pollInterval); + } + } catch (err) { + // Fallback in case of unexpected error + testCases + .filter((tc) => tc.state === RCAState.PENDING) + .forEach((tc) => { + tc.state = RCAState.FAILED; + tc.rcaData = { + error: err instanceof Error ? err.message : "Unexpected error", + }; + }); + await updateProgress( + context, + testCases, + "RCA analysis failed due to unexpected error", + ); + } + + return { testCases }; +} + +// --- Public API function --- +export async function getRCAData( + testInfos: FailedTestInfo[], + authString: string, + context?: ScanProgressContext, +): Promise { + const pollInterval = 5000; + const timeout = 30000; + const initialDelay = 20000; + + const baseUrl = + "https://api-observability.browserstack.com/ext/v1/testRun/{testId}/testRca"; + const headers = { + Authorization: `Basic ${Buffer.from(authString).toString("base64")}`, + "Content-Type": "application/json", + }; + + await notifyProgress(context, "Starting RCA analysis for test cases...", 0); + + // Step 1: Fire initial RCA requests in parallel + const testCases = await Promise.all( + testInfos.map((testInfo) => fetchInitialRCA(testInfo, headers, baseUrl)), + ); + + const pendingCount = testCases.filter( + (tc) => tc.state === RCAState.PENDING, + ).length; + await notifyProgress( + context, + `Initial RCA requests completed. ${pendingCount} cases pending analysis...`, + 10, + ); + + if (pendingCount === 0) { + await notifyProgress( + context, + "RCA analysis completed for all test cases", + 100, + ); + return { testCases }; + } + + // Step 2: Poll pending test cases + return pollRCAResults( + testCases, + headers, + baseUrl, + context, + pollInterval, + timeout, + initialDelay, + ); +} diff --git a/src/tools/rca-agent.ts b/src/tools/rca-agent.ts new file mode 100644 index 00000000..3ed80c75 --- /dev/null +++ b/src/tools/rca-agent.ts @@ -0,0 +1,88 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import logger from "../logger.js"; +import { BrowserStackConfig } from "../lib/types.js"; +import { getBrowserStackAuth } from "../lib/get-auth.js"; +import { getBuildId } from "./rca-agent-utils/get-build-id.js"; +import { getFailedTestIds } from "./rca-agent-utils/get-failed-test-id.js"; +import { getRCAData } from "./rca-agent-utils/rca-data.js"; +import { formatRCAData } from "./rca-agent-utils/format-rca.js"; + +// wTool function that fetches RCA data +export async function fetchRCADataTool( + args: { projectName: string; buildName: string; buildId?: string }, + config: BrowserStackConfig, +): Promise { + try { + let { projectName, buildName, buildId } = args; + const authString = getBrowserStackAuth(config); + const [username, accessKey] = authString.split(":"); + buildId = buildId || await getBuildId(projectName, buildName, username, accessKey); + const testInfos = await getFailedTestIds(buildId, authString); + const rcaData = await getRCAData(testInfos.slice(0, 3), authString); + + const formattedData = formatRCAData(rcaData); + + return { + content: [ + { + type: "text", + text: formattedData, + }, + ], + }; + } catch (error) { + logger.error("Error fetching RCA data", error); + throw error; + } +} + +// Registers the fetchRCA tool with the MCP server +export default function addRCATools( + server: McpServer, + config: BrowserStackConfig, +) { + const tools: Record = {}; + + tools.fetchRCA = server.tool( + "fetchRCA", + "Retrieves AI-RCA (Root Cause Analysis) data for a BrowserStack Automate session and provides insights into test failures.", + { + projectName: z + .string() + .describe( + "The project name of the test run can be available in browsrestack yml or ask it from user", + ), + buildName: z + .string() + .describe( + "The build name of the test run can be available in browsrestack yml or ask it from user", + ), + buildId: z + .string() + .optional() + .describe( + "The build ID of the test run.", + ), + }, + async (args) => { + try { + return await fetchRCADataTool(args, config); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + return { + content: [ + { + type: "text", + text: `Error during fetching RCA data: ${errorMessage}`, + }, + ], + }; + } + }, + ); + + return tools; +} From 8d21d7c6c69ed65c76dca24af049f0535e9c5c4b Mon Sep 17 00:00:00 2001 From: tech-sushant Date: Mon, 8 Sep 2025 14:15:00 +0530 Subject: [PATCH 02/12] Refactor RCA tools and add new types for improved functionality and readability --- src/tools/rca-agent-utils/format-rca.ts | 34 +++- .../rca-agent-utils/get-failed-test-id.ts | 112 +++++----- src/tools/rca-agent-utils/rca-data.ts | 191 ++++++++++-------- src/tools/rca-agent-utils/types.ts | 49 +++++ src/tools/rca-agent.ts | 111 ++++++++-- 5 files changed, 317 insertions(+), 180 deletions(-) create mode 100644 src/tools/rca-agent-utils/types.ts diff --git a/src/tools/rca-agent-utils/format-rca.ts b/src/tools/rca-agent-utils/format-rca.ts index d7961040..154f4570 100644 --- a/src/tools/rca-agent-utils/format-rca.ts +++ b/src/tools/rca-agent-utils/format-rca.ts @@ -1,33 +1,47 @@ +import logger from "../../logger.js"; + // Utility function to format RCA data for better readability export function formatRCAData(rcaData: any): string { + logger.info( + `Formatting RCA data for output: ${JSON.stringify(rcaData, null, 2)}`, + ); if (!rcaData || !rcaData.testCases || rcaData.testCases.length === 0) { return "No RCA data available."; } let output = "## Root Cause Analysis Report\n\n"; - + rcaData.testCases.forEach((testCase: any, index: number) => { - // Show test case name first with smaller heading - output += `### ${testCase.displayName || `Test Case ${index + 1}`}\n`; + // Show test case ID with smaller heading + output += `### Test Case ${index + 1}\n`; output += `**Test ID:** ${testCase.id}\n`; output += `**Status:** ${testCase.state}\n\n`; - if (testCase.rcaData?.originalResponse?.rcaData) { - const rca = testCase.rcaData.originalResponse.rcaData; - + // Access RCA data from the correct path + const rca = testCase.rcaData?.rcaData; + + if (rca) { if (rca.root_cause) { output += `**Root Cause:** ${rca.root_cause}\n\n`; } - + + if (rca.failure_type) { + output += `**Failure Type:** ${rca.failure_type}\n\n`; + } + if (rca.description) { - output += `**Description:**\n${rca.description}\n\n`; + output += `**Detailed Analysis:**\n${rca.description}\n\n`; } - + if (rca.possible_fix) { output += `**Recommended Fix:**\n${rca.possible_fix}\n\n`; } + } else if (testCase.rcaData?.error) { + output += `**Error:** ${testCase.rcaData.error}\n\n`; + } else if (testCase.state === "failed") { + output += `**Note:** RCA analysis failed or is not available for this test case.\n\n`; } - + output += "---\n\n"; }); diff --git a/src/tools/rca-agent-utils/get-failed-test-id.ts b/src/tools/rca-agent-utils/get-failed-test-id.ts index 46b8ddd1..2004d482 100644 --- a/src/tools/rca-agent-utils/get-failed-test-id.ts +++ b/src/tools/rca-agent-utils/get-failed-test-id.ts @@ -1,76 +1,57 @@ -interface TestDetails { - status: string; - details: any; - children?: TestDetails[]; - display_name?: string; -} +import { TestStatus, FailedTestInfo, TestRun, TestDetails } from "./types.js"; -interface TestRun { - hierarchy: TestDetails[]; - pagination?: { - has_next: boolean; - next_page: string | null; - }; -} +let hasNext = false; +let nextPageUrl: string | null = null; -export interface FailedTestInfo { - id: string; - displayName: string; -} - -export async function getFailedTestIds( +export async function getTestIds( buildId: string, authString: string, + status?: TestStatus, ): Promise { - const baseUrl = `https://api-automation.browserstack.com/ext/v1/builds/${buildId}/testRuns?test_statuses=failed`; - let nextUrl = baseUrl; + const baseUrl = `https://api-automation.browserstack.com/ext/v1/builds/${buildId}/testRuns`; + + // Build initial URL + const initialUrl = new URL(baseUrl); + if (status) initialUrl.searchParams.set("test_statuses", status); + + // Use stored nextPageUrl if available, otherwise fresh URL + const requestUrl = + hasNext && nextPageUrl ? nextPageUrl : initialUrl.toString(); let allFailedTests: FailedTestInfo[] = []; - let requestNumber = 0; // Construct Basic auth header const encodedCredentials = Buffer.from(authString).toString("base64"); const authHeader = `Basic ${encodedCredentials}`; try { - while (true) { - requestNumber++; - - const response = await fetch(nextUrl, { - headers: { - Authorization: authHeader, - "Content-Type": "application/json", - }, - }); - - if (!response.ok) { - throw new Error( - `Failed to fetch test runs: ${response.status} ${response.statusText}`, - ); - } - - const data = (await response.json()) as TestRun; - - // Extract failed IDs from current page - if (data.hierarchy && data.hierarchy.length > 0) { - const currentFailedTests = extractFailedTestIds(data.hierarchy); - allFailedTests = allFailedTests.concat(currentFailedTests); - } - - // Check for pagination termination conditions - if (!data.pagination?.has_next || !data.pagination.next_page) { - break; - } + const response = await fetch(requestUrl, { + headers: { + Authorization: authHeader, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch test runs: ${response.status} ${response.statusText}`, + ); + } - // Safety limit to prevent runaway requests - if (requestNumber >= 5) { - break; - } + const data = (await response.json()) as TestRun; - // Prepare next request - nextUrl = `${baseUrl}?next_page=${encodeURIComponent(data.pagination.next_page)}`; + // Extract failed IDs from current page + if (data.hierarchy && data.hierarchy.length > 0) { + allFailedTests = extractFailedTestIds(data.hierarchy); } - // Return unique failed test IDs + // Update pagination state in memory + hasNext = data.pagination?.has_next || false; + nextPageUrl = + hasNext && data.pagination?.next_page + ? buildNextPageUrl(baseUrl, status, data.pagination.next_page) + : null; + + // Return failed test IDs from current page only return allFailedTests; } catch (error) { console.error("Error fetching failed tests:", error); @@ -78,6 +59,18 @@ export async function getFailedTestIds( } } +// Helper to build next page URL safely +function buildNextPageUrl( + baseUrl: string, + status: TestStatus | undefined, + nextPage: string, +): string { + const url = new URL(baseUrl); + if (status) url.searchParams.set("test_statuses", status); + url.searchParams.set("next_page", nextPage); + return url.toString(); +} + // Recursive function to extract failed test IDs from hierarchy function extractFailedTestIds(hierarchy: TestDetails[]): FailedTestInfo[] { let failedTests: FailedTestInfo[] = []; @@ -87,10 +80,7 @@ function extractFailedTestIds(hierarchy: TestDetails[]): FailedTestInfo[] { if (node.details?.observability_url) { const idMatch = node.details.observability_url.match(/details=(\d+)/); if (idMatch) { - failedTests.push({ - id: idMatch[1], - displayName: node.display_name || `Test ${idMatch[1]}` - }); + failedTests.push({ id: idMatch[1] }); } } } diff --git a/src/tools/rca-agent-utils/rca-data.ts b/src/tools/rca-agent-utils/rca-data.ts index 95f34afe..65f9489b 100644 --- a/src/tools/rca-agent-utils/rca-data.ts +++ b/src/tools/rca-agent-utils/rca-data.ts @@ -1,22 +1,5 @@ -import { FailedTestInfo } from "./get-failed-test-id.js"; - -export enum RCAState { - PENDING = "pending", - COMPLETED = "completed", - FAILED = "failed", -} - -export interface RCATestCase { - id: string; - testRunId: string; - displayName?: string; - state: RCAState; - rcaData?: any; -} - -export interface RCAResponse { - testCases: RCATestCase[]; -} +import { FailedTestInfo } from "./types.js"; +import { RCAState, RCATestCase, RCAResponse } from "./types.js"; interface ScanProgressContext { sendNotification: (notification: any) => Promise; @@ -25,10 +8,27 @@ interface ScanProgressContext { }; } -// --- Utility functions --- - const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +function isInProgressState(state: RCAState): boolean { + return [ + RCAState.PENDING, + RCAState.FETCHING_LOGS, + RCAState.GENERATING_RCA, + RCAState.GENERATED_RCA, + ].includes(state); +} + +function isFailedState(state: RCAState): boolean { + return [ + RCAState.FAILED, + RCAState.LLM_SERVICE_ERROR, + RCAState.LOG_FETCH_ERROR, + RCAState.UNKNOWN_ERROR, + RCAState.TIMEOUT, + ].includes(state); +} + function calculateProgress( resolvedCount: number, totalCount: number, @@ -57,27 +57,24 @@ async function notifyProgress( }); } -// Helper to send progress based on current test cases async function updateProgress( context: ScanProgressContext | undefined, testCases: RCATestCase[], message?: string, ) { - const pending = testCases.filter((tc) => tc.state === RCAState.PENDING); - const resolvedCount = testCases.length - pending.length; + const inProgressCases = testCases.filter((tc) => isInProgressState(tc.state)); + const resolvedCount = testCases.length - inProgressCases.length; + await notifyProgress( context, message ?? - (pending.length === 0 - ? "RCA analysis completed for all test cases" - : `RCA analysis in progress (${resolvedCount}/${testCases.length} resolved)`), - pending.length === 0 + `RCA analysis in progress (${resolvedCount}/${testCases.length} resolved)`, + inProgressCases.length === 0 ? 100 : calculateProgress(resolvedCount, testCases.length), ); } -// --- Fetch initial RCA for a test case --- async function fetchInitialRCA( testInfo: FailedTestInfo, headers: Record, @@ -87,12 +84,12 @@ async function fetchInitialRCA( try { const response = await fetch(url, { headers }); + if (!response.ok) { return { id: testInfo.id, testRunId: testInfo.id, - displayName: testInfo.displayName, - state: RCAState.FAILED, + state: RCAState.LOG_FETCH_ERROR, rcaData: { error: `HTTP ${response.status}: Failed to start RCA analysis`, }, @@ -100,31 +97,41 @@ async function fetchInitialRCA( } const data = await response.json(); - if (data.state && !["pending", "completed"].includes(data.state)) { - return { - id: data.id ?? testInfo.id, - testRunId: data.testRunId ?? testInfo.id, - displayName: testInfo.displayName, - state: RCAState.FAILED, - rcaData: { - error: `API returned error state: ${data.state}`, - originalResponse: data, - }, - }; - } + + const apiState = data.state?.toLowerCase(); + let resultState: RCAState; + + if (apiState === "completed") resultState = RCAState.COMPLETED; + else if (apiState === "pending") resultState = RCAState.PENDING; + else if (apiState === "fetching_logs") resultState = RCAState.FETCHING_LOGS; + else if (apiState === "generating_rca") + resultState = RCAState.GENERATING_RCA; + else if (apiState === "generated_rca") resultState = RCAState.GENERATED_RCA; + else if (apiState === "processing" || apiState === "running") + resultState = RCAState.GENERATING_RCA; + else if (apiState === "failed" || apiState === "error") + resultState = RCAState.UNKNOWN_ERROR; + else if (apiState) resultState = RCAState.UNKNOWN_ERROR; + else resultState = RCAState.PENDING; return { - id: data.id ?? testInfo.id, - testRunId: data.testRunId ?? testInfo.id, - displayName: testInfo.displayName, - state: RCAState.PENDING, + id: testInfo.id, + testRunId: testInfo.id, + state: resultState, + ...(resultState === RCAState.COMPLETED && { rcaData: data }), + ...(isFailedState(resultState) && + data.state && { + rcaData: { + error: `API returned state: ${data.state}`, + originalResponse: data, + }, + }), }; } catch (error) { return { id: testInfo.id, testRunId: testInfo.id, - displayName: testInfo.displayName, - state: RCAState.FAILED, + state: RCAState.LLM_SERVICE_ERROR, rcaData: { error: error instanceof Error ? error.message : "Network or parsing error", @@ -133,7 +140,6 @@ async function fetchInitialRCA( } } -// --- Poll all test cases until completion or timeout --- async function pollRCAResults( testCases: RCATestCase[], headers: Record, @@ -144,55 +150,72 @@ async function pollRCAResults( initialDelay: number, ): Promise { const startTime = Date.now(); - await delay(initialDelay); try { while (true) { - const pendingCases = testCases.filter( - (tc) => tc.state === RCAState.PENDING, + const inProgressCases = testCases.filter((tc) => + isInProgressState(tc.state), ); await updateProgress(context, testCases); - if (pendingCases.length === 0) break; + if (inProgressCases.length === 0) break; if (Date.now() - startTime >= timeout) { - pendingCases.forEach((tc) => { - tc.state = RCAState.FAILED; + inProgressCases.forEach((tc) => { + tc.state = RCAState.TIMEOUT; tc.rcaData = { error: `Timeout after ${timeout}ms` }; }); await updateProgress(context, testCases, "RCA analysis timed out"); break; } - // Poll all pending cases in parallel await Promise.allSettled( - pendingCases.map(async (tc) => { + inProgressCases.map(async (tc) => { try { - const response = await fetch(baseUrl.replace("{testId}", tc.id), { - headers, - }); + const pollUrl = baseUrl.replace("{testId}", tc.id); + const response = await fetch(pollUrl, { headers }); if (!response.ok) { - tc.state = RCAState.FAILED; - tc.rcaData = { error: `HTTP ${response.status}: Polling failed` }; + const errorText = await response.text(); + tc.state = RCAState.LOG_FETCH_ERROR; + tc.rcaData = { + error: `HTTP ${response.status}: Polling failed - ${errorText}`, + }; return; } + const data = await response.json(); - if (tc.state === RCAState.PENDING) { - if (data.state === "completed") { + if (!isFailedState(tc.state)) { + const apiState = data.state?.toLowerCase(); + if (apiState === "completed") { tc.state = RCAState.COMPLETED; tc.rcaData = data; - } else if (data.state && data.state !== "pending") { - tc.state = RCAState.FAILED; + } else if (apiState === "failed" || apiState === "error") { + tc.state = RCAState.UNKNOWN_ERROR; tc.rcaData = { error: `API returned error state: ${data.state}`, originalResponse: data, }; + } else if (apiState === "pending") tc.state = RCAState.PENDING; + else if (apiState === "fetching_logs") + tc.state = RCAState.FETCHING_LOGS; + else if (apiState === "generating_rca") + tc.state = RCAState.GENERATING_RCA; + else if (apiState === "generated_rca") + tc.state = RCAState.GENERATED_RCA; + else if (apiState === "processing" || apiState === "running") + tc.state = RCAState.GENERATING_RCA; + else { + tc.state = RCAState.UNKNOWN_ERROR; + tc.rcaData = { + error: `API returned unknown state: ${data.state}`, + originalResponse: data, + }; } } } catch (err) { - if (tc.state === RCAState.PENDING) { - tc.state = RCAState.FAILED; + if (!isFailedState(tc.state)) { + tc.state = RCAState.LLM_SERVICE_ERROR; tc.rcaData = { error: err instanceof Error @@ -207,11 +230,10 @@ async function pollRCAResults( await delay(pollInterval); } } catch (err) { - // Fallback in case of unexpected error testCases - .filter((tc) => tc.state === RCAState.PENDING) + .filter((tc) => isInProgressState(tc.state)) .forEach((tc) => { - tc.state = RCAState.FAILED; + tc.state = RCAState.UNKNOWN_ERROR; tc.rcaData = { error: err instanceof Error ? err.message : "Unexpected error", }; @@ -226,9 +248,8 @@ async function pollRCAResults( return { testCases }; } -// --- Public API function --- export async function getRCAData( - testInfos: FailedTestInfo[], + testIds: string[], authString: string, context?: ScanProgressContext, ): Promise { @@ -245,31 +266,23 @@ export async function getRCAData( await notifyProgress(context, "Starting RCA analysis for test cases...", 0); - // Step 1: Fire initial RCA requests in parallel const testCases = await Promise.all( - testInfos.map((testInfo) => fetchInitialRCA(testInfo, headers, baseUrl)), + testIds.map((testId) => fetchInitialRCA({ id: testId }, headers, baseUrl)), ); - const pendingCount = testCases.filter( - (tc) => tc.state === RCAState.PENDING, + const inProgressCount = testCases.filter((tc) => + isInProgressState(tc.state), ).length; + await notifyProgress( context, - `Initial RCA requests completed. ${pendingCount} cases pending analysis...`, + `Initial RCA requests completed. ${inProgressCount} cases pending analysis...`, 10, ); - if (pendingCount === 0) { - await notifyProgress( - context, - "RCA analysis completed for all test cases", - 100, - ); - return { testCases }; - } + if (inProgressCount === 0) return { testCases }; - // Step 2: Poll pending test cases - return pollRCAResults( + return await pollRCAResults( testCases, headers, baseUrl, diff --git a/src/tools/rca-agent-utils/types.ts b/src/tools/rca-agent-utils/types.ts new file mode 100644 index 00000000..20413fe5 --- /dev/null +++ b/src/tools/rca-agent-utils/types.ts @@ -0,0 +1,49 @@ +export enum TestStatus { + PASSED = "passed", + FAILED = "failed", + PENDING = "pending", + SKIPPED = "skipped", +} + +export interface TestDetails { + status: TestStatus; + details: any; + children?: TestDetails[]; + display_name?: string; +} + +export interface TestRun { + hierarchy: TestDetails[]; + pagination?: { + has_next: boolean; + next_page: string | null; + }; +} + +export interface FailedTestInfo { + id: string; +} + +export enum RCAState { + PENDING = "pending", + FETCHING_LOGS = "fetching_logs", + GENERATING_RCA = "generating_rca", + GENERATED_RCA = "generated_rca", + COMPLETED = "completed", + FAILED = "failed", + LLM_SERVICE_ERROR = "LLM_SERVICE_ERROR", + LOG_FETCH_ERROR = "LOG_FETCH_ERROR", + UNKNOWN_ERROR = "UNKNOWN_ERROR", + TIMEOUT = "TIMEOUT", +} + +export interface RCATestCase { + id: string; + testRunId: string; + state: RCAState; + rcaData?: any; +} + +export interface RCAResponse { + testCases: RCATestCase[]; +} diff --git a/src/tools/rca-agent.ts b/src/tools/rca-agent.ts index 3ed80c75..7d46e683 100644 --- a/src/tools/rca-agent.ts +++ b/src/tools/rca-agent.ts @@ -5,22 +5,23 @@ import logger from "../logger.js"; import { BrowserStackConfig } from "../lib/types.js"; import { getBrowserStackAuth } from "../lib/get-auth.js"; import { getBuildId } from "./rca-agent-utils/get-build-id.js"; -import { getFailedTestIds } from "./rca-agent-utils/get-failed-test-id.js"; +import { getTestIds } from "./rca-agent-utils/get-failed-test-id.js"; import { getRCAData } from "./rca-agent-utils/rca-data.js"; import { formatRCAData } from "./rca-agent-utils/format-rca.js"; +import { TestStatus } from "./rca-agent-utils/types.js"; -// wTool function that fetches RCA data +// Tool function that fetches RCA data export async function fetchRCADataTool( - args: { projectName: string; buildName: string; buildId?: string }, + args: { testId: string[] }, config: BrowserStackConfig, ): Promise { try { - let { projectName, buildName, buildId } = args; const authString = getBrowserStackAuth(config); - const [username, accessKey] = authString.split(":"); - buildId = buildId || await getBuildId(projectName, buildName, username, accessKey); - const testInfos = await getFailedTestIds(buildId, authString); - const rcaData = await getRCAData(testInfos.slice(0, 3), authString); + + // Limit to first 3 test IDs for performance + const testIds = args.testId.slice(0, 3); + + const rcaData = await getRCAData(testIds, authString); const formattedData = formatRCAData(rcaData); @@ -38,6 +39,52 @@ export async function fetchRCADataTool( } } +export async function listTestIdsTool( + args: { + projectName: string; + buildName: string; + buildId?: string; + status?: TestStatus; + }, + config: BrowserStackConfig, +): Promise { + try { + const { projectName, buildName, status } = args; + let { buildId } = args; + const authString = getBrowserStackAuth(config); + const [username, accessKey] = authString.split(":"); + + // Get build ID if not provided + buildId = + buildId || + (await getBuildId(projectName, buildName, username, accessKey)); + + // Get test IDs + const testIds = await getTestIds(buildId, authString, status); + + return { + content: [ + { + type: "text", + text: JSON.stringify(testIds, null, 2), + }, + ], + }; + } catch (error) { + logger.error("Error listing test IDs", error); + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + return { + content: [ + { + type: "text", + text: `Error listing test IDs: ${errorMessage}`, + }, + ], + }; + } +} + // Registers the fetchRCA tool with the MCP server export default function addRCATools( server: McpServer, @@ -49,26 +96,50 @@ export default function addRCATools( "fetchRCA", "Retrieves AI-RCA (Root Cause Analysis) data for a BrowserStack Automate session and provides insights into test failures.", { - projectName: z - .string() - .describe( - "The project name of the test run can be available in browsrestack yml or ask it from user", - ), - buildName: z + testId: z + .array(z.string()) + .describe("Array of test IDs to fetch RCA data for"), + }, + async (args) => { + try { + return await fetchRCADataTool(args, config); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + return { + content: [ + { + type: "text", + text: `Error during fetching RCA data: ${errorMessage}`, + }, + ], + }; + } + }, + ); + + tools.listTestIds = server.tool( + "listTestIds", + "List test IDs from a BrowserStack Automate build, optionally filtered by status (e.g., 'failed', 'passed').", + { + projectName: z.string().describe("The project name of the test run"), + buildName: z.string().describe("The build name of the test run"), + buildId: z .string() + .optional() .describe( - "The build name of the test run can be available in browsrestack yml or ask it from user", + "The build ID of the test run (will be auto-detected if not provided)", ), - buildId: z - .string() + status: z + .nativeEnum(TestStatus) .optional() .describe( - "The build ID of the test run.", + "Filter tests by status. If not provided, all tests are returned.", ), }, async (args) => { try { - return await fetchRCADataTool(args, config); + return await listTestIdsTool(args, config); } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; @@ -76,7 +147,7 @@ export default function addRCATools( content: [ { type: "text", - text: `Error during fetching RCA data: ${errorMessage}`, + text: `Error during listing test IDs: ${errorMessage}`, }, ], }; From 3c0fec9d994ff948d90e4c59cad50e164353e3fb Mon Sep 17 00:00:00 2001 From: tech-sushant Date: Mon, 8 Sep 2025 14:41:26 +0530 Subject: [PATCH 03/12] RCA data fetching by improving pagination handling --- .../rca-agent-utils/get-failed-test-id.ts | 87 +++++++++---------- src/tools/rca-agent-utils/rca-data.ts | 19 ++-- src/tools/rca-agent-utils/types.ts | 3 +- src/tools/rca-agent.ts | 4 +- 4 files changed, 54 insertions(+), 59 deletions(-) diff --git a/src/tools/rca-agent-utils/get-failed-test-id.ts b/src/tools/rca-agent-utils/get-failed-test-id.ts index 2004d482..bd5b9d1c 100644 --- a/src/tools/rca-agent-utils/get-failed-test-id.ts +++ b/src/tools/rca-agent-utils/get-failed-test-id.ts @@ -1,57 +1,59 @@ import { TestStatus, FailedTestInfo, TestRun, TestDetails } from "./types.js"; -let hasNext = false; -let nextPageUrl: string | null = null; - export async function getTestIds( buildId: string, authString: string, status?: TestStatus, ): Promise { const baseUrl = `https://api-automation.browserstack.com/ext/v1/builds/${buildId}/testRuns`; - - // Build initial URL - const initialUrl = new URL(baseUrl); - if (status) initialUrl.searchParams.set("test_statuses", status); - - // Use stored nextPageUrl if available, otherwise fresh URL - const requestUrl = - hasNext && nextPageUrl ? nextPageUrl : initialUrl.toString(); + let url = status ? `${baseUrl}?test_statuses=${status}` : baseUrl; let allFailedTests: FailedTestInfo[] = []; + let requestNumber = 0; // Construct Basic auth header const encodedCredentials = Buffer.from(authString).toString("base64"); const authHeader = `Basic ${encodedCredentials}`; try { - const response = await fetch(requestUrl, { - headers: { - Authorization: authHeader, - "Content-Type": "application/json", - }, - }); + while (true) { + requestNumber++; + + const response = await fetch(url, { + headers: { + Authorization: authHeader, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch test runs: ${response.status} ${response.statusText}`, + ); + } - if (!response.ok) { - throw new Error( - `Failed to fetch test runs: ${response.status} ${response.statusText}`, - ); - } + const data = (await response.json()) as TestRun; - const data = (await response.json()) as TestRun; + // Extract failed IDs from current page + if (data.hierarchy && data.hierarchy.length > 0) { + const currentFailedTests = extractFailedTestIds(data.hierarchy); + allFailedTests = allFailedTests.concat(currentFailedTests); + } - // Extract failed IDs from current page - if (data.hierarchy && data.hierarchy.length > 0) { - allFailedTests = extractFailedTestIds(data.hierarchy); - } + // Check for pagination termination conditions + if (!data.pagination?.has_next || !data.pagination.next_page) { + break; + } + + // Safety limit to prevent runaway requests + if (requestNumber >= 5) { + break; + } - // Update pagination state in memory - hasNext = data.pagination?.has_next || false; - nextPageUrl = - hasNext && data.pagination?.next_page - ? buildNextPageUrl(baseUrl, status, data.pagination.next_page) - : null; + // Prepare next request + url = `${baseUrl}?next_page=${encodeURIComponent(data.pagination.next_page)}`; + } - // Return failed test IDs from current page only + // Return unique failed test IDs return allFailedTests; } catch (error) { console.error("Error fetching failed tests:", error); @@ -59,18 +61,6 @@ export async function getTestIds( } } -// Helper to build next page URL safely -function buildNextPageUrl( - baseUrl: string, - status: TestStatus | undefined, - nextPage: string, -): string { - const url = new URL(baseUrl); - if (status) url.searchParams.set("test_statuses", status); - url.searchParams.set("next_page", nextPage); - return url.toString(); -} - // Recursive function to extract failed test IDs from hierarchy function extractFailedTestIds(hierarchy: TestDetails[]): FailedTestInfo[] { let failedTests: FailedTestInfo[] = []; @@ -80,7 +70,10 @@ function extractFailedTestIds(hierarchy: TestDetails[]): FailedTestInfo[] { if (node.details?.observability_url) { const idMatch = node.details.observability_url.match(/details=(\d+)/); if (idMatch) { - failedTests.push({ id: idMatch[1] }); + failedTests.push({ + test_id: idMatch[1], + test_name: node.display_name || `Test ${idMatch[1]}`, + }); } } } diff --git a/src/tools/rca-agent-utils/rca-data.ts b/src/tools/rca-agent-utils/rca-data.ts index 65f9489b..97edce69 100644 --- a/src/tools/rca-agent-utils/rca-data.ts +++ b/src/tools/rca-agent-utils/rca-data.ts @@ -1,4 +1,3 @@ -import { FailedTestInfo } from "./types.js"; import { RCAState, RCATestCase, RCAResponse } from "./types.js"; interface ScanProgressContext { @@ -76,19 +75,19 @@ async function updateProgress( } async function fetchInitialRCA( - testInfo: FailedTestInfo, + testId: string, headers: Record, baseUrl: string, ): Promise { - const url = baseUrl.replace("{testId}", testInfo.id); + const url = baseUrl.replace("{testId}", testId); try { const response = await fetch(url, { headers }); if (!response.ok) { return { - id: testInfo.id, - testRunId: testInfo.id, + id: testId, + testRunId: testId, state: RCAState.LOG_FETCH_ERROR, rcaData: { error: `HTTP ${response.status}: Failed to start RCA analysis`, @@ -115,8 +114,8 @@ async function fetchInitialRCA( else resultState = RCAState.PENDING; return { - id: testInfo.id, - testRunId: testInfo.id, + id: testId, + testRunId: testId, state: resultState, ...(resultState === RCAState.COMPLETED && { rcaData: data }), ...(isFailedState(resultState) && @@ -129,8 +128,8 @@ async function fetchInitialRCA( }; } catch (error) { return { - id: testInfo.id, - testRunId: testInfo.id, + id: testId, + testRunId: testId, state: RCAState.LLM_SERVICE_ERROR, rcaData: { error: @@ -267,7 +266,7 @@ export async function getRCAData( await notifyProgress(context, "Starting RCA analysis for test cases...", 0); const testCases = await Promise.all( - testIds.map((testId) => fetchInitialRCA({ id: testId }, headers, baseUrl)), + testIds.map((testId) => fetchInitialRCA(testId, headers, baseUrl)), ); const inProgressCount = testCases.filter((tc) => diff --git a/src/tools/rca-agent-utils/types.ts b/src/tools/rca-agent-utils/types.ts index 20413fe5..c03fbdfa 100644 --- a/src/tools/rca-agent-utils/types.ts +++ b/src/tools/rca-agent-utils/types.ts @@ -21,7 +21,8 @@ export interface TestRun { } export interface FailedTestInfo { - id: string; + test_id: string; + test_name: string; } export enum RCAState { diff --git a/src/tools/rca-agent.ts b/src/tools/rca-agent.ts index 7d46e683..ccd010b0 100644 --- a/src/tools/rca-agent.ts +++ b/src/tools/rca-agent.ts @@ -98,7 +98,9 @@ export default function addRCATools( { testId: z .array(z.string()) - .describe("Array of test IDs to fetch RCA data for"), + .describe( + "Array of test IDs to fetch RCA data for If not provided call listTestIds tool first to get the IDs", + ), }, async (args) => { try { From ff81d6ed7322cf54f8a846a25e0b35b7a920ca41 Mon Sep 17 00:00:00 2001 From: tech-sushant Date: Tue, 9 Sep 2025 14:36:19 +0530 Subject: [PATCH 04/12] refactor: Simplify build ID retrieval and enhance tool descriptions for clarity --- src/tools/rca-agent.ts | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/src/tools/rca-agent.ts b/src/tools/rca-agent.ts index ccd010b0..eac844f2 100644 --- a/src/tools/rca-agent.ts +++ b/src/tools/rca-agent.ts @@ -43,21 +43,22 @@ export async function listTestIdsTool( args: { projectName: string; buildName: string; - buildId?: string; status?: TestStatus; }, config: BrowserStackConfig, ): Promise { try { const { projectName, buildName, status } = args; - let { buildId } = args; const authString = getBrowserStackAuth(config); const [username, accessKey] = authString.split(":"); // Get build ID if not provided - buildId = - buildId || - (await getBuildId(projectName, buildName, username, accessKey)); + const buildId = await getBuildId( + username, + accessKey, + projectName, + buildName, + ); // Get test IDs const testIds = await getTestIds(buildId, authString, status); @@ -94,12 +95,13 @@ export default function addRCATools( tools.fetchRCA = server.tool( "fetchRCA", - "Retrieves AI-RCA (Root Cause Analysis) data for a BrowserStack Automate session and provides insights into test failures.", + "Retrieves AI-RCA (Root Cause Analysis) data for a BrowserStack Automate and App-Automate session and provides insights into test failures.", { testId: z .array(z.string()) + .max(3) .describe( - "Array of test IDs to fetch RCA data for If not provided call listTestIds tool first to get the IDs", + "Array of test IDs to fetch RCA data. Input should be a maximum of 3 IDs at a time. If you get more than 3 Ids ask user to choose less than 3", ), }, async (args) => { @@ -122,16 +124,10 @@ export default function addRCATools( tools.listTestIds = server.tool( "listTestIds", - "List test IDs from a BrowserStack Automate build, optionally filtered by status (e.g., 'failed', 'passed').", + "List test IDs from a BrowserStack Automate build, optionally filtered by status", { projectName: z.string().describe("The project name of the test run"), buildName: z.string().describe("The build name of the test run"), - buildId: z - .string() - .optional() - .describe( - "The build ID of the test run (will be auto-detected if not provided)", - ), status: z .nativeEnum(TestStatus) .optional() From 7858d84afb4d40b2e32dfb6a32aea70044f275e6 Mon Sep 17 00:00:00 2001 From: tech-sushant Date: Tue, 9 Sep 2025 14:41:56 +0530 Subject: [PATCH 05/12] fix: Update timeout value in getRCAData function and improve description for test ID input --- src/tools/rca-agent-utils/rca-data.ts | 2 +- src/tools/rca-agent.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tools/rca-agent-utils/rca-data.ts b/src/tools/rca-agent-utils/rca-data.ts index 97edce69..a1b0a601 100644 --- a/src/tools/rca-agent-utils/rca-data.ts +++ b/src/tools/rca-agent-utils/rca-data.ts @@ -253,7 +253,7 @@ export async function getRCAData( context?: ScanProgressContext, ): Promise { const pollInterval = 5000; - const timeout = 30000; + const timeout = 40000; const initialDelay = 20000; const baseUrl = diff --git a/src/tools/rca-agent.ts b/src/tools/rca-agent.ts index eac844f2..7f623e07 100644 --- a/src/tools/rca-agent.ts +++ b/src/tools/rca-agent.ts @@ -101,7 +101,7 @@ export default function addRCATools( .array(z.string()) .max(3) .describe( - "Array of test IDs to fetch RCA data. Input should be a maximum of 3 IDs at a time. If you get more than 3 Ids ask user to choose less than 3", + "Array of test IDs to fetch RCA data for (maximum 3 IDs). Use the listTestIds tool to discover available test IDs if needed. If more than 3 IDs are provided, only the first 3 will be processed.", ), }, async (args) => { From eac1aa653275e683eb538718aaebf228e2185142 Mon Sep 17 00:00:00 2001 From: tech-sushant Date: Tue, 9 Sep 2025 16:25:32 +0530 Subject: [PATCH 06/12] Enhance descriptions for test ID and project/build name inputs in RCA tools --- src/tools/rca-agent.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/tools/rca-agent.ts b/src/tools/rca-agent.ts index 7f623e07..df097dc0 100644 --- a/src/tools/rca-agent.ts +++ b/src/tools/rca-agent.ts @@ -101,7 +101,7 @@ export default function addRCATools( .array(z.string()) .max(3) .describe( - "Array of test IDs to fetch RCA data for (maximum 3 IDs). Use the listTestIds tool to discover available test IDs if needed. If more than 3 IDs are provided, only the first 3 will be processed.", + "Array of test IDs to fetch RCA data for (maximum 3 IDs). If not provided, use the listTestIds tool get all failed testcases. If more than 3 IDs are provided, only the first 3 will be processed.", ), }, async (args) => { @@ -126,13 +126,20 @@ export default function addRCATools( "listTestIds", "List test IDs from a BrowserStack Automate build, optionally filtered by status", { - projectName: z.string().describe("The project name of the test run"), - buildName: z.string().describe("The build name of the test run"), + projectName: z + .string() + .describe( + "The Browserstack project name used while creation of test run. Check browserstack.yml or similar project configuration files. If found extract it and provide to user,IF not found or unsure, prompt the user for this value. Do not make assumptions", + ), + buildName: z + .string() + .describe( + "The Browserstack build name used while creation of test run. Check browserstack.yml or similar project configuration files. If found extract it and provide to user,IF not found or unsure, prompt the user for this value. Do not make assumptions", + ), status: z .nativeEnum(TestStatus) - .optional() .describe( - "Filter tests by status. If not provided, all tests are returned.", + "Filter tests by status. If not provided, all tests are returned. Example for RCA usecase always use failed status", ), }, async (args) => { From 670614298069f76cdf3058ccf515e0e9b8286619 Mon Sep 17 00:00:00 2001 From: tech-sushant Date: Tue, 9 Sep 2025 16:42:14 +0530 Subject: [PATCH 07/12] fix: Improve progress calculation and centralize API state mapping for clarity --- src/tools/rca-agent-utils/rca-data.ts | 66 ++++++++++++--------------- 1 file changed, 29 insertions(+), 37 deletions(-) diff --git a/src/tools/rca-agent-utils/rca-data.ts b/src/tools/rca-agent-utils/rca-data.ts index a1b0a601..0f60c0ac 100644 --- a/src/tools/rca-agent-utils/rca-data.ts +++ b/src/tools/rca-agent-utils/rca-data.ts @@ -33,11 +33,33 @@ function calculateProgress( totalCount: number, baseProgress: number = 10, ): number { + if (totalCount === 0) return 100; // ✅ fix divide by zero const progressRange = 90 - baseProgress; const completionProgress = (resolvedCount / totalCount) * progressRange; return Math.min(100, baseProgress + completionProgress); } +// ✅ centralized mapping function +function mapApiState(apiState?: string): RCAState { + const state = apiState?.toLowerCase(); + switch (state) { + case "completed": + return RCAState.COMPLETED; + case "pending": + return RCAState.PENDING; + case "fetching_logs": + return RCAState.FETCHING_LOGS; + case "generating_rca": + return RCAState.GENERATING_RCA; + case "generated_rca": + return RCAState.GENERATED_RCA; + case "error": + return RCAState.UNKNOWN_ERROR; + default: + return RCAState.UNKNOWN_ERROR; + } +} + async function notifyProgress( context: ScanProgressContext | undefined, message: string, @@ -96,22 +118,7 @@ async function fetchInitialRCA( } const data = await response.json(); - - const apiState = data.state?.toLowerCase(); - let resultState: RCAState; - - if (apiState === "completed") resultState = RCAState.COMPLETED; - else if (apiState === "pending") resultState = RCAState.PENDING; - else if (apiState === "fetching_logs") resultState = RCAState.FETCHING_LOGS; - else if (apiState === "generating_rca") - resultState = RCAState.GENERATING_RCA; - else if (apiState === "generated_rca") resultState = RCAState.GENERATED_RCA; - else if (apiState === "processing" || apiState === "running") - resultState = RCAState.GENERATING_RCA; - else if (apiState === "failed" || apiState === "error") - resultState = RCAState.UNKNOWN_ERROR; - else if (apiState) resultState = RCAState.UNKNOWN_ERROR; - else resultState = RCAState.PENDING; + const resultState = mapApiState(data.state); // ✅ reuse helper return { id: testId, @@ -185,29 +192,14 @@ async function pollRCAResults( const data = await response.json(); if (!isFailedState(tc.state)) { - const apiState = data.state?.toLowerCase(); - if (apiState === "completed") { - tc.state = RCAState.COMPLETED; + const mappedState = mapApiState(data.state); // ✅ reuse helper + tc.state = mappedState; + + if (mappedState === RCAState.COMPLETED) { tc.rcaData = data; - } else if (apiState === "failed" || apiState === "error") { - tc.state = RCAState.UNKNOWN_ERROR; - tc.rcaData = { - error: `API returned error state: ${data.state}`, - originalResponse: data, - }; - } else if (apiState === "pending") tc.state = RCAState.PENDING; - else if (apiState === "fetching_logs") - tc.state = RCAState.FETCHING_LOGS; - else if (apiState === "generating_rca") - tc.state = RCAState.GENERATING_RCA; - else if (apiState === "generated_rca") - tc.state = RCAState.GENERATED_RCA; - else if (apiState === "processing" || apiState === "running") - tc.state = RCAState.GENERATING_RCA; - else { - tc.state = RCAState.UNKNOWN_ERROR; + } else if (mappedState === RCAState.UNKNOWN_ERROR) { tc.rcaData = { - error: `API returned unknown state: ${data.state}`, + error: `API returned state: ${data.state}`, originalResponse: data, }; } From b17a879f12146972bf998717547952f1ed75c50a Mon Sep 17 00:00:00 2001 From: tech-sushant Date: Wed, 10 Sep 2025 14:29:38 +0530 Subject: [PATCH 08/12] refactoring for RCA Tool --- src/tools/rca-agent.ts | 93 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 76 insertions(+), 17 deletions(-) diff --git a/src/tools/rca-agent.ts b/src/tools/rca-agent.ts index df097dc0..333a52d8 100644 --- a/src/tools/rca-agent.ts +++ b/src/tools/rca-agent.ts @@ -10,6 +10,47 @@ import { getRCAData } from "./rca-agent-utils/rca-data.js"; import { formatRCAData } from "./rca-agent-utils/format-rca.js"; import { TestStatus } from "./rca-agent-utils/types.js"; +// Tool function to fetch build ID +export async function getBuildIdTool( + args: { + projectName: string; + buildName: string; + }, + config: BrowserStackConfig, +): Promise { + try { + const { projectName, buildName } = args; + const authString = getBrowserStackAuth(config); + const [username, accessKey] = authString.split(":"); + const buildId = await getBuildId( + username, + accessKey, + projectName, + buildName, + ); + return { + content: [ + { + type: "text", + text: buildId, + }, + ], + }; + } catch (error) { + logger.error("Error fetching build ID", error); + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + return { + content: [ + { + type: "text", + text: `Error fetching build ID: ${errorMessage}`, + }, + ], + }; + } +} + // Tool function that fetches RCA data export async function fetchRCADataTool( args: { testId: string[] }, @@ -41,24 +82,14 @@ export async function fetchRCADataTool( export async function listTestIdsTool( args: { - projectName: string; - buildName: string; + buildId: string; status?: TestStatus; }, config: BrowserStackConfig, ): Promise { try { - const { projectName, buildName, status } = args; + const { buildId, status } = args; const authString = getBrowserStackAuth(config); - const [username, accessKey] = authString.split(":"); - - // Get build ID if not provided - const buildId = await getBuildId( - username, - accessKey, - projectName, - buildName, - ); // Get test IDs const testIds = await getTestIds(buildId, authString, status); @@ -122,19 +153,47 @@ export default function addRCATools( }, ); - tools.listTestIds = server.tool( - "listTestIds", - "List test IDs from a BrowserStack Automate build, optionally filtered by status", + tools.getBuildId = server.tool( + "getBuildId", + "Get the BrowserStack build ID for a given project and build name.", { projectName: z .string() .describe( - "The Browserstack project name used while creation of test run. Check browserstack.yml or similar project configuration files. If found extract it and provide to user,IF not found or unsure, prompt the user for this value. Do not make assumptions", + "The Browserstack project name used while creation of test run. Check browserstack.yml or similar project configuration files. If found extract it and provide to user, IF not found or unsure, prompt the user for this value. Do not make assumptions", ), buildName: z .string() .describe( - "The Browserstack build name used while creation of test run. Check browserstack.yml or similar project configuration files. If found extract it and provide to user,IF not found or unsure, prompt the user for this value. Do not make assumptions", + "The Browserstack build name used while creation of test run. Check browserstack.yml or similar project configuration files. If found extract it and provide to user, IF not found or unsure, prompt the user for this value. Do not make assumptions", + ), + }, + async (args) => { + try { + return await getBuildIdTool(args, config); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + return { + content: [ + { + type: "text", + text: `Error during fetching build ID: ${errorMessage}`, + }, + ], + }; + } + }, + ); + + tools.listTestIds = server.tool( + "listTestIds", + "List test IDs from a BrowserStack Automate build, optionally filtered by status", + { + buildId: z + .string() + .describe( + "The Browserstack Build ID of the test run. If not known, use the getBuildId tool to fetch it using project and build name", ), status: z .nativeEnum(TestStatus) From 9c5f3d531548e17e5b7ea33873c4787ff1b750ec Mon Sep 17 00:00:00 2001 From: tech-sushant Date: Wed, 10 Sep 2025 15:33:01 +0530 Subject: [PATCH 09/12] refactor: Remove logger and adjust test ID --- src/tools/rca-agent-utils/format-rca.ts | 5 ----- src/tools/rca-agent-utils/rca-data.ts | 4 ++-- src/tools/rca-agent.ts | 2 +- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/tools/rca-agent-utils/format-rca.ts b/src/tools/rca-agent-utils/format-rca.ts index 154f4570..c4e84600 100644 --- a/src/tools/rca-agent-utils/format-rca.ts +++ b/src/tools/rca-agent-utils/format-rca.ts @@ -1,10 +1,5 @@ -import logger from "../../logger.js"; - // Utility function to format RCA data for better readability export function formatRCAData(rcaData: any): string { - logger.info( - `Formatting RCA data for output: ${JSON.stringify(rcaData, null, 2)}`, - ); if (!rcaData || !rcaData.testCases || rcaData.testCases.length === 0) { return "No RCA data available."; } diff --git a/src/tools/rca-agent-utils/rca-data.ts b/src/tools/rca-agent-utils/rca-data.ts index 0f60c0ac..1e66220e 100644 --- a/src/tools/rca-agent-utils/rca-data.ts +++ b/src/tools/rca-agent-utils/rca-data.ts @@ -118,7 +118,7 @@ async function fetchInitialRCA( } const data = await response.json(); - const resultState = mapApiState(data.state); // ✅ reuse helper + const resultState = mapApiState(data.state); return { id: testId, @@ -192,7 +192,7 @@ async function pollRCAResults( const data = await response.json(); if (!isFailedState(tc.state)) { - const mappedState = mapApiState(data.state); // ✅ reuse helper + const mappedState = mapApiState(data.state); tc.state = mappedState; if (mappedState === RCAState.COMPLETED) { diff --git a/src/tools/rca-agent.ts b/src/tools/rca-agent.ts index 333a52d8..d2e53e85 100644 --- a/src/tools/rca-agent.ts +++ b/src/tools/rca-agent.ts @@ -60,7 +60,7 @@ export async function fetchRCADataTool( const authString = getBrowserStackAuth(config); // Limit to first 3 test IDs for performance - const testIds = args.testId.slice(0, 3); + const testIds = args.testId; const rcaData = await getRCAData(testIds, authString); From d12c3ce0c570a677f348c68ce088a9546cd27a01 Mon Sep 17 00:00:00 2001 From: tech-sushant Date: Wed, 10 Sep 2025 15:35:03 +0530 Subject: [PATCH 10/12] Update src/tools/rca-agent.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/tools/rca-agent.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tools/rca-agent.ts b/src/tools/rca-agent.ts index d2e53e85..52770a38 100644 --- a/src/tools/rca-agent.ts +++ b/src/tools/rca-agent.ts @@ -23,10 +23,10 @@ export async function getBuildIdTool( const authString = getBrowserStackAuth(config); const [username, accessKey] = authString.split(":"); const buildId = await getBuildId( - username, - accessKey, projectName, buildName, + username, + accessKey, ); return { content: [ From 0b9c146cae90a03e461d1e8142463753b3f8a5b7 Mon Sep 17 00:00:00 2001 From: tech-sushant Date: Wed, 10 Sep 2025 15:36:17 +0530 Subject: [PATCH 11/12] Replace console.error with logger --- src/tools/rca-agent-utils/get-failed-test-id.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tools/rca-agent-utils/get-failed-test-id.ts b/src/tools/rca-agent-utils/get-failed-test-id.ts index bd5b9d1c..7e8e6fb2 100644 --- a/src/tools/rca-agent-utils/get-failed-test-id.ts +++ b/src/tools/rca-agent-utils/get-failed-test-id.ts @@ -1,3 +1,4 @@ +import logger from "../../logger.js"; import { TestStatus, FailedTestInfo, TestRun, TestDetails } from "./types.js"; export async function getTestIds( @@ -56,7 +57,7 @@ export async function getTestIds( // Return unique failed test IDs return allFailedTests; } catch (error) { - console.error("Error fetching failed tests:", error); + logger.error("Error fetching failed tests:", error); throw error; } } From 82cc762d8fee39a059f5056f64958f25031f4600 Mon Sep 17 00:00:00 2001 From: tech-sushant Date: Wed, 10 Sep 2025 16:14:51 +0530 Subject: [PATCH 12/12] refactor: Move parameter definitions to constants for RCA tools --- src/tools/rca-agent-utils/constants.ts | 37 ++++++++++++++++++++++++++ src/tools/rca-agent.ts | 37 +++----------------------- 2 files changed, 41 insertions(+), 33 deletions(-) create mode 100644 src/tools/rca-agent-utils/constants.ts diff --git a/src/tools/rca-agent-utils/constants.ts b/src/tools/rca-agent-utils/constants.ts new file mode 100644 index 00000000..0ff64d9e --- /dev/null +++ b/src/tools/rca-agent-utils/constants.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; +import { TestStatus } from "./types.js"; + +export const FETCH_RCA_PARAMS = { + testId: z + .array(z.string()) + .max(3) + .describe( + "Array of test IDs to fetch RCA data for (maximum 3 IDs). If not provided, use the listTestIds tool get all failed testcases. If more than 3 IDs are provided, only the first 3 will be processed." + ), +}; + +export const GET_BUILD_ID_PARAMS = { + projectName: z + .string() + .describe( + "The Browserstack project name used while creation of test run. Check browserstack.yml or similar project configuration files. If found extract it and provide to user, IF not found or unsure, prompt the user for this value. Do not make assumptions" + ), + buildName: z + .string() + .describe( + "The Browserstack build name used while creation of test run. Check browserstack.yml or similar project configuration files. If found extract it and provide to user, IF not found or unsure, prompt the user for this value. Do not make assumptions" + ), +}; + +export const LIST_TEST_IDS_PARAMS = { + buildId: z + .string() + .describe( + "The Browserstack Build ID of the test run. If not known, use the getBuildId tool to fetch it using project and build name" + ), + status: z + .nativeEnum(TestStatus) + .describe( + "Filter tests by status. If not provided, all tests are returned. Example for RCA usecase always use failed status" + ), +}; diff --git a/src/tools/rca-agent.ts b/src/tools/rca-agent.ts index 52770a38..3b723b85 100644 --- a/src/tools/rca-agent.ts +++ b/src/tools/rca-agent.ts @@ -1,5 +1,5 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; +import { FETCH_RCA_PARAMS, GET_BUILD_ID_PARAMS, LIST_TEST_IDS_PARAMS } from "./rca-agent-utils/constants.js"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import logger from "../logger.js"; import { BrowserStackConfig } from "../lib/types.js"; @@ -127,14 +127,7 @@ export default function addRCATools( tools.fetchRCA = server.tool( "fetchRCA", "Retrieves AI-RCA (Root Cause Analysis) data for a BrowserStack Automate and App-Automate session and provides insights into test failures.", - { - testId: z - .array(z.string()) - .max(3) - .describe( - "Array of test IDs to fetch RCA data for (maximum 3 IDs). If not provided, use the listTestIds tool get all failed testcases. If more than 3 IDs are provided, only the first 3 will be processed.", - ), - }, + FETCH_RCA_PARAMS, async (args) => { try { return await fetchRCADataTool(args, config); @@ -156,18 +149,7 @@ export default function addRCATools( tools.getBuildId = server.tool( "getBuildId", "Get the BrowserStack build ID for a given project and build name.", - { - projectName: z - .string() - .describe( - "The Browserstack project name used while creation of test run. Check browserstack.yml or similar project configuration files. If found extract it and provide to user, IF not found or unsure, prompt the user for this value. Do not make assumptions", - ), - buildName: z - .string() - .describe( - "The Browserstack build name used while creation of test run. Check browserstack.yml or similar project configuration files. If found extract it and provide to user, IF not found or unsure, prompt the user for this value. Do not make assumptions", - ), - }, + GET_BUILD_ID_PARAMS, async (args) => { try { return await getBuildIdTool(args, config); @@ -189,18 +171,7 @@ export default function addRCATools( tools.listTestIds = server.tool( "listTestIds", "List test IDs from a BrowserStack Automate build, optionally filtered by status", - { - buildId: z - .string() - .describe( - "The Browserstack Build ID of the test run. If not known, use the getBuildId tool to fetch it using project and build name", - ), - status: z - .nativeEnum(TestStatus) - .describe( - "Filter tests by status. If not provided, all tests are returned. Example for RCA usecase always use failed status", - ), - }, + LIST_TEST_IDS_PARAMS, async (args) => { try { return await listTestIdsTool(args, config);