#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { chromium, Browser, Page } from "playwright";
import AxeBuilder from "@axe-core/playwright";
import * as fs from "fs";
import * as path from "path";
const server = new McpServer({
name: "playwright-a11y",
version: "2.0.0",
});
// Shared browser instance for performance
let sharedBrowser: Browser | null = null;
async function getBrowser(): Promise<Browser> {
if (!sharedBrowser || !sharedBrowser.isConnected()) {
sharedBrowser = await chromium.launch({ headless: true });
}
return sharedBrowser;
}
// Ensure screenshots directory exists
const SCREENSHOTS_DIR = path.join(process.cwd(), "accessibility-screenshots");
if (!fs.existsSync(SCREENSHOTS_DIR)) {
fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true });
}
interface ScanResult {
url: string;
timestamp: string;
selector?: string;
summary: {
violations: number;
passes: number;
incomplete: number;
};
violations: Array<{
id: string;
impact: string;
description: string;
help: string;
helpUrl: string;
tags: string[];
nodes: Array<{
html: string;
target: any; // Can be string[], CrossTreeSelector, or ShadowDomSelector
failureSummary?: string;
}>;
}>;
screenshotPath?: string;
}
/**
* TOOL: a11y_scanUrl
* Enhanced URL scanning with optional element targeting and screenshots
*/
server.registerTool(
"a11y_scanUrl",
{
description: "Run accessibility scan on a URL or specific element using Playwright + axe-core. Supports full page or targeted section scanning with optional screenshots. Uses comprehensive WCAG 2.0, 2.1 Level A/AA and best-practice rules by default.",
inputSchema: z.object({
url: z.string().describe("URL to scan for accessibility issues"),
selector: z.string().optional().describe("CSS selector to scan only a specific section/element (e.g., 'header', '.main-content', '#navigation')"),
waitForSelector: z.string().optional().describe("CSS selector to wait for before scanning (useful for dynamic content)"),
captureScreenshot: z.boolean().optional().default(false).describe("Capture screenshot of the scanned area with violations highlighted"),
timeout: z.number().optional().default(30000).describe("Navigation timeout in milliseconds"),
}),
},
async ({ url, selector, waitForSelector, captureScreenshot, timeout }) => {
let browser: Browser | null = null;
let page: Page | null = null;
try {
browser = await getBrowser();
const context = await browser.newContext();
page = await context.newPage();
// Navigate to URL
await page.goto(url, {
waitUntil: "networkidle",
timeout
});
// Wait for specific selector if provided
if (waitForSelector) {
await page.waitForSelector(waitForSelector, { timeout: 10000 });
}
// Build axe configuration with comprehensive WCAG tags
const axeBuilder = new AxeBuilder({ page });
// Use comprehensive WCAG tags (matches common testing standards)
const comprehensiveTags = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice'];
axeBuilder.withTags(comprehensiveTags);
// If selector is provided, scan only that element
if (selector) {
// Verify selector exists
const elementExists = await page.locator(selector).count() > 0;
if (!elementExists) {
throw new Error(`Selector "${selector}" not found on page`);
}
axeBuilder.include(selector);
}
// Run accessibility scan
const results = await axeBuilder.analyze();
let screenshotPath: string | undefined;
// Capture screenshot if requested
if (captureScreenshot) {
const timestamp = Date.now();
const filename = `scan-${timestamp}.png`;
screenshotPath = path.join(SCREENSHOTS_DIR, filename);
if (selector) {
// Screenshot specific element
const element = page.locator(selector);
await element.screenshot({ path: screenshotPath });
} else {
// Full page screenshot
await page.screenshot({ path: screenshotPath, fullPage: true });
}
}
await context.close();
// Format the response
const result: ScanResult = {
url,
timestamp: new Date().toISOString(),
selector,
summary: {
violations: results.violations.length,
passes: results.passes.length,
incomplete: results.incomplete.length
},
violations: results.violations.map(violation => ({
id: violation.id,
impact: violation.impact || "unknown",
description: violation.description,
help: violation.help,
helpUrl: violation.helpUrl,
tags: violation.tags,
nodes: violation.nodes.map(node => ({
html: node.html,
target: node.target,
failureSummary: node.failureSummary
}))
})),
screenshotPath
};
return {
content: [
{
type: "text" as const,
text: JSON.stringify(result, null, 2),
},
],
};
} catch (error) {
if (page) await page.close().catch(() => {});
const errorResult = {
url,
selector,
timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : String(error),
};
return {
content: [
{
type: "text" as const,
text: JSON.stringify(errorResult, null, 2),
},
],
isError: true,
};
}
}
);
/**
* PROMPT: comprehensive-a11y-test
* Predefined template for comprehensive accessibility testing with minimal input
*/
server.registerPrompt(
"comprehensive-a11y-test",
{
title: "Comprehensive Accessibility Test",
description: "Run a comprehensive accessibility audit with detailed analysis. Just provide URL, optional block name, and optional steps.",
argsSchema: {
url: z.string().describe("URL to test (e.g., https://example.com)"),
block: z.string().optional().describe("Name of specific section/component to test (e.g., 'Rewards account', 'Navigation')"),
steps: z.string().optional().describe("Comma-separated interaction steps (e.g., 'open Rewards block, select provider, input value, click apply')")
}
},
async ({ url, block, steps }) => {
let instructions = `Use the accessibility-testing MCP tools to perform a comprehensive audit.
**Testing Instructions:**
1. Navigate to the URL: ${url}
2. Run a full accessibility audit using Axe (axe-core).
3. Return:
- A list of all accessibility issues found
- For each issue include: rule ID, description, severity/impact, selector, HTML snippet (if available)
4. Generate a final human-readable summary explaining:
- The main categories of problems
- Which issues are most critical
- What should be fixed first
`;
if (block && steps) {
instructions += `
5. Test the "${block}" section specifically:
- Perform these interactions: ${steps}
- Scan for accessibility issues after each interaction
- Compare violations across different states
**How to execute:**
- First: Use \`mcp__playwright-a11y__a11y_scanUrl\` for the initial full page scan
- Then: Use \`mcp__playwright-a11y__a11y_scanInteractiveByText\` with:
- containerText: "${block}"
- customInteractions: Parse the steps "${steps}" into individual actions
- captureScreenshots: true
`;
} else if (block) {
instructions += `
5. Test the "${block}" section specifically:
- Auto-discover and test all interactive elements
- Scan for accessibility issues in different states
**How to execute:**
- First: Use \`mcp__playwright-a11y__a11y_scanUrl\` for the initial full page scan
- Then: Use \`mcp__playwright-a11y__a11y_scanInteractiveByText\` with:
- containerText: "${block}"
- autoDiscover: true
- captureScreenshots: true
`;
} else {
instructions += `
**How to execute:**
- Use \`mcp__playwright-a11y__a11y_scanUrl\` with:
- url: "${url}"
- captureScreenshot: true
- timeout: 30000
`;
}
instructions += `
**Report Format:**
After completing all scans, provide:
1. Executive Summary (severity counts, main issues)
2. Critical Issues (impact: "critical" or "serious")
3. Moderate Issues (impact: "moderate")
4. Minor Issues (impact: "minor")
5. Recommendations (prioritized fix list)
Make the report actionable and easy to understand for developers.`;
return {
messages: [
{
role: "user",
content: {
type: "text",
text: instructions
}
}
]
};
}
);
/**
* TOOL: a11y_scanInteractiveByText
* Intelligent scanning that finds elements by visible text/labels and auto-discovers interactions
*/
server.registerTool(
"a11y_scanInteractiveByText",
{
description: "Test accessibility of components by finding them using visible text/labels instead of CSS selectors. Automatically discovers and tests interactive elements. Perfect for natural language testing like 'test the Rewards section' or 'test the user menu'. Uses comprehensive WCAG 2.0, 2.1 Level A/AA and best-practice rules by default.",
inputSchema: z.object({
url: z.string().describe("URL to navigate to"),
containerText: z.string().describe("Visible text to find the container by (e.g., 'Rewards', 'Navigation', 'User Profile'). Will search for this text in headings, labels, buttons, and ARIA labels."),
autoDiscover: z.boolean().optional().default(true).describe("Automatically discover and test all interactive elements (buttons, links, inputs) within the container"),
customInteractions: z.array(z.object({
stateName: z.string().describe("Name for this interaction state"),
elementText: z.string().describe("Visible text of element to interact with (e.g., 'Open Menu', 'Show More')"),
action: z.enum(["click", "hover", "focus"]).describe("Interaction type"),
waitAfter: z.number().optional().default(500)
})).optional().describe("Optional custom interactions in addition to auto-discovery"),
captureScreenshots: z.boolean().optional().default(false),
timeout: z.number().optional().default(30000),
}),
},
async ({ url, containerText, autoDiscover, customInteractions, captureScreenshots, timeout }) => {
let browser: Browser | null = null;
let page: Page | null = null;
try {
browser = await getBrowser();
const context = await browser.newContext();
page = await context.newPage();
await page.goto(url, {
waitUntil: "networkidle",
timeout
});
// Find container by text - try multiple strategies
let containerLocator = null;
let containerSelector = null;
// Strategy 1: Find by heading containing text
let heading = page.locator(`h1, h2, h3, h4, h5, h6`).filter({ hasText: containerText });
if (await heading.count() > 0) {
// Get parent section/div/article
containerLocator = heading.first().locator('xpath=ancestor::*[self::section or self::div or self::article or self::nav or self::aside][1]');
if (await containerLocator.count() === 0) {
containerLocator = heading.first().locator('xpath=..');
}
}
// Strategy 2: Find section/div with aria-label or text content
if (!containerLocator || await containerLocator.count() === 0) {
containerLocator = page.locator(`[aria-label*="${containerText}" i], [aria-labelledby*="${containerText}" i]`);
}
// Strategy 3: Find by role with name
if (!containerLocator || await containerLocator.count() === 0) {
containerLocator = page.getByRole('region', { name: new RegExp(containerText, 'i') });
}
// Strategy 4: Find any element containing the text
if (!containerLocator || await containerLocator.count() === 0) {
containerLocator = page.locator(`section, div, nav, aside, article`).filter({ hasText: containerText }).first();
}
if (!containerLocator || await containerLocator.count() === 0) {
throw new Error(`Could not find container with text "${containerText}". Try being more specific or use a different part of the visible text.`);
}
// Get a selector for axe
try {
// Try to get a stable selector
const element = await containerLocator.first().elementHandle();
if (element) {
const tagName = await element.evaluate(el => el.tagName.toLowerCase());
const id = await element.evaluate(el => el.id);
const className = await element.evaluate(el => el.className);
if (id) {
containerSelector = `#${id}`;
} else if (className && typeof className === 'string') {
const firstClass = className.split(' ')[0];
if (firstClass) {
containerSelector = `.${firstClass}`;
}
} else {
containerSelector = tagName;
}
}
} catch (e) {
containerSelector = 'body'; // fallback
}
const results = [];
// Initial state scan with comprehensive WCAG tags
try {
console.log('š Scanning initial state...');
const axeBuilder = new AxeBuilder({ page });
if (containerSelector) {
axeBuilder.include(containerSelector);
}
// Use comprehensive WCAG tags for thorough testing
const comprehensiveTags = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice'];
axeBuilder.withTags(comprehensiveTags);
const axeResults = await axeBuilder.analyze();
console.log(` Initial state: ${axeResults.violations.length} violations, ${axeResults.passes.length} passes\n`);
let screenshotPath: string | undefined;
if (captureScreenshots) {
const timestamp = Date.now();
const filename = `initial-state-${timestamp}.png`;
screenshotPath = path.join(SCREENSHOTS_DIR, filename);
await containerLocator.screenshot({ path: screenshotPath });
}
results.push({
stateName: "Initial State",
summary: {
violations: axeResults.violations.length,
passes: axeResults.passes.length,
incomplete: axeResults.incomplete.length
},
violations: axeResults.violations.map(violation => ({
id: violation.id,
impact: violation.impact || "unknown",
description: violation.description,
help: violation.help,
helpUrl: violation.helpUrl,
nodes: violation.nodes.map(node => ({
html: node.html,
target: node.target,
failureSummary: node.failureSummary
}))
})),
screenshotPath
});
} catch (error) {
results.push({
stateName: "Initial State",
error: error instanceof Error ? error.message : String(error)
});
}
// Auto-discover interactive elements and scenarios
if (autoDiscover) {
console.log('š Auto-discovering interactive scenarios...');
// Comprehensive WCAG tags for thorough testing
const comprehensiveTags = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice'];
// Discover different types of interactive elements
const interactiveSelectors = {
buttons: 'button, [role="button"], input[type="submit"], input[type="button"]',
links: 'a[href]',
inputs: 'input:not([type="hidden"]), textarea, select',
toggles: '[role="switch"], [aria-expanded]',
tabs: '[role="tab"]',
menuItems: '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]',
disclosures: 'details, [aria-haspopup]'
};
const discoveredScenarios = [];
// Discover each type
for (const [type, selector] of Object.entries(interactiveSelectors)) {
const elements = containerLocator.locator(selector);
const count = await elements.count();
if (count > 0) {
console.log(` Found ${count} ${type}`);
}
// Test up to 3 of each type
for (let i = 0; i < Math.min(count, 3); i++) {
try {
const element = elements.nth(i);
const isVisible = await element.isVisible();
if (!isVisible) continue;
const text = await element.textContent() || await element.getAttribute('aria-label') ||
await element.getAttribute('title') || `${type} ${i + 1}`;
const elementInfo = {
type,
text: text.trim(),
element
};
// Check for special states
const ariaExpanded = await element.getAttribute('aria-expanded');
const ariaPressed = await element.getAttribute('aria-pressed');
const ariaChecked = await element.getAttribute('aria-checked');
discoveredScenarios.push({
...elementInfo,
hasExpandedState: ariaExpanded !== null,
hasPressedState: ariaPressed !== null,
hasCheckedState: ariaChecked !== null
});
} catch (error) {
continue;
}
}
}
console.log(`\nā Discovered ${discoveredScenarios.length} interactive scenarios\n`);
// Test each discovered scenario
for (const scenario of discoveredScenarios) {
try {
// Click/interact with the element
await scenario.element.click();
await page.waitForTimeout(500);
// Comprehensive scan with all WCAG tags
const axeBuilder = new AxeBuilder({ page });
if (containerSelector) {
axeBuilder.include(containerSelector);
}
// Use comprehensive tags instead of just wcagLevel
axeBuilder.withTags(comprehensiveTags);
const axeResults = await axeBuilder.analyze();
let screenshotPath: string | undefined;
if (captureScreenshots) {
const timestamp = Date.now();
const sanitizedText = scenario.text.replace(/[^a-z0-9]/gi, '-').toLowerCase().substring(0, 30);
const filename = `${scenario.type}-${sanitizedText}-${timestamp}.png`;
screenshotPath = path.join(SCREENSHOTS_DIR, filename);
await containerLocator.screenshot({ path: screenshotPath });
}
results.push({
stateName: `After interacting with ${scenario.type}: "${scenario.text}"`,
action: "click",
elementType: scenario.type,
elementText: scenario.text,
hasStateManagement: scenario.hasExpandedState || scenario.hasPressedState || scenario.hasCheckedState,
summary: {
violations: axeResults.violations.length,
passes: axeResults.passes.length,
incomplete: axeResults.incomplete.length
},
violations: axeResults.violations.map(violation => ({
id: violation.id,
impact: violation.impact || "unknown",
description: violation.description,
help: violation.help,
helpUrl: violation.helpUrl,
tags: violation.tags,
nodes: violation.nodes.map(node => ({
html: node.html,
target: node.target,
failureSummary: node.failureSummary
}))
})),
screenshotPath
});
console.log(` ā Tested: ${scenario.type} "${scenario.text}" - ${axeResults.violations.length} violations`);
} catch (error) {
console.log(` ā Skipped: ${scenario.type} "${scenario.text}"`);
continue;
}
}
}
// Custom interactions
if (customInteractions) {
// Comprehensive WCAG tags for thorough testing
const comprehensiveTags = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice'];
for (const interaction of customInteractions) {
try {
// Find element by text
const element = containerLocator.getByText(interaction.elementText, { exact: false }).first();
if (await element.count() === 0) {
results.push({
stateName: interaction.stateName,
elementText: interaction.elementText,
error: `Could not find element with text "${interaction.elementText}"`
});
continue;
}
// Perform action
switch (interaction.action) {
case "click":
await element.click();
break;
case "hover":
await element.hover();
break;
case "focus":
await element.focus();
break;
}
await page.waitForTimeout(interaction.waitAfter || 500);
// Scan with comprehensive WCAG tags
const axeBuilder = new AxeBuilder({ page });
if (containerSelector) {
axeBuilder.include(containerSelector);
}
// Use comprehensive WCAG tags
axeBuilder.withTags(comprehensiveTags);
const axeResults = await axeBuilder.analyze();
let screenshotPath: string | undefined;
if (captureScreenshots) {
const timestamp = Date.now();
const sanitizedName = interaction.stateName.replace(/[^a-z0-9]/gi, '-').toLowerCase();
const filename = `custom-${sanitizedName}-${timestamp}.png`;
screenshotPath = path.join(SCREENSHOTS_DIR, filename);
await containerLocator.screenshot({ path: screenshotPath });
}
results.push({
stateName: interaction.stateName,
action: interaction.action,
elementText: interaction.elementText,
summary: {
violations: axeResults.violations.length,
passes: axeResults.passes.length,
incomplete: axeResults.incomplete.length
},
violations: axeResults.violations.map(violation => ({
id: violation.id,
impact: violation.impact || "unknown",
description: violation.description,
help: violation.help,
helpUrl: violation.helpUrl,
nodes: violation.nodes.map(node => ({
html: node.html,
target: node.target,
failureSummary: node.failureSummary
}))
})),
screenshotPath
});
} catch (error) {
results.push({
stateName: interaction.stateName,
elementText: interaction.elementText,
error: error instanceof Error ? error.message : String(error)
});
}
}
}
await context.close();
return {
content: [
{
type: "text" as const,
text: JSON.stringify({
url,
containerText,
containerFound: true,
timestamp: new Date().toISOString(),
totalStates: results.length,
states: results
}, null, 2),
},
],
};
} catch (error) {
if (page) await page.close().catch(() => {});
return {
content: [
{
type: "text" as const,
text: JSON.stringify({
url,
containerText,
error: error instanceof Error ? error.message : String(error)
}, null, 2),
},
],
isError: true,
};
}
}
);
// Cleanup on exit
process.on("SIGINT", async () => {
if (sharedBrowser) {
await sharedBrowser.close();
}
process.exit(0);
});
process.on("SIGTERM", async () => {
if (sharedBrowser) {
await sharedBrowser.close();
}
process.exit(0);
});
// Start MCP server with stdio transport
const transport = new StdioServerTransport();
server.connect(transport).catch((error) => {
console.error("Failed to start server:", error);
process.exit(1);
});