Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
276 changes: 253 additions & 23 deletions src/tools/accessibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { AccessibilityScanner } from "./accessiblity-utils/scanner.js";
import { AccessibilityReportFetcher } from "./accessiblity-utils/report-fetcher.js";
import {
AccessibilityReportFetcher,
type IssueSource,
} from "./accessiblity-utils/report-fetcher.js";
import {
AccessibilityAuthConfig,
safeAuthConfigData,
Expand Down Expand Up @@ -68,11 +71,15 @@ function createErrorResponse(message: string): CallToolResult {
};
}

/**
* Each message ends with a newline: clients that join text blocks with no separator
* would otherwise run a link at the end of one block into the next block's first word.
*/
function createSuccessResponse(messages: string[]): CallToolResult {
return {
content: messages.map((text) => ({
type: "text" as const,
text,
text: `${text}\n`,
})),
};
}
Expand Down Expand Up @@ -142,8 +149,67 @@ async function executeAccessibilityRAG(
}
}

interface FetchIssuesArgs {
scanId?: string;
scanRunId?: string;
buildId?: string;
workflowReportId?: string;
reportUrl?: string;
cursor?: number;
}

/** A source named by its ids, or a dashboard link still to be resolved to them. */
type RequestedSource = IssueSource | { kind: "link"; reportUrl: string };

/** The one source the arguments name, or why they do not name exactly one. */
function issueSourceFrom(args: FetchIssuesArgs): RequestedSource | string {
const named = [
Boolean(args.scanId || args.scanRunId),
Boolean(args.buildId),
Boolean(args.workflowReportId),
Boolean(args.reportUrl),
].filter(Boolean).length;
if (named !== 1) {
return "Pass exactly one source: scanId with scanRunId (a Website Scanner run), buildId (an Automated Tests build), workflowReportId (a Workflow Analyzer report) or reportUrl (a report's dashboard link).";
}
if (args.reportUrl) return { kind: "link", reportUrl: args.reportUrl };
if (args.buildId) return { kind: "build", buildId: args.buildId };
if (args.workflowReportId) {
return { kind: "workflow", reportId: args.workflowReportId };
}
if (!args.scanId || !args.scanRunId) {
return "A Website Scanner run needs both scanId and scanRunId.";
}
return { kind: "scan", scanId: args.scanId, scanRunId: args.scanRunId };
}

/** The arguments that name the same source again, for the next-page hint. */
function sourceArgs(source: IssueSource): string {
switch (source.kind) {
case "scan":
return `scanId: "${source.scanId}", scanRunId: "${source.scanRunId}"`;
case "build":
return `buildId: "${source.buildId}"`;
case "workflow":
return `workflowReportId: "${source.reportId}"`;
}
}

/** What a dashboard link resolved to, in words. */
function describeSource(source: IssueSource): string {
switch (source.kind) {
case "scan":
return `Website Scanner scan ${source.scanId}, run ${source.scanRunId}`;
case "build":
return `Automated Tests build ${source.buildId}`;
case "workflow":
return `Workflow Analyzer report ${source.reportId}`;
}
}

async function executeFetchAccessibilityIssues(
args: { scanId: string; scanRunId: string; cursor?: number },
args: FetchIssuesArgs,
context: ScanProgressContext | undefined,
server: McpServer,
config: BrowserStackConfig,
): Promise<CallToolResult> {
Expand All @@ -154,28 +220,142 @@ async function executeFetchAccessibilityIssues(
undefined,
config,
);
return await fetchAccessibilityIssues(
args.scanId,
args.scanRunId,
config,
args.cursor,
);
const source = issueSourceFrom(args);
if (typeof source === "string") return createErrorResponse(source);
return await fetchAccessibilityIssues(source, config, args.cursor, context);
} catch (error) {
return handleMCPError("fetchAccessibilityIssues", server, config, error);
}
}

/** How grouped reports' duplicates were handled, or nothing for an ungrouped report. */
function duplicatesNote(duplicates: number | null, unlinked: number): string[] {
if (!duplicates) return [];
const unmatched =
unlinked > 0
? ` ${unlinked} of them could not be matched to a main issue and are counted only here.`
: "";
return [
`This report groups repeated occurrences: ${duplicates} duplicate occurrences are folded into their main issue (see duplicate_count) and are not included in the total.${unmatched}`,
];
}

/** How often, and for how long, a first read waits on a report that is still processing. */
export const REPORT_STATUS_POLL_MS = 10_000;
export const REPORT_STATUS_WAIT_MS = 5 * 60 * 1000;

/** Statuses during which a report's issues are still being added. */
const PROCESSING_STATUSES = new Set([
"queued",
"running",
"processing",
"finalizing",
]);

interface ProcessingState {
/** The status when the issues were read; undefined when it could not be read. */
status?: string;
/** The status first seen, before any waiting. */
firstStatus?: string;
waitedMs: number;
}

/**
* Where a report is in its processing before its issues are read. Issues keep being added
* until it completes (an advanced scan adds its advanced-rule issues while "finalizing", for
* minutes after the tests end), so a first read waits for it, reporting progress, for up to
* REPORT_STATUS_WAIT_MS. A later page does not wait again, and a status that cannot be read
* is not waited on.
*/
async function waitForProcessing(
reportFetcher: AccessibilityReportFetcher,
source: IssueSource,
wait: boolean,
context: ScanProgressContext | undefined,
): Promise<ProcessingState> {
const started = Date.now();
const firstStatus = await reportFetcher.reportStatus(source);
let status = firstStatus;
while (
wait &&
status &&
PROCESSING_STATUSES.has(status) &&
Date.now() - started < REPORT_STATUS_WAIT_MS
) {
const waited = Date.now() - started;
if (context) {
await notifyScanProgress(
context,
`The report is still ${status}; waiting for it to complete (${formatDuration(waited)} of up to ${formatDuration(REPORT_STATUS_WAIT_MS)}).`,
Math.min(99, Math.round((waited / REPORT_STATUS_WAIT_MS) * 100)),
);
}
await new Promise((resolve) => setTimeout(resolve, REPORT_STATUS_POLL_MS));
status = await reportFetcher.reportStatus(source);
}
return { status, firstStatus, waitedMs: Date.now() - started };
}

function formatDuration(ms: number): string {
const seconds = Math.round(ms / 1000);
return seconds < 60
? `${seconds}s`
: `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`;
}

/** What the answer says about the report's processing, if anything. */
function processingNote({
status,
firstStatus,
waitedMs,
}: ProcessingState): string[] {
if (status && PROCESSING_STATUSES.has(status)) {
const meaning =
status === "finalizing"
? ", which on an advanced scan means advanced issue processing"
: "";
return [
`⚠️ This report is still processing (status: ${status}${meaning}). The issues below are only those found so far: more will be added and the total will grow. Call fetchAccessibilityIssues again in a few minutes for the complete list.`,
];
}
if (firstStatus && PROCESSING_STATUSES.has(firstStatus)) {
return [
`The report was still processing (status: ${firstStatus}); waited ${formatDuration(waitedMs)} for it to complete before reading its issues.`,
];
}
return [];
}

async function fetchAccessibilityIssues(
scanId: string,
scanRunId: string,
requested: RequestedSource,
config: BrowserStackConfig,
cursor = 0,
context?: ScanProgressContext,
): Promise<CallToolResult> {
const reportFetcher = await initializeReportFetcher(config);
const reportLink = await reportFetcher.getReportLink(scanId, scanRunId);
const source =
requested.kind === "link"
? await reportFetcher.sourceFromReportUrl(requested.reportUrl)
: requested;
const processing = await waitForProcessing(
reportFetcher,
source,
cursor === 0,
context,
);
const stillProcessing =
processing.status !== undefined &&
PROCESSING_STATUSES.has(processing.status);
const reportLink = await reportFetcher.getReportLink(source);

const { records, page_length, total_issues, next_page } =
await parseAccessibilityReportFromCSV(reportLink, { nextPage: cursor });
const {
records,
page_length,
total_issues,
next_page,
duplicate_issues,
unlinked_duplicates,
} = await parseAccessibilityReportFromCSV(reportLink, { nextPage: cursor });

const currentlyShown =
cursor === 0
Expand All @@ -185,13 +365,23 @@ async function fetchAccessibilityIssues(
const remainingIssues = total_issues - currentlyShown;

const messages = [
...(requested.kind === "link"
? [`Resolved the report link to ${describeSource(source)}.`]
: []),
...processingNote(processing),
`Retrieved ${page_length} accessibility issues (Total: ${total_issues})`,
...duplicatesNote(duplicate_issues, unlinked_duplicates),
`You can also download the full report (CSV) from the following link, valid for about 12 hours: ${reportLink}`,
`Issues: ${wrapUntrusted("accessibility scan results", JSON.stringify(records, null, 2))}`,
];

if (next_page !== null) {
messages.push(
`${remainingIssues} more issues available. Use fetchAccessibilityIssues with cursor: ${next_page} to get the next batch.`,
`${remainingIssues} more issues available. Use fetchAccessibilityIssues with ${sourceArgs(source)} and cursor: ${next_page} to get the next batch.`,
);
} else if (stillProcessing) {
messages.push(
`All issues found so far retrieved; the report is still processing.`,
);
} else {
messages.push(`✅ All issues retrieved.`);
Expand Down Expand Up @@ -366,12 +556,14 @@ function createScanSuccessResponse(
scanRunId: string,
reportUrl: string,
cursor: number | null,
duplicates: string[] = [],
): CallToolResult {
const messages = [
`Accessibility scan "${name}" completed. check the BrowserStack dashboard for more details [https://scanner.browserstack.com/site-scanner/scan-details/${name}].`,
`Scan ID: ${scanId} and Scan Run ID: ${scanRunId}`,
`You can also download the full report from the following link: ${reportUrl}`,
`We found ${totalIssues} issues. Below are the details of the ${pageLength} most critical issues.`,
...duplicates,
`Scan results: ${wrapUntrusted("accessibility scan results", JSON.stringify(records, null, 2))}`,
];

Expand Down Expand Up @@ -411,10 +603,20 @@ async function runAccessibilityScan(
}

const reportFetcher = await initializeReportFetcher(config);
const reportLink = await reportFetcher.getReportLink(scanId, scanRunId);
const reportLink = await reportFetcher.getReportLink({
kind: "scan",
scanId,
scanRunId,
});

const { records, page_length, total_issues, next_page } =
await parseAccessibilityReportFromCSV(reportLink);
const {
records,
page_length,
total_issues,
next_page,
duplicate_issues,
unlinked_duplicates,
} = await parseAccessibilityReportFromCSV(reportLink);

return createScanSuccessResponse(
name,
Expand All @@ -425,6 +627,7 @@ async function runAccessibilityScan(
scanRunId,
reportLink,
next_page,
duplicatesNote(duplicate_issues, unlinked_duplicates),
);
}

Expand Down Expand Up @@ -596,15 +799,37 @@ export default function addAccessibilityTools(

tools.fetchAccessibilityIssues = server.tool(
"fetchAccessibilityIssues",
"Fetch accessibility issues from a completed scan with pagination support. Use cursor parameter to get subsequent pages of results." +
"Fetch accessibility issues with pagination support, from exactly one source: a completed Website Scanner run (scanId with scanRunId), an Automated Tests build from a BrowserStack SDK test run (buildId), a Workflow Analyzer report (workflowReportId), or a Website Scanner or Automated Tests report's dashboard link (reportUrl). Also returns a link to download the full report as a CSV. Use cursor parameter to get subsequent pages of results." +
NEEDS_A11Y_SCAN_ID,
{
scanId: z
.string()
.describe("The scan ID from a completed accessibility scan"),
.optional()
.describe(
"Website Scanner: the scan ID from a completed accessibility scan",
),
scanRunId: z
.string()
.describe("The scan run ID from a completed accessibility scan"),
.optional()
.describe(
"Website Scanner: the scan run ID from a completed accessibility scan",
),
buildId: z
.string()
.optional()
.describe(
"Automated Tests: the Accessibility build id, shown as thBuildId in the build's accessibility report link (40 lowercase letters and digits). Not the Automate build id or the build name.",
),
workflowReportId: z
.string()
.optional()
.describe("Workflow Analyzer: the report's numeric id, e.g. 123456"),
reportUrl: z
.string()
.optional()
.describe(
"A report's Accessibility dashboard link, as the user pasted it: a Website Scanner or Automated Tests report link carrying thBuildId. A Website Scanner link resolves only to its scan's latest run. Workflow Analyzer links are not supported; use workflowReportId.",
),
cursor: z
.number()
.optional()
Expand All @@ -617,8 +842,13 @@ export default function addAccessibilityTools(
destructiveHint: false,
idempotentHint: true,
},
async (args) => {
return await executeFetchAccessibilityIssues(args, server, config);
async (args, context) => {
return await executeFetchAccessibilityIssues(
args,
context as ScanProgressContext,
server,
config,
);
},
);

Expand Down
Loading