Created
July 8, 2025 16:06
-
-
Save scottwalter-nice/88db0c6d087e0a7b659ca3e3962a085f to your computer and use it in GitHub Desktop.
axe server
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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