Web Accessibility MCP Server
- mcp-web-a11y
- src
#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ErrorCode,
ListToolsRequestSchema,
McpError,
} from '@modelcontextprotocol/sdk/types.js';
import puppeteer from 'puppeteer';
import axe from 'axe-core';
import { writeFileSync } from 'fs';
import { join } from 'path';
import { simulate } from '@bjornlu/colorblind';
// Extend Window interface to include our simulate function
declare global {
interface Window {
simulate: (rgb: RGB, type: string) => RGB;
}
}
interface RGB {
r: number;
g: number;
b: number;
}
// Helper function to parse color string to RGB
function parseColor(color: string): RGB {
// Remove all spaces and convert to lowercase
color = color.toLowerCase().replace(/\s/g, '');
// Handle rgba/rgb format
if (color.startsWith('rgba(') || color.startsWith('rgb(')) {
const values = color
.replace('rgba(', '')
.replace('rgb(', '')
.replace(')', '')
.split(',')
.map(Number);
return {
r: values[0],
g: values[1],
b: values[2]
};
}
// Handle hex format
if (color.startsWith('#')) {
const hex = color.replace('#', '');
const r = parseInt(hex.substr(0, 2), 16);
const g = parseInt(hex.substr(2, 2), 16);
const b = parseInt(hex.substr(4, 2), 16);
return { r, g, b };
}
// Default to black if color format is not recognized
return { r: 0, g: 0, b: 0 };
}
// Helper function to convert RGB to CSS color string
function rgbToString(rgb: RGB): string {
return `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`;
}
interface AnalyzeUrlArgs {
url: string;
waitForSelector?: string;
userAgent?: string;
}
interface SimulateColorblindArgs {
url: string;
type: 'protanopia' | 'deuteranopia' | 'tritanopia';
outputPath?: string;
userAgent?: string;
}
class WebA11yServer {
private server: Server;
constructor() {
this.server = new Server(
{
name: 'web-a11y-server',
version: '0.1.0',
},
{
capabilities: {
tools: {},
},
}
);
this.setupToolHandlers();
this.server.onerror = (error) => console.error('[MCP Error]', error);
process.on('SIGINT', async () => {
await this.server.close();
process.exit(0);
});
}
private setupToolHandlers() {
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'check_accessibility',
description: 'Check web accessibility of a given URL using axe-core',
inputSchema: {
type: 'object',
properties: {
url: {
type: 'string',
description: 'URL to analyze',
},
waitForSelector: {
type: 'string',
description: 'Optional CSS selector to wait for before analysis',
},
userAgent: {
type: 'string',
description: 'Optional user agent string to use for the request',
},
},
required: ['url'],
},
},
{
name: 'simulate_colorblind',
description: 'Simulate how a webpage looks for colorblind users',
inputSchema: {
type: 'object',
properties: {
url: {
type: 'string',
description: 'URL to capture',
},
type: {
type: 'string',
enum: ['protanopia', 'deuteranopia', 'tritanopia'],
description: 'Type of color blindness to simulate',
},
outputPath: {
type: 'string',
description: 'Optional path to save the screenshot',
},
userAgent: {
type: 'string',
description: 'Optional user agent string to use for the request',
},
},
required: ['url', 'type'],
},
},
],
}));
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === 'check_accessibility') {
return this.handleAccessibilityCheck(request);
} else if (request.params.name === 'simulate_colorblind') {
return this.handleColorBlindSimulation(request);
} else {
throw new McpError(
ErrorCode.MethodNotFound,
`Unknown tool: ${request.params.name}`
);
}
});
}
private async handleColorBlindSimulation(request: any) {
if (!request.params.arguments ||
typeof request.params.arguments.url !== 'string' ||
typeof request.params.arguments.type !== 'string') {
throw new McpError(
ErrorCode.InvalidParams,
'URL and type parameters are required'
);
}
const args: SimulateColorblindArgs = {
url: request.params.arguments.url,
type: request.params.arguments.type as 'protanopia' | 'deuteranopia' | 'tritanopia',
outputPath: request.params.arguments.outputPath,
userAgent: request.params.arguments.userAgent
};
let browser;
try {
console.error('[Debug] Launching browser...');
browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-accelerated-2d-canvas',
'--disable-gpu',
'--window-size=1920,1080'
]
});
console.error('[Debug] Creating new page...');
const page = await browser.newPage();
await page.setViewport({ width: 1920, height: 1080 });
await page.setUserAgent(args.userAgent || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
// Enable request interception for debugging
await page.setRequestInterception(true);
page.on('request', request => {
console.error(`[Debug] Request: ${request.url()}`);
request.continue();
});
page.on('response', response => {
console.error(`[Debug] Response: ${response.url()} - ${response.status()}`);
});
page.on('console', msg => {
console.error(`[Page Console] ${msg.text()}`);
});
console.error('[Debug] Navigating to URL...');
const urlToUse = args.url.replace(/^(https?:\/\/)?(www\.)?/, 'https://www.');
console.error(`[Debug] Modified URL: ${urlToUse}`);
const response = await page.goto(urlToUse, {
waitUntil: 'networkidle2',
timeout: 120000 // Increased timeout to 2 minutes
});
console.error(`[Debug] Page loaded with status: ${response?.status()}`);
// Wait for the page to be fully loaded
console.error('[Debug] Waiting for page load...');
await page.waitForSelector('body', { timeout: 120000 });
// Give extra time for dynamic content
console.error('[Debug] Waiting for dynamic content...');
await new Promise(resolve => setTimeout(resolve, 5000));
// Inject the colorblind simulation code
console.error('[Debug] Injecting colorblind simulation...');
await page.evaluate((type) => {
// Implementation of colorblind simulation
function multiply(a: number[], b: number[]): number[] {
return [
a[0] * b[0] + a[1] * b[3] + a[2] * b[6],
a[0] * b[1] + a[1] * b[4] + a[2] * b[7],
a[0] * b[2] + a[1] * b[5] + a[2] * b[8],
];
}
const colorBlindnessMatrices = {
protanopia: [
0.567, 0.433, 0,
0.558, 0.442, 0,
0, 0.242, 0.758
],
deuteranopia: [
0.625, 0.375, 0,
0.7, 0.3, 0,
0, 0.3, 0.7
],
tritanopia: [
0.95, 0.05, 0,
0, 0.433, 0.567,
0, 0.475, 0.525
]
};
window.simulate = function(rgb: RGB, type: string): RGB {
const matrix = colorBlindnessMatrices[type as keyof typeof colorBlindnessMatrices];
const result = multiply([rgb.r / 255, rgb.g / 255, rgb.b / 255], matrix);
return {
r: Math.round(result[0] * 255),
g: Math.round(result[1] * 255),
b: Math.round(result[2] * 255)
};
};
function parseColor(color: string): RGB {
color = color.toLowerCase().replace(/\s/g, '');
if (color.startsWith('rgba(') || color.startsWith('rgb(')) {
const values = color
.replace('rgba(', '')
.replace('rgb(', '')
.replace(')', '')
.split(',')
.map(Number);
return {
r: values[0],
g: values[1],
b: values[2]
};
}
if (color.startsWith('#')) {
const hex = color.replace('#', '');
const r = parseInt(hex.substr(0, 2), 16);
const g = parseInt(hex.substr(2, 2), 16);
const b = parseInt(hex.substr(4, 2), 16);
return { r, g, b };
}
return { r: 0, g: 0, b: 0 };
}
function rgbToString(rgb: RGB): string {
return `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`;
}
// Get all elements with background color or color
const elements = document.querySelectorAll('*');
elements.forEach(el => {
const htmlEl = el as HTMLElement;
const styles = window.getComputedStyle(htmlEl);
const color = styles.color;
const backgroundColor = styles.backgroundColor;
if (color !== 'rgba(0, 0, 0, 0)') {
const rgbColor = parseColor(color);
const simulatedColor = window.simulate(rgbColor, type);
htmlEl.style.color = rgbToString(simulatedColor);
}
if (backgroundColor !== 'rgba(0, 0, 0, 0)') {
const rgbBgColor = parseColor(backgroundColor);
const simulatedBgColor = window.simulate(rgbBgColor, type);
htmlEl.style.backgroundColor = rgbToString(simulatedBgColor);
}
});
}, args.type);
// Wait for the filter to be applied
console.error('[Debug] Waiting for filter to apply...');
await new Promise(resolve => setTimeout(resolve, 2000));
// Get output directory from environment variable or use default
const outputDir = process.env.MCP_OUTPUT_DIR || './output';
console.error('[Debug] Taking screenshot...');
const outputPath = join(outputDir, args.outputPath || `colorblind_${args.type}.png`);
await page.screenshot({
path: outputPath,
fullPage: true
});
console.error('[Debug] Screenshot saved successfully');
await browser.close();
console.error('[Debug] Browser closed');
return {
content: [
{
type: 'text',
text: JSON.stringify({
url: args.url,
type: args.type,
outputPath: outputPath,
timestamp: new Date().toISOString(),
message: `Screenshot saved with ${args.type} simulation`
}, null, 2),
},
],
};
} catch (error) {
console.error('[Debug] Error occurred:', error);
if (browser) {
try {
await browser.close();
console.error('[Debug] Browser closed after error');
} catch (closeError) {
console.error('[Debug] Error closing browser:', closeError);
}
}
return {
content: [
{
type: 'text',
text: `Error simulating color blindness: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
}
}
private async handleAccessibilityCheck(request: any) {
if (!request.params.arguments || typeof request.params.arguments.url !== 'string') {
throw new McpError(
ErrorCode.InvalidParams,
'URL parameter is required'
);
}
const args: AnalyzeUrlArgs = {
url: request.params.arguments.url,
waitForSelector: typeof request.params.arguments.waitForSelector === 'string'
? request.params.arguments.waitForSelector
: undefined,
userAgent: typeof request.params.arguments.userAgent === 'string'
? request.params.arguments.userAgent
: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
};
try {
const browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-accelerated-2d-canvas',
'--disable-gpu',
'--window-size=1920,1080',
'--dns-prefetch-disable'
]
});
const page = await browser.newPage();
await page.setViewport({ width: 1920, height: 1080 });
await page.setUserAgent(args.userAgent || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
console.error(`[Debug] Navigating to ${args.url}`);
const urlToUse = args.url.replace(/^(https?:\/\/)?(www\.)?/, 'https://www.');
console.error(`[Debug] Modified URL: ${urlToUse}`);
const response = await page.goto(urlToUse, {
waitUntil: 'domcontentloaded',
timeout: 30000
});
console.error(`[Debug] Page loaded with status: ${response?.status()}`);
await page.waitForSelector('body', { timeout: 30000 });
await new Promise(resolve => setTimeout(resolve, 5000));
await page.evaluate(axe.source);
const results = await page.evaluate(() => {
return new Promise((resolve) => {
// @ts-ignore
window.axe.run((err: any, results: any) => {
if (err) {
resolve({ error: err });
}
resolve(results);
});
});
}) as {
violations: Array<{
impact: string;
description: string;
help: string;
helpUrl: string;
nodes: Array<{
html: string;
failureSummary: string;
}>;
}>;
passes: unknown[];
inapplicable: unknown[];
incomplete: unknown[];
};
await browser.close();
if ('error' in results) {
throw new Error(String(results.error));
}
const violations = results.violations.map(violation => ({
impact: violation.impact,
description: violation.description,
help: violation.help,
helpUrl: violation.helpUrl,
nodes: violation.nodes.map((node: any) => ({
html: node.html,
failureSummary: node.failureSummary,
})),
}));
return {
content: [
{
type: 'text',
text: JSON.stringify({
url: args.url,
timestamp: new Date().toISOString(),
violations,
passes: results.passes.length,
inapplicable: results.inapplicable.length,
incomplete: results.incomplete.length,
}, null, 2),
},
],
};
} catch (error) {
return {
content: [
{
type: 'text',
text: `Error analyzing URL: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
}
}
async run() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error('Web Accessibility MCP server running on stdio');
}
}
const server = new WebA11yServer();
server.run().catch(console.error);