@browserstack/mcp-server 2.2.5-beta.3 → 2.2.6-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.
Files changed (36) hide show
  1. package/README.md +32 -194
  2. package/dist/server-factory.js +1 -6
  3. package/dist/tools/accessibility.js +62 -576
  4. package/dist/tools/accessiblity-utils/accessibility-rag.d.ts +12 -0
  5. package/dist/tools/accessiblity-utils/accessibility-rag.js +59 -0
  6. package/dist/tools/accessiblity-utils/report-fetcher.d.ts +7 -116
  7. package/dist/tools/accessiblity-utils/report-fetcher.js +29 -268
  8. package/dist/tools/accessiblity-utils/report-parser.d.ts +1 -41
  9. package/dist/tools/accessiblity-utils/report-parser.js +43 -142
  10. package/dist/tools/appautomate-utils/appium-sdk/config-generator.d.ts +0 -2
  11. package/dist/tools/appautomate-utils/appium-sdk/config-generator.js +5 -15
  12. package/dist/tools/appautomate-utils/appium-sdk/constants.d.ts +1 -2
  13. package/dist/tools/appautomate-utils/appium-sdk/constants.js +1 -6
  14. package/dist/tools/appautomate-utils/appium-sdk/handler.js +1 -11
  15. package/dist/tools/appautomate-utils/appium-sdk/instructions.d.ts +1 -3
  16. package/dist/tools/appautomate-utils/appium-sdk/instructions.js +2 -2
  17. package/dist/tools/appautomate-utils/appium-sdk/languages/nodejs.d.ts +1 -3
  18. package/dist/tools/appautomate-utils/appium-sdk/languages/nodejs.js +6 -19
  19. package/dist/tools/appautomate-utils/native-execution/appautomate.d.ts +2 -7
  20. package/dist/tools/appautomate-utils/native-execution/appautomate.js +12 -15
  21. package/dist/tools/appautomate-utils/native-execution/constants.d.ts +1 -2
  22. package/dist/tools/appautomate-utils/native-execution/constants.js +1 -6
  23. package/dist/tools/appautomate.js +16 -29
  24. package/dist/tools/testmanagement-utils/create-testrun.d.ts +1 -1
  25. package/dist/tools/testmanagement-utils/update-testrun.d.ts +1 -1
  26. package/dist/tools/tool-handoff.d.ts +2 -3
  27. package/dist/tools/tool-handoff.js +2 -3
  28. package/package.json +1 -1
  29. package/dist/lib/accessibility-routing.d.ts +0 -5
  30. package/dist/lib/accessibility-routing.js +0 -5
  31. package/dist/tools/accessiblity-utils/app-report-fetcher.d.ts +0 -341
  32. package/dist/tools/accessiblity-utils/app-report-fetcher.js +0 -731
  33. package/dist/tools/accessiblity-utils/paginate.d.ts +0 -21
  34. package/dist/tools/accessiblity-utils/paginate.js +0 -44
  35. package/dist/tools/appautomate-utils/appium-sdk/languages/accessibility.d.ts +0 -2
  36. package/dist/tools/appautomate-utils/appium-sdk/languages/accessibility.js +0 -32
@@ -1,11 +1,11 @@
1
1
  import { z } from "zod";
2
2
  import { AccessibilityScanner } from "./accessiblity-utils/scanner.js";
3
- import { AccessibilityReportFetcher, EXPORT_WAIT_MS, ExportPendingError, } from "./accessiblity-utils/report-fetcher.js";
4
- import { compareAppIssues, } from "./accessiblity-utils/app-report-fetcher.js";
3
+ import { AccessibilityReportFetcher, } from "./accessiblity-utils/report-fetcher.js";
5
4
  import { AccessibilityAuthConfig, safeAuthConfigData, } from "./accessiblity-utils/auth-config.js";
6
5
  import { trackMCP } from "../lib/instrumentation.js";
7
- import { compareIssues, loadMainIssues, parseAccessibilityReportFromCSV, } from "./accessiblity-utils/report-parser.js";
6
+ import { parseAccessibilityReportFromCSV } from "./accessiblity-utils/report-parser.js";
8
7
  import { wrapUntrusted } from "../lib/untrusted-content.js";
8
+ import { queryAccessibilityRAG } from "./accessiblity-utils/accessibility-rag.js";
9
9
  import { getBrowserStackAuth } from "../lib/get-auth.js";
10
10
  import { elicitCredentialsIfSupported } from "../lib/elicit-credentials.js";
11
11
  import logger from "../logger.js";
@@ -66,17 +66,25 @@ async function initializeReportFetcher(config) {
66
66
  reportFetcher.setAuth(auth);
67
67
  return reportFetcher;
68
68
  }
69
+ async function executeAccessibilityRAG(args, server, config) {
70
+ try {
71
+ trackMCP("accessibilityExpert", server.server.getClientVersion(), undefined, config);
72
+ return await queryAccessibilityRAG(args.query, config);
73
+ }
74
+ catch (error) {
75
+ return handleMCPError("accessibilityExpert", server, config, error);
76
+ }
77
+ }
69
78
  /** The one source the arguments name, or why they do not name exactly one. */
70
79
  function issueSourceFrom(args) {
71
80
  const named = [
72
81
  Boolean(args.scanId || args.scanRunId),
73
82
  Boolean(args.buildId),
74
83
  Boolean(args.workflowReportId),
75
- Boolean(args.appReportId),
76
84
  Boolean(args.reportUrl),
77
85
  ].filter(Boolean).length;
78
86
  if (named !== 1) {
79
- return "Pass exactly one source: scanId with scanRunId (a Website Scanner run), buildId (an Automated Tests or App Accessibility build), workflowReportId (a Workflow Analyzer report), appReportId (an App Accessibility manual report) or reportUrl (a report's dashboard link).";
87
+ 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).";
80
88
  }
81
89
  if (args.reportUrl)
82
90
  return { kind: "link", reportUrl: args.reportUrl };
@@ -85,9 +93,6 @@ function issueSourceFrom(args) {
85
93
  if (args.workflowReportId) {
86
94
  return { kind: "workflow", reportId: args.workflowReportId };
87
95
  }
88
- if (args.appReportId) {
89
- return { kind: "appReport", reportId: args.appReportId };
90
- }
91
96
  if (!args.scanId || !args.scanRunId) {
92
97
  return "A Website Scanner run needs both scanId and scanRunId.";
93
98
  }
@@ -99,12 +104,9 @@ function sourceArgs(source) {
99
104
  case "scan":
100
105
  return `scanId: "${source.scanId}", scanRunId: "${source.scanRunId}"`;
101
106
  case "build":
102
- case "appBuild":
103
107
  return `buildId: "${source.buildId}"`;
104
108
  case "workflow":
105
109
  return `workflowReportId: "${source.reportId}"`;
106
- case "appReport":
107
- return `appReportId: "${source.reportId}"`;
108
110
  }
109
111
  }
110
112
  /** What a dashboard link resolved to, in words. */
@@ -116,10 +118,6 @@ function describeSource(source) {
116
118
  return `Automated Tests build ${source.buildId}`;
117
119
  case "workflow":
118
120
  return `Workflow Analyzer report ${source.reportId}`;
119
- case "appBuild":
120
- return `App Accessibility build ${source.buildId}`;
121
- case "appReport":
122
- return `App Accessibility manual report ${source.reportId}`;
123
121
  }
124
122
  }
125
123
  async function executeFetchAccessibilityIssues(args, context, server, config) {
@@ -128,11 +126,7 @@ async function executeFetchAccessibilityIssues(args, context, server, config) {
128
126
  const source = issueSourceFrom(args);
129
127
  if (typeof source === "string")
130
128
  return createErrorResponse(source);
131
- return await fetchAccessibilityIssues(source, config, args.cursor, context, {
132
- compareWith: args.compareWith,
133
- pageUrl: args.pageUrl,
134
- exportTaskId: args.exportTaskId,
135
- });
129
+ return await fetchAccessibilityIssues(source, config, args.cursor, context);
136
130
  }
137
131
  catch (error) {
138
132
  return handleMCPError("fetchAccessibilityIssues", server, config, error);
@@ -161,21 +155,19 @@ function excludedIssuesNote(duplicates, unlinked, hidden) {
161
155
  /** How often, and for how long, a first read waits on a report that is still processing. */
162
156
  export const REPORT_STATUS_POLL_MS = 10_000;
163
157
  export const REPORT_STATUS_WAIT_MS = 5 * 60 * 1000;
164
- /** Statuses during which a report's issues are still being added (`rule_processing`: an app build's). */
158
+ /** Statuses during which a report's issues are still being added. */
165
159
  const PROCESSING_STATUSES = new Set([
166
160
  "queued",
167
161
  "running",
168
162
  "processing",
169
163
  "finalizing",
170
- "rule_processing",
171
- "in_progress",
172
164
  ]);
173
165
  /**
174
- * Of those, the ones worth waiting on: the processing after the run (a web scan's advanced
175
- * issues, an app build's rules), which ends within minutes. A report whose scan or tests are still running is read as it is,
166
+ * Of those, the one worth waiting on: the advanced-issue processing after the run, which
167
+ * ends within minutes. A report whose scan or tests are still running is read as it is,
176
168
  * with a note, since that can last as long as the suite does.
177
169
  */
178
- const WAIT_STATUSES = new Set(["finalizing", "rule_processing"]);
170
+ const WAIT_STATUSES = new Set(["finalizing"]);
179
171
  /**
180
172
  * Where a report is in its processing before its issues are read. Issues keep being added
181
173
  * until it completes; an advanced scan adds its advanced-rule issues while "finalizing", for
@@ -223,498 +215,41 @@ function processingNote({ status, firstStatus, waitedMs, }) {
223
215
  }
224
216
  return [];
225
217
  }
226
- /** Progress while a report's CSV export is generated, so clients see the call is alive. */
227
- function exportProgress(context) {
228
- return async (waitedMs) => {
229
- if (!context)
230
- return;
231
- await notifyScanProgress(context, `Generating the report's CSV export (large reports take longer): ${formatDuration(waitedMs)} so far.`, Math.min(99, Math.round((waitedMs / EXPORT_WAIT_MS) * 100)));
232
- };
233
- }
234
- /** What to say of an export still being generated: how to continue it, not an error. */
235
- function exportPendingMessages(source, error, extraArgs) {
236
- return [
237
- `This report's CSV export is still being generated (waited ${formatDuration(error.waitedMs)}; large reports can take a few minutes). No issues have been read yet.`,
238
- `Call fetchAccessibilityIssues again with ${sourceArgs(source)}${extraArgs} and exportTaskId: "${error.taskId}" to continue this export instead of starting a new one.`,
239
- ];
240
- }
241
- /** The last line: how to get the next page, or that there is none. */
242
- function closingMessage(next_page, remaining, source, stillProcessing, extraArgs = "") {
243
- if (next_page !== null) {
244
- return `${remaining} more issues available. Use fetchAccessibilityIssues with ${sourceArgs(source)}${extraArgs} and cursor: ${next_page} to get the next batch.`;
245
- }
246
- if (stillProcessing) {
247
- return `All issues found so far retrieved; the report is still processing.`;
248
- }
249
- return `✅ All issues retrieved.`;
250
- }
251
- /**
252
- * An app build's name and dashboard link, as data: the name is the user's own (from their
253
- * CI or browserstack.yml), so it is quoted in an untrusted block rather than in our prose.
254
- */
255
- function appBuildDetails(report) {
256
- return `Build: ${wrapUntrusted("App Accessibility build", JSON.stringify({
257
- name: report.build_name,
258
- ...(report.dashboard_link
259
- ? { dashboard_link: report.dashboard_link }
260
- : {}),
261
- }, null, 2))}`;
262
- }
263
- /**
264
- * Why a native (Espresso, XCUITest) build recorded no scans. The build request's flag only
265
- * turns the checks on: the suite itself must carry BrowserStack's instrumentation.
266
- */
267
- const NATIVE_NO_SCAN_CAUSES = {
268
- espresso: [
269
- "The test suite was not built with BrowserStack's Gradle plugin and SDK: add classpath 'com.browserstack:gradle-tool:latest.release' to the root build.gradle, id 'com.browserstack.gradle-tool' to the app's plugins, and androidTestImplementation 'com.browserstack:browserstack-java-sdk:latest.release' (1.31.0 or newer), then rebuild the test APK.",
270
- "Test Observability was off for the build: Espresso's App Accessibility report needs `testObservability: true` in the build request alongside `accessibility: true` (runAppTestsOnBrowserStack sends both).",
271
- "The app cannot reach BrowserStack from the device: it needs internet permission, and cleartext traffic allowed (usesCleartextTraffic) if the network calls for it.",
272
- "No tests ran: check the build's sessions and test results on App Automate.",
273
- ],
274
- xcuitest: [
275
- "The UI test target does not embed and initialize the BrowserStack Swift SDK: add BrowserstackSwiftSDK.xcframework (or the Swift package) to the test target, and set NSPrincipalClass to BrowserstackSwiftSDK.BrowserstackSwiftSDK in its Info.plist or call BrowserstackSwiftSDK.init() in the test class's init().",
276
- "The device runs iOS older than 14, or the suite was built with Swift older than 5.4.",
277
- "No tests ran: check the build's sessions and test results on App Automate.",
278
- ],
279
- };
280
- /** Which native framework ran a build, when its details name one. */
281
- function nativeFramework(framework) {
282
- if (!framework)
283
- return undefined;
284
- if (/espresso/i.test(framework))
285
- return "espresso";
286
- if (/xcui|xctest/i.test(framework))
287
- return "xcuitest";
288
- return undefined;
289
- }
290
- /**
291
- * Why a finished app build has no scans. Each cause here produced a run that finished
292
- * green with nothing scanned and no warning; they are listed most likely first, with what
293
- * the build's own details say ahead of the rest. A native build gets the native causes.
294
- */
295
- function noScansNote(report) {
296
- const native = nativeFramework(report.framework);
297
- if (native) {
298
- const causes = [
299
- ...(report.fup_capped
300
- ? [
301
- "This build hit the fair-usage cap (fup in the build details), which drops tests or scans.",
302
- ]
303
- : []),
304
- ...NATIVE_NO_SCAN_CAUSES[native],
305
- ];
306
- return [
307
- `No accessibility scans were recorded for the ${native === "espresso" ? "Espresso" : "XCUITest"} build below (status: ${report.status}), so there are no issues to list.`,
308
- appBuildDetails(report),
309
- "The usual causes for a native build, most likely first:",
310
- ...causes.map((cause, i) => `${i + 1}. ${cause}`),
311
- ];
312
- }
313
- const causes = [
314
- ...(report.fup_capped
315
- ? [
316
- "This build hit the fair-usage cap (fup in the build details), which drops tests or scans.",
317
- ]
318
- : []),
319
- ...(report.status === "timeout"
320
- ? [
321
- 'The session timed out during initialization (status "timeout"): check the script and the config.',
322
- ]
323
- : []),
324
- "`accessibility: true` is not set in browserstack.yml (or the capabilities); the SDK defaults it to false.",
325
- 'The platform entry in browserstack.yml does not use `osVersion`, unquoted: with `platformVersion` the SDK selects the device but App Accessibility records no scans, and a quoted "14.0" fails its number check and turns the checks off; neither says so. Write `osVersion: 14.0`.',
326
- 'The device runs an OS App Accessibility does not support: Android 11 or newer, or iOS 14 or newer, is needed (an older Android reads "update_os_version").',
327
- "The driver was created inside the test body: the SDK decides per test, at its start, from the drivers that already exist. Create it in a fixture or setup hook.",
328
- "The test only read the screen (find_elements, page_source): scans run on interaction commands such as click/tap, send keys, actions and navigation. Interact with each screen you want scanned.",
329
- ...(report.status === "timeout"
330
- ? []
331
- : [
332
- 'The session timed out during initialization (status "timeout"): check the script and the config.',
333
- ]),
334
- ];
335
- return [
336
- `No accessibility scans were recorded for the App Accessibility build below (status: ${report.status}), so there are no issues to list.`,
337
- appBuildDetails(report),
338
- "The usual causes, most likely first:",
339
- ...causes.map((cause, i) => `${i + 1}. ${cause}`),
340
- ];
341
- }
342
- const SEVERITY_ORDER = ["critical", "serious", "moderate", "minor"];
343
- /** How many screens the summary names; the rest are counted. */
344
- const SUMMARY_SCREENS = 10;
345
- /** An app report's issues at a glance: by severity, by rule, and by screen. */
346
- function summaryNote(summary, total) {
347
- if (total === 0)
348
- return [];
349
- const rank = (severity) => {
350
- const i = SEVERITY_ORDER.indexOf(severity);
351
- return i === -1 ? SEVERITY_ORDER.length : i;
352
- };
353
- const severities = Object.entries(summary.by_severity)
354
- .sort(([a], [b]) => rank(a) - rank(b))
355
- .map(([severity, n]) => `${n} ${severity}`)
356
- .join(", ");
357
- const rules = summary.by_rule
358
- .map((r) => `${r.issue_type} ${r.count}`)
359
- .join(", ");
360
- const shown = summary.by_screen.slice(0, SUMMARY_SCREENS);
361
- const more = summary.by_screen.length - shown.length;
362
- const screens = `${shown.map((s) => `${s.screen} ${s.count}`).join(", ")}${more > 0 ? `, and ${more} more screens` : ""}`;
363
- return [
364
- `Summary of the ${total} issues: ${severities}.`,
365
- wrapUntrusted("accessibility issue counts", `By rule: ${rules}.\nBy screen: ${screens}.`),
366
- ];
367
- }
368
- /** How many new and resolved issues a comparison lists per page. */
369
- const COMPARE_LIST_MAX = 50;
370
- /** An issue as a comparison lists it: what and where, without the fix text. */
371
- function compareRow(row) {
372
- const { issue_type, rule_id, severity, screen, component, element } = row;
373
- return { issue_type, rule_id, severity, screen, component, element };
374
- }
375
- /** One page of a comparison's list: the cursor counts the issues already listed. */
376
- function issueList(label, rows, cursor) {
377
- if (cursor > 0 && cursor >= rows.length) {
378
- return `${label} (${rows.length}): all listed on earlier pages.`;
379
- }
380
- const shown = rows.slice(cursor, cursor + COMPARE_LIST_MAX).map(compareRow);
381
- const range = rows.length > COMPARE_LIST_MAX
382
- ? `, ${cursor + 1} to ${cursor + shown.length}`
383
- : "";
384
- return `${label} (${rows.length})${range}: ${wrapUntrusted("accessibility scan results", JSON.stringify(shown, null, 2))}`;
385
- }
386
- /** The build to compare with, or why there is none. */
387
- async function baselineAppBuild(reportFetcher, buildId, compareWith) {
388
- let baseline;
389
- if (compareWith === "previous") {
390
- const previous = await reportFetcher.previousAppBuild(buildId);
391
- if (!previous) {
392
- return "This is its project's first build, so there is nothing earlier to compare it with. Pass an earlier build's id or link as compareWith instead.";
393
- }
394
- baseline = {
395
- buildId: previous.buildId,
396
- sameName: previous.same_name,
397
- searchedAll: previous.searched_all,
398
- };
399
- }
400
- else {
401
- const named = /^https?:\/\//i.test(compareWith)
402
- ? await reportFetcher.sourceFromReportUrl(compareWith)
403
- : { kind: "build", buildId: compareWith };
404
- const source = await reportFetcher.resolveBuild(named);
405
- if (source.kind !== "appBuild") {
406
- return `compareWith names ${describeSource(source)}, which is not an App Accessibility build. Pass an earlier app build's id or link, or "previous".`;
407
- }
408
- baseline = { buildId: source.buildId };
409
- }
410
- if (baseline.buildId === buildId) {
411
- return `compareWith names this build itself. Pass an earlier build's id or link, or "previous".`;
412
- }
413
- return baseline;
414
- }
415
- /**
416
- * What changed between an earlier app build and this one: issues new in this build,
417
- * issues the earlier one had that this one does not, and how many it kept. An issue on a
418
- * screen this build did not reach also reads as resolved, so a build that scanned other
419
- * screens or ran other tests is called out.
420
- */
421
- async function compareAppBuilds(reportFetcher, source, compareWith, cursor, preface, processing) {
422
- const baseline = await baselineAppBuild(reportFetcher, source.buildId, compareWith);
423
- if (typeof baseline === "string")
424
- return createErrorResponse(baseline);
425
- const [named, current] = await Promise.all([
426
- reportFetcher.appBuildIssues(baseline.buildId),
427
- reportFetcher.appBuildIssues(source.buildId),
428
- ]);
429
- // A comparison runs from the earlier build to the later; named the other way round, the
430
- // new issues would read as resolved and the resolved as new.
431
- const reversed = named.created_at !== undefined &&
432
- current.created_at !== undefined &&
433
- Date.parse(named.created_at) > Date.parse(current.created_at);
434
- const [before, after] = reversed ? [current, named] : [named, current];
435
- const { added, resolved, retained } = compareAppIssues(before.issues, after.issues);
436
- const which = baseline.sameName === undefined
437
- ? ""
438
- : baseline.sameName
439
- ? ", the build before it"
440
- : baseline.searchedAll
441
- ? ", the build just before it in its project (no earlier build has this build's name)"
442
- : ", the build just before it in its project (none of the newest builds before it has this build's name)";
443
- const warnings = [];
444
- if (before.scan_count !== after.scan_count) {
445
- warnings.push(`⚠️ The builds scanned a different number of screens (${after.scan_count} in the later build, ${before.scan_count} in the earlier one): an issue on a screen the later build did not reach reads as resolved without having been fixed. Check the resolved issues' screens before calling them fixed.`);
446
- }
447
- const notRun = before.tests.filter((t) => !after.tests.includes(t));
448
- if (notRun.length > 0) {
449
- warnings.push(`⚠️ Some tests had screens scanned in the earlier build but not in the later one; their issues read as resolved without having been checked again. ${wrapUntrusted("test names", JSON.stringify(notRun))}`);
450
- }
451
- // Build names are the user's own (from their CI or browserstack.yml), so they are data.
452
- const builds = wrapUntrusted("App Accessibility builds", JSON.stringify({
453
- later: { build_number: after.build_number, name: after.build_name },
454
- earlier: { build_number: before.build_number, name: before.build_name },
455
- }, null, 2));
456
- return createSuccessResponse([
457
- ...preface,
458
- ...processingNote(processing),
459
- ...(reversed
460
- ? [
461
- "compareWith names a build newer than this one, so the comparison runs from this build (the earlier) to that one (the later).",
462
- ]
463
- : []),
464
- `Compared App Accessibility build #${after.build_number} with build #${before.build_number}${which}: ${added.length} new, ${resolved.length} resolved, ${retained} still open.`,
465
- `Builds: ${builds}`,
466
- `Issues are matched one for one by rule and element: its id, or else its class and its text or label. New and still-open issues add up to the later build's ${after.issues.length}; resolved and still-open ones to the earlier build's ${before.issues.length}.`,
467
- ...warnings,
468
- issueList("New issues", added, cursor),
469
- issueList("Resolved issues", resolved, cursor),
470
- Math.max(added.length, resolved.length) > cursor + COMPARE_LIST_MAX
471
- ? `More to list: call fetchAccessibilityIssues with buildId: "${source.buildId}", compareWith: "${compareWith}" and cursor: ${cursor + COMPARE_LIST_MAX}.`
472
- : `Read the later build's open issues with fetchAccessibilityIssues with buildId: "${after.buildId}".`,
473
- ]);
474
- }
475
- /** An app build's issues come inline from its scanned screens; there is no CSV to link. */
476
- async function appBuildMessages(reportFetcher, source, cursor, preface, processing, stillProcessing) {
477
- const report = await reportFetcher.appBuildReport(source.buildId, cursor);
478
- if (report.scan_count === 0 && !stillProcessing) {
479
- return [...preface, ...processingNote(processing), ...noScansNote(report)];
480
- }
481
- const { records, page_length, total_issues, next_page } = report;
482
- const screens = `${report.scan_count} screen${report.scan_count === 1 ? "" : "s"} scanned`;
483
- return [
484
- ...preface,
485
- ...processingNote(processing),
486
- `Retrieved ${page_length} accessibility issues (Total: ${total_issues}) from the App Accessibility build below (${screens})`,
487
- appBuildDetails(report),
488
- ...(cursor === 0 ? summaryNote(report.summary, total_issues) : []),
489
- ...excludedIssuesNote(null, 0, report.hidden_issues),
490
- `Issues: ${wrapUntrusted("accessibility scan results", JSON.stringify(records, null, 2))}`,
491
- closingMessage(next_page, report.remaining, source, stillProcessing),
492
- ];
493
- }
494
- /**
495
- * A manual report's issues come inline from its scanned screens. The API lists every
496
- * occurrence and does not mark the duplicates the dashboard folds, so the dashboard's
497
- * own count is stated next to the total rather than reproduced.
498
- */
499
- async function appReportMessages(reportFetcher, source, cursor, preface, processing, stillProcessing) {
500
- const report = await reportFetcher.appReportIssues(source.reportId, cursor);
501
- // The report's name is whatever its author typed, so it is quoted as data.
502
- const details = `Report: ${wrapUntrusted("App Accessibility manual report", JSON.stringify({ name: report.report_name, dashboard_link: report.dashboard_link }, null, 2))}`;
503
- if (report.scan_count === 0 && !stillProcessing) {
504
- return [
505
- ...preface,
506
- ...processingNote(processing),
507
- `No screens were scanned in the App Accessibility manual report below (status: ${report.status}), so there are no issues to list.`,
508
- details,
509
- ];
510
- }
511
- const { records, page_length, total_issues, next_page } = report;
512
- const where = `${report.scan_count} screen${report.scan_count === 1 ? "" : "s"} scanned${report.device ? ` on ${report.device}` : ""}`;
513
- const dashboardNote = report.duplicate_occurrences > 0
514
- ? [
515
- `The dashboard counts ${report.dashboard_count} issues for this report: ${report.duplicate_occurrences} of the occurrences are duplicates it folds, which the API does not mark.`,
516
- ]
517
- : [];
518
- return [
519
- ...preface,
520
- ...processingNote(processing),
521
- `Retrieved ${page_length} accessibility issue occurrences (Total: ${total_issues}) from the App Accessibility manual report below (${where})`,
522
- details,
523
- ...(cursor === 0 ? summaryNote(report.summary, total_issues) : []),
524
- ...dashboardNote,
525
- ...excludedIssuesNote(null, 0, report.hidden_issues),
526
- `Issues: ${wrapUntrusted("accessibility scan results", JSON.stringify(records, null, 2))}`,
527
- closingMessage(next_page, report.remaining, source, stillProcessing),
528
- ];
529
- }
530
- async function fetchAccessibilityIssues(requested, config, cursor = 0, context, { compareWith, pageUrl, exportTaskId, } = {}) {
218
+ async function fetchAccessibilityIssues(requested, config, cursor = 0, context) {
531
219
  const reportFetcher = await initializeReportFetcher(config);
532
- const named = requested.kind === "link"
220
+ const source = requested.kind === "link"
533
221
  ? await reportFetcher.sourceFromReportUrl(requested.reportUrl)
534
222
  : requested;
535
- // A build id names a web build or a mobile app build; the APIs tell which.
536
- const source = await reportFetcher.resolveBuild(named);
537
- // Website Scanner runs and App Accessibility builds can be compared, each in its own way.
538
- if (compareWith !== undefined && source.kind === "scan") {
539
- return compareScanRuns(requested.kind === "link" ? requested : source, compareWith, config, context);
540
- }
541
- if (compareWith !== undefined && source.kind !== "appBuild") {
542
- return createErrorResponse(`compareWith compares Website Scanner runs and App Accessibility builds; ${describeSource(source)} is neither.`);
543
- }
544
223
  const processing = await waitForProcessing(reportFetcher, source, cursor === 0, context);
545
224
  const stillProcessing = processing.status !== undefined &&
546
225
  PROCESSING_STATUSES.has(processing.status);
547
- const preface = requested.kind === "link"
548
- ? [`Resolved the report link to ${describeSource(source)}.`]
549
- : [];
550
- if (source.kind === "appBuild" && compareWith !== undefined) {
551
- return compareAppBuilds(reportFetcher, source, compareWith, cursor, preface, processing);
552
- }
553
- if (source.kind === "appBuild") {
554
- return createSuccessResponse(await appBuildMessages(reportFetcher, source, cursor, preface, processing, stillProcessing));
555
- }
556
- if (source.kind === "appReport") {
557
- return createSuccessResponse(await appReportMessages(reportFetcher, source, cursor, preface, processing, stillProcessing));
558
- }
559
- const pageArg = pageUrl ? `, pageUrl: "${pageUrl}"` : "";
560
- let reportLink;
561
- try {
562
- reportLink = await reportFetcher.getReportLink(source, {
563
- exportTaskId,
564
- onWait: exportProgress(context),
565
- });
566
- }
567
- catch (error) {
568
- if (error instanceof ExportPendingError) {
569
- return createSuccessResponse(exportPendingMessages(source, error, pageArg));
570
- }
571
- throw error;
572
- }
573
- const { records, page_length, total_issues, next_page, remaining, duplicate_issues, unlinked_duplicates, hidden_issues, page, } = await parseAccessibilityReportFromCSV(reportLink, {
574
- nextPage: cursor,
575
- pageUrl,
576
- });
577
- return createSuccessResponse([
578
- ...preface,
226
+ const reportLink = await reportFetcher.getReportLink(source);
227
+ const { records, page_length, total_issues, next_page, duplicate_issues, unlinked_duplicates, hidden_issues, } = await parseAccessibilityReportFromCSV(reportLink, { nextPage: cursor });
228
+ const currentlyShown = cursor === 0
229
+ ? page_length
230
+ : Math.floor(cursor / JSON.stringify(records[0] || {}).length) +
231
+ page_length;
232
+ const remainingIssues = total_issues - currentlyShown;
233
+ const messages = [
234
+ ...(requested.kind === "link"
235
+ ? [`Resolved the report link to ${describeSource(source)}.`]
236
+ : []),
579
237
  ...processingNote(processing),
580
- ...(page ? pageNote(page) : []),
581
238
  `Retrieved ${page_length} accessibility issues (Total: ${total_issues})`,
582
239
  ...excludedIssuesNote(duplicate_issues, unlinked_duplicates, hidden_issues),
583
240
  `You can also download the full report (CSV) from the following link, valid for about 12 hours: ${reportLink}`,
584
241
  `Issues: ${wrapUntrusted("accessibility scan results", JSON.stringify(records, null, 2))}`,
585
- closingMessage(next_page, remaining, source, stillProcessing, pageArg),
586
- ]);
587
- }
588
- /** What one page of a report holds, in the dashboard's page-view terms. */
589
- function pageNote(page) {
590
- if (page.occurrences === 0) {
591
- const similar = page.similar_pages?.length
592
- ? ` Pages in this report with a similar address: ${wrapUntrusted("report page addresses", JSON.stringify(page.similar_pages))}`
593
- : "";
594
- return [`No issue in this report is on ${page.url}.${similar}`];
595
- }
596
- const severities = Object.entries(page.by_severity)
597
- .sort(([, a], [, b]) => b - a)
598
- .map(([severity, n]) => `${n} ${severity}`)
599
- .join(", ");
600
- return [
601
- `${page.occurrences} issue occurrences on ${page.url} (${severities}), as the dashboard's page view counts them: ${page.distinct_issues} distinct issues, ${page.first_found_here} of them first found on this page. Each issue below is listed once, with occurrences_on_page; first_found_on names the page an issue was first found on when it is another one.`,
602
242
  ];
603
- }
604
- /** At most this many new and resolved issues are listed in a comparison. */
605
- const COMPARE_LIST_LIMIT = 50;
606
- /**
607
- * A Website Scanner run against an earlier run of the same scan: the run before it
608
- * ("previous") or a given scanRunId. Issues are matched by rule, element and page; against
609
- * the previous run, the dashboard's own counts (the run's changesSinceLastRun) are given too.
610
- */
611
- async function compareScanRuns(requested, compareWith, config, context) {
612
- const reportFetcher = await initializeReportFetcher(config);
613
- const source = requested.kind === "link"
614
- ? await reportFetcher.sourceFromReportUrl(requested.reportUrl)
615
- : requested;
616
- if (source.kind !== "scan") {
617
- return createErrorResponse('compareWith compares Website Scanner runs: pass the newer run (scanId and scanRunId, or its report link) and compareWith: "previous" or the older run\'s scanRunId.');
618
- }
619
- const previous = await reportFetcher.previousScanRun(source.scanId, source.scanRunId);
620
- const baseRunId = compareWith === "previous" ? previous && String(previous.id) : compareWith;
621
- if (!baseRunId) {
622
- return createErrorResponse(`Run ${source.scanRunId} is the first run of scan ${source.scanId}; there is no earlier run to compare it with.`);
623
- }
624
- const base = { ...source, scanRunId: baseRunId };
625
- const againstPrevious = previous !== undefined && String(previous.id) === baseRunId;
626
- let links;
627
- try {
628
- links = await Promise.all([
629
- reportFetcher.getReportLink(source, { onWait: exportProgress(context) }),
630
- reportFetcher.getReportLink(base, {}),
631
- ]);
632
- }
633
- catch (error) {
634
- if (error instanceof ExportPendingError) {
635
- return createSuccessResponse([
636
- `The CSV exports of these runs are still being generated (waited ${formatDuration(error.waitedMs)}). Call fetchAccessibilityIssues again with the same arguments in about a minute; the exports continue where they are.`,
637
- ]);
638
- }
639
- throw error;
640
- }
641
- const [after, before] = await Promise.all(links.map(loadMainIssues));
642
- const diff = compareIssues(before, after);
643
- const changes = againstPrevious
644
- ? await reportFetcher.changesSinceLastRun(source.scanId, source.scanRunId)
645
- : undefined;
646
- const baseLabel = againstPrevious
647
- ? `the run before it, run ${baseRunId}${previous?.scanDate ? ` (${previous.scanDate})` : ""}`
648
- : `run ${baseRunId}`;
649
- const matched = `${diff.added.length} new, ${diff.resolved.length} resolved, ${diff.retained} still open`;
650
- const listed = (issues) => wrapUntrusted("accessibility comparison", JSON.stringify(issues.slice(0, COMPARE_LIST_LIMIT), null, 2)) +
651
- (issues.length > COMPARE_LIST_LIMIT
652
- ? `\n(${issues.length - COMPARE_LIST_LIMIT} more not listed; see the CSV links.)`
653
- : "");
654
- return createSuccessResponse([
655
- ...(requested.kind === "link"
656
- ? [`Resolved the report link to ${describeSource(source)}.`]
657
- : []),
658
- `Compared Website Scanner scan ${source.scanId}: run ${source.scanRunId} with ${baseLabel}.`,
659
- changes
660
- ? `The dashboard's comparison: ${changes.newIssues} new, ${changes.resolvedIssues} resolved, ${changes.retainedIssues} still open. Matching the issues by rule, element and page gives ${matched}.`
661
- : `Matching the issues by rule, element and page: ${matched}.`,
662
- `New issues: ${listed(diff.added)}`,
663
- `Resolved issues: ${listed(diff.resolved)}`,
664
- `CSV of each run, valid for about 12 hours: newer ${links[0]} , older ${links[1]}`,
665
- ]);
666
- }
667
- /**
668
- * What App Accessibility has on record, newest first, with the ids the issues are read by.
669
- * Without a project name, the automated listing shows the projects: a build list needs one.
670
- */
671
- async function listAppAccessibilityReports(args, config) {
672
- const reportFetcher = await initializeReportFetcher(config);
673
- const limit = args.limit ?? 10;
674
- const filtered = (what) => args.name ? ` whose ${what} contains "${args.name}"` : "";
675
- if (args.kind === "manual") {
676
- const { total, reports } = await reportFetcher.listAppManualReports(args.name, limit);
677
- if (reports.length === 0) {
678
- return createSuccessResponse([
679
- `No App Accessibility manual reports${filtered("name")}. Drop the name filter to list the newest reports.`,
680
- ]);
681
- }
682
- return createSuccessResponse([
683
- `${total} App Accessibility manual reports (newest first)${filtered("name")}${total > reports.length ? `, showing ${reports.length}` : ""}:`,
684
- wrapUntrusted("app accessibility reports", JSON.stringify(reports, null, 2)),
685
- `Read a report's issues with fetchAccessibilityIssues with appReportId: <reportId>; its dashboard_link opens it on the dashboard.`,
686
- ]);
687
- }
688
- if (!args.projectName) {
689
- const { total, projects } = await reportFetcher.listAppProjects(limit);
690
- return createSuccessResponse([
691
- `${total} App Accessibility projects (newest first)${total > projects.length ? `, showing ${projects.length}` : ""}:`,
692
- wrapUntrusted("app accessibility projects", JSON.stringify(projects, null, 2)),
693
- `A build list needs a project: call listAppAccessibilityReports again with projectName: <name> (exact) to list its builds.`,
694
- ]);
695
- }
696
- const { project, total, builds } = await reportFetcher.listAppBuilds(args.projectName, args.name, limit);
697
- if (builds.length === 0) {
698
- return createSuccessResponse([
699
- `No builds of App Accessibility project "${project.name}"${filtered("name")}${args.name
700
- ? ". Drop the name filter to list the project's newest builds"
701
- : ": nothing has run with accessibility on in it yet"}.`,
702
- ]);
243
+ if (next_page !== null) {
244
+ messages.push(`${remainingIssues} more issues available. Use fetchAccessibilityIssues with ${sourceArgs(source)} and cursor: ${next_page} to get the next batch.`);
703
245
  }
704
- return createSuccessResponse([
705
- `${total} builds of App Accessibility project "${project.name}" (newest first)${filtered("name")}${total > builds.length ? `, showing ${builds.length}` : ""}:`,
706
- wrapUntrusted("app accessibility builds", JSON.stringify(builds, null, 2)),
707
- `Read a build's issues with fetchAccessibilityIssues with buildId: <buildId>; it waits while a build is still processing. report_status "failed" with scan_count 0 means the run recorded no scans.`,
708
- ]);
709
- }
710
- async function executeListAppAccessibilityReports(args, server, config) {
711
- try {
712
- trackMCP("listAppAccessibilityReports", server.server.getClientVersion(), undefined, config);
713
- return await listAppAccessibilityReports(args, config);
246
+ else if (stillProcessing) {
247
+ messages.push(`All issues found so far retrieved; the report is still processing.`);
714
248
  }
715
- catch (error) {
716
- return handleMCPError("listAppAccessibilityReports", server, config, error);
249
+ else {
250
+ messages.push(`✅ All issues retrieved.`);
717
251
  }
252
+ return createSuccessResponse(messages);
718
253
  }
719
254
  async function executeAccessibilityScan(args, context, server, config) {
720
255
  try {
@@ -821,33 +356,32 @@ async function runAccessibilityScan(name, pageURL, context, config, authConfigId
821
356
  return createScanFailureResponse(name, status);
822
357
  }
823
358
  const reportFetcher = await initializeReportFetcher(config);
824
- let reportLink;
825
- try {
826
- reportLink = await reportFetcher.getReportLink({ kind: "scan", scanId, scanRunId }, { onWait: exportProgress(context) });
827
- }
828
- catch (error) {
829
- if (error instanceof ExportPendingError) {
830
- return createSuccessResponse([
831
- `Accessibility scan "${name}" completed. Scan ID: ${scanId} and Scan Run ID: ${scanRunId}`,
832
- ...exportPendingMessages({
833
- kind: "scan",
834
- scanId: String(scanId),
835
- scanRunId: String(scanRunId),
836
- }, error, ""),
837
- ]);
838
- }
839
- throw error;
840
- }
359
+ const reportLink = await reportFetcher.getReportLink({
360
+ kind: "scan",
361
+ scanId,
362
+ scanRunId,
363
+ });
841
364
  const { records, page_length, total_issues, next_page, duplicate_issues, unlinked_duplicates, hidden_issues, } = await parseAccessibilityReportFromCSV(reportLink);
842
365
  return createScanSuccessResponse(name, total_issues, page_length, records, scanId, scanRunId, reportLink, next_page, excludedIssuesNote(duplicate_issues, unlinked_duplicates, hidden_issues));
843
366
  }
844
367
  export default function addAccessibilityTools(server, config) {
845
368
  const tools = {};
846
- tools.startAccessibilityScan = server.tool("startAccessibilityScan", "Scan a web page URL for accessibility issues and get a CSV report. For a mobile app, run it on App Automate with accessibility on (setupBrowserStackAppAutomateTests), then fetchAccessibilityIssues.", {
847
- name: z.string().describe("Name of the accessibility scan"),
848
- pageURL: z
369
+ tools.accessibilityExpert = server.tool("accessibilityExpert", "Answer accessibility, a11y, and WCAG questions using BrowserStack's accessibility knowledge base.", {
370
+ query: z
849
371
  .string()
850
- .describe("The web page URL to scan for accessibility issues"),
372
+ .describe("Any accessibility, a11y, WCAG, or web accessibility question"),
373
+ }, {
374
+ title: "Accessibility Expert",
375
+ readOnlyHint: true,
376
+ openWorldHint: false,
377
+ destructiveHint: false,
378
+ idempotentHint: true,
379
+ }, async (args) => {
380
+ return await executeAccessibilityRAG(args, server, config);
381
+ });
382
+ tools.startAccessibilityScan = server.tool("startAccessibilityScan", "Start an accessibility scan via BrowserStack and retrieve a local CSV report path.", {
383
+ name: z.string().describe("Name of the accessibility scan"),
384
+ pageURL: z.string().describe("The URL to scan for accessibility issues"),
851
385
  authConfigId: z
852
386
  .number()
853
387
  .optional()
@@ -938,7 +472,7 @@ export default function addAccessibilityTools(server, config) {
938
472
  }, async (args) => {
939
473
  return await executeGetAuthConfig(args, server, config);
940
474
  });
941
- tools.fetchAccessibilityIssues = server.tool("fetchAccessibilityIssues", "Fetch accessibility issues from one source: scanId+scanRunId, buildId, workflowReportId, appReportId or reportUrl." +
475
+ tools.fetchAccessibilityIssues = server.tool("fetchAccessibilityIssues", "Fetch accessibility issues (paged by cursor) from one source: scanId+scanRunId, buildId, workflowReportId or reportUrl." +
942
476
  NEEDS_A11Y_SCAN_ID, {
943
477
  scanId: z
944
478
  .string()
@@ -952,36 +486,19 @@ export default function addAccessibilityTools(server, config) {
952
486
  .string()
953
487
  .regex(/^[a-z0-9]{40}$/, "40 lowercase letters and digits")
954
488
  .optional()
955
- .describe("Automated Tests or App Accessibility build id: the SDK's testhub id, or the thBuildId in a web report link."),
489
+ .describe("Automated Tests: the thBuildId in the build's accessibility report link (not the Automate build id)."),
956
490
  workflowReportId: z
957
491
  .string()
958
492
  .optional()
959
493
  .describe("Workflow Analyzer: the report's numeric id."),
960
- appReportId: z
961
- .string()
962
- .regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, "A manual report id is a UUID")
963
- .optional()
964
- .describe("Manual App Accessibility report id (a UUID), from listAppAccessibilityReports or its /report/ link."),
965
494
  reportUrl: z
966
495
  .string()
967
496
  .optional()
968
- .describe("A web or app report link (dashboard, scanner share link, manual report), or an Automate or App Automate link."),
497
+ .describe("A Website Scanner or Automated Tests report link from the Accessibility dashboard (carries thBuildId)."),
969
498
  cursor: z
970
499
  .number()
971
500
  .optional()
972
- .describe("Character offset for pagination (default: 0); with compareWith, how many issues are already listed."),
973
- pageUrl: z
974
- .string()
975
- .optional()
976
- .describe("Only the issues on this page URL, counted as the dashboard's page view counts them."),
977
- compareWith: z
978
- .string()
979
- .optional()
980
- .describe(`"previous", an older scanRunId (Website Scanner), or an earlier app build's id or link; lists new and resolved issues.`),
981
- exportTaskId: z
982
- .string()
983
- .optional()
984
- .describe("Continue a CSV export that an earlier call said is still being generated."),
501
+ .describe("Character offset for pagination (default: 0)"),
985
502
  }, {
986
503
  title: "Fetch Accessibility Issues",
987
504
  readOnlyHint: true,
@@ -991,36 +508,5 @@ export default function addAccessibilityTools(server, config) {
991
508
  }, async (args, context) => {
992
509
  return await executeFetchAccessibilityIssues(args, context, server, config);
993
510
  });
994
- tools.listAppAccessibilityReports = server.tool("listAppAccessibilityReports", "List App Accessibility work newest first, with the ids fetchAccessibilityIssues reads: a project's builds (automated; omit projectName to list projects) or manual reports.", {
995
- kind: z
996
- .enum(["automated", "manual"])
997
- .optional()
998
- .default("automated")
999
- .describe("automated (App Automate builds, default) or manual (Workflow Analyzer reports)."),
1000
- projectName: z
1001
- .string()
1002
- .optional()
1003
- .describe("Automated only: the exact App Accessibility project name; omit to list the projects."),
1004
- name: z
1005
- .string()
1006
- .optional()
1007
- .describe("Partial build or report name to filter by."),
1008
- limit: z
1009
- .number()
1010
- .int()
1011
- .min(1)
1012
- .max(30)
1013
- .optional()
1014
- .default(10)
1015
- .describe("Rows to return, newest first (1 to 30, default 10)."),
1016
- }, {
1017
- title: "List App Accessibility Reports",
1018
- readOnlyHint: true,
1019
- openWorldHint: false,
1020
- destructiveHint: false,
1021
- idempotentHint: true,
1022
- }, async (args) => {
1023
- return await executeListAppAccessibilityReports(args, server, config);
1024
- });
1025
511
  return tools;
1026
512
  }