Skip to content
Merged
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
92 changes: 85 additions & 7 deletions src/tools/accessibility.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,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 {
AccessibilityScanner,
type ScanSettings,
} from "./accessiblity-utils/scanner.js";
import {
AccessibilityReportFetcher,
type IssueSource,
Expand Down Expand Up @@ -416,13 +419,61 @@ async function fetchAccessibilityIssues(
return createSuccessResponse(messages);
}

/** The WCAG versions and levels Website Scanner checks against, and RGAA 4.1, by name. */
const WCAG_VERSIONS = {
wcag2a: "WCAG 2.0 A",
wcag2aa: "WCAG 2.0 AA",
wcag2aaa: "WCAG 2.0 AAA",
wcag21a: "WCAG 2.1 A",
wcag21aa: "WCAG 2.1 AA",
wcag21aaa: "WCAG 2.1 AAA",
wcag22a: "WCAG 2.2 A",
wcag22aa: "WCAG 2.2 AA",
wcag22aaa: "WCAG 2.2 AAA",
rgaa41: "RGAA 4.1",
} as const;

/**
* What the scan ran with, read back from the run, since an unset setting takes the API's
* default and the plan can turn advanced rules off. When it cannot be read, only what was
* asked for is said: a guessed default would be stated as fact.
*/
function scanSettingsNote(
asked: ScanSettings,
applied: ScanSettings | undefined,
): string {
const wcagName = (value: string) =>
WCAG_VERSIONS[value as keyof typeof WCAG_VERSIONS] ?? value;
const onOff = (value: boolean) => (value ? "on" : "off");
const describe = (settings: ScanSettings) =>
[
settings.wcagVersion !== undefined ? wcagName(settings.wcagVersion) : "",
settings.bestPractices !== undefined
? `best practices ${onOff(settings.bestPractices)}`
: "",
settings.needsReview !== undefined
? `needs review ${onOff(settings.needsReview)}`
: "",
settings.advancedRules !== undefined
? `advanced rules ${onOff(settings.advancedRules)}`
: "",
]
.filter(Boolean)
.join(", ");
if (applied && describe(applied)) {
return `Scan settings, as the scan ran: ${describe(applied)}.`;
}
return describe(asked)
? `Scan settings asked for: ${describe(asked)}; anything not asked for takes the scan's default.`
: "No scan settings were asked for, so the scan's defaults apply.";
}

async function executeAccessibilityScan(
args: {
name: string;
pageURL: string;
authConfigId?: number;
advancedRules?: boolean;
},
} & ScanSettings,
context: ScanProgressContext,
server: McpServer,
config: BrowserStackConfig,
Expand All @@ -440,7 +491,12 @@ async function executeAccessibilityScan(
context,
config,
args.authConfigId,
args.advancedRules,
{
wcagVersion: args.wcagVersion,
bestPractices: args.bestPractices,
needsReview: args.needsReview,
advancedRules: args.advancedRules,
},
);
} catch (error) {
return handleMCPError("startAccessibilityScan", server, config, error);
Expand Down Expand Up @@ -583,10 +639,13 @@ function createScanSuccessResponse(
reportUrl: string,
cursor: number | null,
duplicates: string[] = [],
settings: ScanSettings = {},
applied?: ScanSettings,
): 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}`,
scanSettingsNote(settings, applied),
`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,
Expand All @@ -608,15 +667,15 @@ async function runAccessibilityScan(
context: ScanProgressContext,
config: BrowserStackConfig,
authConfigId?: number,
advancedRules?: boolean,
settings: ScanSettings = {},
): Promise<CallToolResult> {
const scanner = await initializeScanner(config);

const startResp = await scanner.startScan(
name,
[pageURL],
authConfigId,
advancedRules,
settings,
);
const scanId = startResp.data!.id;
const scanRunId = startResp.data!.scanRunId;
Expand All @@ -628,6 +687,7 @@ async function runAccessibilityScan(
return createScanFailureResponse(name, status);
}

const applied = await scanner.runSettings(scanId, scanRunId);
const reportFetcher = await initializeReportFetcher(config);
const reportLink = await reportFetcher.getReportLink({
kind: "scan",
Expand Down Expand Up @@ -655,6 +715,8 @@ async function runAccessibilityScan(
reportLink,
next_page,
excludedIssuesNote(duplicate_issues, unlinked_duplicates, hidden_issues),
settings,
applied,
);
}

Expand Down Expand Up @@ -699,7 +761,23 @@ export default function addAccessibilityTools(
advancedRules: z
.boolean()
.optional()
.describe("Enable advanced accessibility rules in the scan settings"),
.describe("Run the advanced accessibility rules (default true)."),
wcagVersion: z
.enum(Object.keys(WCAG_VERSIONS) as [keyof typeof WCAG_VERSIONS])
.optional()
.describe(
'WCAG version and level to check against, e.g. "wcag22aa", or "rgaa41" for RGAA 4.1 (default "wcag21aa").',
),
bestPractices: z
.boolean()
.optional()
.describe("Also report best-practice issues (default false)."),
needsReview: z
.boolean()
.optional()
.describe(
'Also report "needs review" issues to check by hand (default true).',
),
},
{
title: "Start Accessibility Scan",
Expand Down
81 changes: 69 additions & 12 deletions src/tools/accessiblity-utils/scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,21 @@ export interface AccessibilityScanStatus {
errors?: string[];
}

/**
* What a Website Scanner scan checks for, as the create-scan API takes it under
* scanSettings. A setting left out takes the API's default.
*/
export interface ScanSettings {
/** The WCAG version and level, such as "wcag22aa", or "rgaa41" (API default "wcag21aa"). */
wcagVersion?: string;
/** Report best-practice issues too (API default false). */
bestPractices?: boolean;
/** Report issues that need a manual review (API default true). */
needsReview?: boolean;
/** Run the advanced rules (API default true). */
advancedRules?: boolean;
}

export class AccessibilityScanner {
private auth: { username: string; password: string } | undefined;

Expand All @@ -31,7 +46,7 @@ export class AccessibilityScanner {
name: string,
urlList: string[],
authConfigId?: number,
advancedRules?: boolean,
settings: ScanSettings = {},
): Promise<AccessibilityScanResponse> {
if (!this.auth?.username || !this.auth?.password) {
throw new Error(
Expand Down Expand Up @@ -80,14 +95,15 @@ export class AccessibilityScanner {
}
});

const scanSettings = Object.fromEntries(
Object.entries(settings).filter(([, value]) => value !== undefined),
);
const baseRequestBody = {
name,
urlList: transformedUrlList,
recurring: false,
...(authConfigId && { authConfigId }),
...(advancedRules !== undefined && {
scanSettings: { advancedRules },
}),
...(Object.keys(scanSettings).length > 0 && { scanSettings }),
};

let requestBody = baseRequestBody;
Expand Down Expand Up @@ -121,21 +137,62 @@ export class AccessibilityScanner {
);
return data;
} catch (err: any) {
// apiClient throws generic errors, try to extract message
if (err?.response?.status === 422) {
// apiClient throws generic errors, try to extract message. A 422 also refuses a
// setting (a WCAG version the API does not take), so its own reason comes first.
const data = err?.response?.data;
const reason =
data?.error ||
data?.message ||
(Array.isArray(data?.errors) ? data.errors.join(", ") : undefined);
if (err?.response?.status === 422 && !reason) {
throw new Error(
"A scan with this name already exists. please update the name and run again.",
);
}
const msg =
err?.response?.data?.error ||
err?.response?.data?.message ||
err?.message ||
String(err);
throw new Error(`Failed to start scan: ${msg}`);
throw new Error(
`Failed to start scan: ${reason || err?.message || String(err)}`,
);
}
}

/**
* The settings a run actually ran with, from its summary, or undefined when it cannot be
* read. A setting left out takes the API's default, and the plan can turn advanced rules
* off, so what was asked for is not always what ran.
*/
async runSettings(
scanId: string,
scanRunId: string,
): Promise<ScanSettings | undefined> {
const resp = await apiClient
.get({
url: `https://api-accessibility.browserstack.com/api/website-scanner/v1/scans/${scanId}/scan_runs/${scanRunId}`,
headers: {
Authorization:
"Basic " +
Buffer.from(
`${this.auth?.username}:${this.auth?.password}`,
).toString("base64"),
},
raise_error: false,
})
.catch(() => undefined);
const run = resp?.ok ? resp.data?.data : undefined;
if (!run) return undefined;
return {
...(run.wcagVersion ? { wcagVersion: run.wcagVersion } : {}),
...(typeof run.bestPractices === "boolean"
? { bestPractices: run.bestPractices }
: {}),
...(typeof run.needsReview === "boolean"
? { needsReview: run.needsReview }
: {}),
...(typeof run.advanced === "boolean"
? { advancedRules: run.advanced }
: {}),
};
}

async pollStatus(
scanId: string,
scanRunId: string,
Expand Down
1 change: 1 addition & 0 deletions tests/tools/accessibilityScanOutput.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ vi.mock("../../src/tools/accessiblity-utils/scanner.js", () => ({
setAuth = vi.fn();
startScan = m.startScan;
waitUntilComplete = m.waitUntilComplete;
runSettings = vi.fn().mockResolvedValue(undefined);
},
}));
vi.mock("../../src/tools/accessiblity-utils/report-fetcher.js", () => ({
Expand Down
Loading
Loading