#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { McpError } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import { chromium } from "playwright";
import type { Browser, Page } from "playwright";
const server = new McpServer({
name: "accessibility-checker",
version: "0.1.0",
description: "Automated website accessibility checker using browser automation and WCAG compliance analysis",
});
let browser: Browser | null = null;
async function getBrowser(): Promise<Browser> {
if (!browser) {
browser = await chromium.launch({
headless: true,
});
}
return browser;
}
async function getPage(url: string): Promise<Page> {
const br = await getBrowser();
const page = await br.newPage();
try {
await page.goto(url, {
waitUntil: "networkidle",
timeout: 30000,
});
return page;
} catch (error) {
await br.close();
throw new McpError(-32000, `Failed to load page: ${(error as Error).message}`);
}
}
server.registerTool(
"scan_page",
{
title: "Scan Page for Accessibility",
description: "Crawls a webpage and analyzes all interactive elements, headings, and text for accessibility issues including color contrast, size ratios, and WCAG compliance.",
inputSchema: {
url: z.string().url().describe("The URL of the webpage to scan"),
maxElements: z.number().min(1).max(500).default(100).describe("Maximum number of elements to analyze (default: 100)"),
includeHeadings: z.boolean().default(true).describe("Include heading elements (h1-h6) in scan"),
includeButtons: z.boolean().default(true).describe("Include buttons and interactive elements in scan"),
includeLinks: z.boolean().default(true).describe("Include links and anchor tags in scan"),
},
},
async ({ url, maxElements, includeHeadings, includeButtons, includeLinks }) => {
const page = await getPage(url);
try {
const analysis = await page.evaluate(({ maxElements, includeHeadings, includeButtons, includeLinks }) => {
const results: any[] = [];
const checkedSelectors = new Set<string>();
const selectors: string[] = [];
if (includeHeadings) {
for (let i = 1; i <= 6; i++) {
selectors.push(`h${i}`);
}
}
if (includeButtons) {
selectors.push('a', 'button', 'input[type="button"]', 'input[type="submit"]', '[role="button"]', '[role="link"]');
}
if (includeLinks) {
selectors.push('a[href]', '[role="link"]');
}
for (const selector of selectors) {
try {
const elements = Array.from((globalThis as any).document.querySelectorAll(selector));
for (let i = 0; i < elements.length && i < maxElements; i++) {
const el = elements[i];
if (!el || !el.offsetParent) continue;
const styles = (globalThis as any).window.getComputedStyle(el);
const color = styles.color || '';
const backgroundColor = styles.backgroundColor || '';
const fontSize = styles.fontSize || '';
const fontWeight = styles.fontWeight || '';
const text = (el as any).textContent?.trim() || '';
if (text.length === 0) continue;
let uniqueSelector = '';
if ((el as any).id) {
uniqueSelector = `#${(el as any).id}`;
} else if ((el as any).className) {
const classes = (el as any).className.split(' ').filter((c: string) => c && (globalThis as any).document.querySelectorAll(`.${c}`).length === 1);
if (classes.length > 0) {
uniqueSelector = `.${classes[0]}`;
}
}
uniqueSelector = uniqueSelector || (el as any).tagName.toLowerCase();
checkedSelectors.add(uniqueSelector);
const fgHex = (color || '').match(/^#?([a-f0-9]{6})$/i);
const bgHex = (backgroundColor || '').match(/^#?([a-f0-9]{6})$/i);
const fgHexValue = fgHex ? fgHex[0] : '000000';
const bgHexValue = bgHex ? bgHex[0] : 'ffffff';
const fgLum = fgHexValue ? (parseInt(fgHex[1], 16) / 255 * 0.2126 + parseInt(fgHex[3], 16) / 255 * 0.7152 + parseInt(fgHex[5], 16) / 255 * 0.0722) + 0.05 : 0;
const bgLum = bgHexValue ? (parseInt(bgHex[1], 16) / 255 * 0.2126 + parseInt(bgHex[3], 16) / 255 * 0.7152 + parseInt(bgHex[5], 16) / 255 * 0.0722) + 0.05 : 0;
const L1 = fgLum + 0.05;
const L2 = bgLum + 0.05;
const L = Math.max(L1, L2);
const lighter = Math.max(L1, L2);
const darker = Math.min(L1, L2);
const contrast = (lighter + 0.05) / (darker + 0.05);
const wcagAA = contrast >= 4.5;
const wcagAAA = contrast >= 7;
;
let recommendation: string | undefined;
if (fontSize && backgroundColor && color) {
const fontSizePx = parseFloat(fontSize);
const fontSizeRatio = fontSizePx / 16;
if (fontSizeRatio < 1.125 && !wcagAAA) {
severity = "error";
recommendation = "Font size too small for contrast. WCAG AAA requires either 7:1 contrast or font size >= 18px (scaled to 14pt)";
}
}
if (!wcagAA) {
severity = "critical";
recommendation = `Contrast too low (${contrast.toFixed(2)}). WCAG AA requires 4.5:1 for normal text`;
} else if (!wcagAAA) {
severity = "warning";
recommendation = `Contrast does not meet AAA (${contrast.toFixed(2)})`;
}
const role = (el as any).getAttribute('role') || undefined;
const hasInteractiveRole = role && ['button', 'link', 'checkbox', 'radio', 'switch'].includes(role);
const interactiveTags = ['a', 'button', 'input', 'select', 'textarea'];
const headingTags = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
const tag = (el as any).tagName?.toLowerCase() || '';
const isInteractive = interactiveTags.includes(tag) || hasInteractiveRole;
const isHeading = headingTags.includes(tag);
const priority = isInteractive ? 'critical' : (isHeading ? 'high' : 'medium');
results.push({
selector: uniqueSelector,
type: 'contrast',
computedStyles: { color, backgroundColor, fontSize, fontWeight },
accessibility: {
contrast,
wcagAA,
wcagAAA,
normalAA: contrast >= 3,
largeAA: contrast >= 3,
normalAAA: wcagAAA,
largeAAA: wcagAAA,
fontSizeRatio,
recommendation,
},
confidence: 'high',
priority,
});
}
} catch (e) {
continue;
}
}
return results;
}, { maxElements, includeHeadings, includeButtons, includeLinks });
const issues = analysis.filter((a: any) => a.accessibility && a.accessibility.severity === 'critical');
const warnings = analysis.filter((a: any) => a.accessibility && a.accessibility.severity === 'error');
const passed = analysis.filter((a: any) => a.accessibility && a.accessibility.severity === 'info');
const summary = {
url,
scanTime: new Date().toISOString(),
elementsAnalyzed: analysis.length,
issues: {
critical: issues.filter((a: any) => a.accessibility.severity === 'critical').length,
error: issues.filter((a: any) => a.accessibility.severity === 'error').length,
warning: warnings.length,
info: passed.length,
},
totalIssues: issues.length,
criticalIssues: issues.filter((a: any) => a.accessibility.severity === 'critical').length,
passedChecks: passed.length,
};
await page.close();
await browser.close();
return {
content: [{ type: "text", text: JSON.stringify({ results: analysis, summary }) }],
};
} catch (error) {
await browser?.close().catch(() => {});
throw new McpError(-32000, `Scan failed: ${(error as Error).message}`);
}
}
);
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
const transport = new StdioServerTransport();
server.connect(transport);