Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save scottwalter-nice/88db0c6d087e0a7b659ca3e3962a085f to your computer and use it in GitHub Desktop.
Save scottwalter-nice/88db0c6d087e0a7b659ca3e3962a085f to your computer and use it in GitHub Desktop.
axe server
#!/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 WebSocket from 'ws';
interface ChromeTab {
id: string;
title: string;
url: string;
webSocketDebuggerUrl: string;
}
interface AxeResult {
violations: Array<{
id: string;
impact: string;
description: string;
help: string;
helpUrl: string;
nodes: Array<{
html: string;
target: string[];
failureSummary: string;
}>;
}>;
passes: Array<{
id: string;
description: string;
help: string;
}>;
incomplete: Array<{
id: string;
description: string;
help: string;
nodes: Array<{
html: string;
target: string[];
}>;
}>;
inapplicable: Array<{
id: string;
description: string;
help: string;
}>;
}
class ChromeMCPServer {
private server: Server;
private chromePort: number;
private currentTab: ChromeTab | null = null;
private ws: WebSocket | null = null;
private messageId = 1;
constructor(chromePort = 9222) {
this.chromePort = chromePort;
this.server = new Server(
{
name: "chrome-devtools-server",
version: "0.1.0",
},
{
capabilities: {
tools: {},
},
}
);
this.setupToolHandlers();
}
private setupToolHandlers() {
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "list_tabs",
description: "List all open tabs in the Chrome instance",
inputSchema: {
type: "object",
properties: {},
},
},
{
name: "connect_tab",
description: "Connect to a specific tab by ID or URL pattern",
inputSchema: {
type: "object",
properties: {
tabId: {
type: "string",
description: "Tab ID to connect to",
},
urlPattern: {
type: "string",
description: "URL pattern to match (alternative to tabId)",
},
},
},
},
{
name: "get_page_content",
description: "Get the HTML content of the current page",
inputSchema: {
type: "object",
properties: {},
},
},
{
name: "get_page_text",
description: "Get the text content of the current page",
inputSchema: {
type: "object",
properties: {},
},
},
{
name: "evaluate_js",
description: "Execute JavaScript on the current page",
inputSchema: {
type: "object",
properties: {
expression: {
type: "string",
description: "JavaScript expression to evaluate",
},
},
required: ["expression"],
},
},
{
name: "click_element",
description: "Click an element on the page",
inputSchema: {
type: "object",
properties: {
selector: {
type: "string",
description: "CSS selector for the element to click",
},
},
required: ["selector"],
},
},
{
name: "type_text",
description: "Type text into an input field",
inputSchema: {
type: "object",
properties: {
selector: {
type: "string",
description: "CSS selector for the input element",
},
text: {
type: "string",
description: "Text to type",
},
},
required: ["selector", "text"],
},
},
{
name: "take_screenshot",
description: "Take a screenshot of the current page",
inputSchema: {
type: "object",
properties: {
fullPage: {
type: "boolean",
description: "Whether to capture the full page or just viewport",
default: false,
},
},
},
},
{
name: "check_accessibility",
description: "Run axe-core accessibility audit on the current page",
inputSchema: {
type: "object",
properties: {
selector: {
type: "string",
description: "CSS selector to limit audit scope (optional, defaults to entire page)",
},
tags: {
type: "array",
items: {
type: "string"
},
description: "Accessibility tags to test (e.g., ['wcag2a', 'wcag2aa', 'wcag21aa'])",
},
rules: {
type: "object",
description: "Custom rules configuration (optional)",
},
includeViolationsOnly: {
type: "boolean",
description: "Whether to include only violations in the result (default: false)",
default: false,
},
},
},
},
],
};
});
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case "list_tabs":
return await this.listTabs();
case "connect_tab":
return await this.connectTab(args?.tabId, args?.urlPattern);
case "get_page_content":
return await this.getPageContent();
case "get_page_text":
return await this.getPageText();
case "evaluate_js":
return await this.evaluateJS(args?.expression);
case "click_element":
return await this.clickElement(args?.selector);
case "type_text":
return await this.typeText(args?.selector, args?.text);
case "take_screenshot":
return await this.takeScreenshot(args?.fullPage);
case "check_accessibility":
return await this.checkAccessibility(
args?.selector,
args?.tags,
args?.rules,
args?.includeViolationsOnly
);
default:
throw new McpError(
ErrorCode.MethodNotFound,
`Unknown tool: ${name}`
);
}
} catch (error) {
throw new McpError(
ErrorCode.InternalError,
`Error executing ${name}: ${error instanceof Error ? error.message : String(error)}`
);
}
});
}
private async fetchFromChrome(endpoint: string): Promise<any> {
const response = await fetch(`http://localhost:${this.chromePort}${endpoint}`);
if (!response.ok) {
throw new Error(`Chrome DevTools API error: ${response.statusText}`);
}
return response.json();
}
private async sendDevToolsCommand(method: string, params: any = {}): Promise<any> {
if (!this.ws) {
throw new Error("Not connected to a tab");
}
return new Promise((resolve, reject) => {
const id = this.messageId++;
const message = JSON.stringify({ id, method, params });
const timeout = setTimeout(() => {
reject(new Error("DevTools command timeout"));
}, 10000);
const handler = (data: WebSocket.Data) => {
try {
const response = JSON.parse(data.toString());
if (response.id === id) {
clearTimeout(timeout);
this.ws?.removeListener('message', handler);
if (response.error) {
reject(new Error(response.error.message));
} else {
resolve(response.result);
}
}
} catch (e) {
// Ignore parsing errors for non-matching messages
}
};
this.ws.on('message', handler);
this.ws.send(message);
});
}
private async listTabs() {
const tabs = await this.fetchFromChrome('/json/list');
const formattedTabs = tabs.map((tab: any) => ({
id: tab.id,
title: tab.title,
url: tab.url,
type: tab.type,
}));
return {
content: [
{
type: "text",
text: `Found ${tabs.length} tabs:\n\n${formattedTabs
.map(tab => `ID: ${tab.id}\nTitle: ${tab.title}\nURL: ${tab.url}\nType: ${tab.type}\n`)
.join('\n')}`,
},
],
};
}
private async connectTab(tabId?: string, urlPattern?: string) {
const tabs = await this.fetchFromChrome('/json/list');
let targetTab;
if (tabId) {
targetTab = tabs.find((tab: any) => tab.id === tabId);
} else if (urlPattern) {
targetTab = tabs.find((tab: any) => tab.url.includes(urlPattern));
} else {
throw new Error("Either tabId or urlPattern must be provided");
}
if (!targetTab) {
throw new Error("Tab not found");
}
// Close existing connection
if (this.ws) {
this.ws.close();
}
// Connect to the tab's WebSocket
this.ws = new WebSocket(targetTab.webSocketDebuggerUrl);
await new Promise((resolve, reject) => {
this.ws!.on('open', resolve);
this.ws!.on('error', reject);
});
// Enable necessary domains
await this.sendDevToolsCommand('Runtime.enable');
await this.sendDevToolsCommand('Page.enable');
await this.sendDevToolsCommand('DOM.enable');
this.currentTab = {
id: targetTab.id,
title: targetTab.title,
url: targetTab.url,
webSocketDebuggerUrl: targetTab.webSocketDebuggerUrl,
};
return {
content: [
{
type: "text",
text: `Connected to tab: ${targetTab.title}\nURL: ${targetTab.url}`,
},
],
};
}
private async getPageContent() {
if (!this.currentTab) {
throw new Error("No tab connected");
}
const result = await this.sendDevToolsCommand('Runtime.evaluate', {
expression: 'document.documentElement.outerHTML',
returnByValue: true,
});
return {
content: [
{
type: "text",
text: result.result.value,
},
],
};
}
private async getPageText() {
if (!this.currentTab) {
throw new Error("No tab connected");
}
const result = await this.sendDevToolsCommand('Runtime.evaluate', {
expression: 'document.body.innerText',
returnByValue: true,
});
return {
content: [
{
type: "text",
text: result.result.value,
},
],
};
}
private async evaluateJS(expression: string) {
if (!this.currentTab) {
throw new Error("No tab connected");
}
const result = await this.sendDevToolsCommand('Runtime.evaluate', {
expression,
returnByValue: true,
});
return {
content: [
{
type: "text",
text: JSON.stringify(result.result.value, null, 2),
},
],
};
}
private async clickElement(selector: string) {
if (!this.currentTab) {
throw new Error("No tab connected");
}
const result = await this.sendDevToolsCommand('Runtime.evaluate', {
expression: `
const element = document.querySelector('${selector}');
if (element) {
element.click();
'Element clicked successfully';
} else {
'Element not found';
}
`,
returnByValue: true,
});
return {
content: [
{
type: "text",
text: result.result.value,
},
],
};
}
private async typeText(selector: string, text: string) {
if (!this.currentTab) {
throw new Error("No tab connected");
}
const result = await this.sendDevToolsCommand('Runtime.evaluate', {
expression: `
const element = document.querySelector('${selector}');
if (element) {
element.focus();
element.value = '${text.replace(/'/g, "\\'")}';
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
'Text typed successfully';
} else {
'Element not found';
}
`,
returnByValue: true,
});
return {
content: [
{
type: "text",
text: result.result.value,
},
],
};
}
private async takeScreenshot(fullPage = false) {
if (!this.currentTab) {
throw new Error("No tab connected");
}
const result = await this.sendDevToolsCommand('Page.captureScreenshot', {
format: 'png',
captureBeyondViewport: fullPage,
});
return {
content: [
{
type: "text",
text: `Screenshot captured (base64): ${result.data.substring(0, 100)}...`,
},
],
};
}
private async checkAccessibility(
selector?: string,
tags?: string[],
rules?: any,
includeViolationsOnly = false
) {
if (!this.currentTab) {
throw new Error("No tab connected");
}
// First, inject axe-core into the page if it's not already loaded
const axeLoadExpression = `
(function() {
if (typeof axe === 'undefined') {
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/[email protected]/axe.min.js';
document.head.appendChild(script);
return new Promise((resolve) => {
script.onload = () => resolve('axe-core loaded');
script.onerror = () => resolve('Failed to load axe-core');
});
} else {
return Promise.resolve('axe-core already available');
}
})()
`;
const loadResult = await this.sendDevToolsCommand('Runtime.evaluate', {
expression: axeLoadExpression,
awaitPromise: true,
returnByValue: true,
});
if (loadResult.result.value.includes('Failed')) {
throw new Error('Could not load axe-core library');
}
// Wait a moment for the script to fully load
await new Promise(resolve => setTimeout(resolve, 1000));
// Build the axe configuration
const config: any = {};
if (tags) {
config.tags = tags;
}
if (rules) {
config.rules = rules;
}
const axeExpression = `
(async function() {
try {
const context = ${selector ? `'${selector}'` : 'document'};
const options = ${JSON.stringify(config)};
const results = await axe.run(context, options);
return {
success: true,
results: results,
summary: {
violations: results.violations.length,
passes: results.passes.length,
incomplete: results.incomplete.length,
inapplicable: results.inapplicable.length
}
};
} catch (error) {
return {
success: false,
error: error.message
};
}
})()
`;
const result = await this.sendDevToolsCommand('Runtime.evaluate', {
expression: axeExpression,
awaitPromise: true,
returnByValue: true,
});
const axeResults = result.result.value;
if (!axeResults.success) {
throw new Error(`Accessibility check failed: ${axeResults.error}`);
}
// Format the results for better readability
const formatResults = (axeData: AxeResult) => {
let output = `Accessibility Audit Results for: ${this.currentTab?.title}\n`;
output += `URL: ${this.currentTab?.url}\n`;
output += `Scope: ${selector || 'Entire page'}\n\n`;
output += `Summary:\n`;
output += `- Violations: ${axeData.violations.length}\n`;
output += `- Passes: ${axeData.passes.length}\n`;
output += `- Incomplete: ${axeData.incomplete.length}\n`;
output += `- Inapplicable: ${axeData.inapplicable.length}\n\n`;
if (axeData.violations.length > 0) {
output += `VIOLATIONS (${axeData.violations.length}):\n`;
output += '='.repeat(50) + '\n';
axeData.violations.forEach((violation, index) => {
output += `\n${index + 1}. ${violation.id} (${violation.impact})\n`;
output += ` Description: ${violation.description}\n`;
output += ` Help: ${violation.help}\n`;
output += ` More info: ${violation.helpUrl}\n`;
output += ` Affected elements: ${violation.nodes.length}\n`;
violation.nodes.slice(0, 3).forEach((node, nodeIndex) => {
output += ` Element ${nodeIndex + 1}: ${node.target.join(', ')}\n`;
output += ` HTML: ${node.html.substring(0, 100)}${node.html.length > 100 ? '...' : ''}\n`;
if (node.failureSummary) {
output += ` Issue: ${node.failureSummary}\n`;
}
});
if (violation.nodes.length > 3) {
output += ` ... and ${violation.nodes.length - 3} more elements\n`;
}
output += '\n';
});
}
if (!includeViolationsOnly) {
if (axeData.incomplete.length > 0) {
output += `\nINCOMPLETE CHECKS (${axeData.incomplete.length}):\n`;
output += '='.repeat(50) + '\n';
axeData.incomplete.forEach((incomplete, index) => {
output += `\n${index + 1}. ${incomplete.id}\n`;
output += ` Description: ${incomplete.description}\n`;
output += ` Help: ${incomplete.help}\n`;
output += ` Elements requiring manual review: ${incomplete.nodes.length}\n`;
});
}
output += `\n\nPASSED CHECKS (${axeData.passes.length}):\n`;
output += '='.repeat(50) + '\n';
axeData.passes.forEach((pass, index) => {
output += `${index + 1}. ${pass.id} - ${pass.description}\n`;
});
}
return output;
};
return {
content: [
{
type: "text",
text: formatResults(axeResults.results),
},
],
};
}
async run() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error("Chrome DevTools MCP server running on stdio");
}
}
// Check if Chrome is running with remote debugging
async function checkChromeConnection(port = 9222) {
try {
const response = await fetch(`http://localhost:${port}/json/version`);
if (response.ok) {
const version = await response.json();
console.error(`Connected to Chrome ${version.Browser}`);
return true;
}
} catch (error) {
console.error(`Chrome not found on port ${port}. Start Chrome with: --remote-debugging-port=${port}`);
return false;
}
return false;
}
// Main execution
async function main() {
const port = process.env.CHROME_DEBUG_PORT ? parseInt(process.env.CHROME_DEBUG_PORT) : 9222;
if (await checkChromeConnection(port)) {
const server = new ChromeMCPServer(port);
await server.run();
} else {
process.exit(1);
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((error) => {
console.error("Server error:", error);
process.exit(1);
});
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment