accessibility-scanner-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +73 -0
- package/package.json +39 -0
- package/server.mjs +180 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Chris Morris
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# accessibility-scanner-mcp
|
|
2
|
+
|
|
3
|
+
An [MCP](https://modelcontextprotocol.io) server that lets an AI agent scan a web page for
|
|
4
|
+
**WCAG accessibility issues** and get back findings it can act on. The agent calls one tool with a
|
|
5
|
+
URL; it gets every violation grouped by severity, each with the **exact element selector, the
|
|
6
|
+
offending HTML, the specific failure, the WCAG success criterion, and a fix-guide link** — plus the
|
|
7
|
+
items that still need human review.
|
|
8
|
+
|
|
9
|
+
It runs the real [axe-core](https://github.com/dequelabs/axe-core) engine in your **local Chrome**
|
|
10
|
+
(via `playwright-core`), so nothing about the pages you scan leaves your machine. It also resolves
|
|
11
|
+
color contrast over CSS gradients, which most tools leave as "needs review."
|
|
12
|
+
|
|
13
|
+
Part of [accessibilityscanner.app](https://accessibilityscanner.app).
|
|
14
|
+
|
|
15
|
+
## Requirements
|
|
16
|
+
|
|
17
|
+
- Node.js 18+
|
|
18
|
+
- Google Chrome installed (or set the `CHROME_PATH` environment variable to a Chromium binary)
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
Add it to your MCP client's config. No global install needed — `npx` fetches it on first run.
|
|
23
|
+
|
|
24
|
+
**Claude Desktop** (`claude_desktop_config.json`), **Cursor**, **Claude Code**, or any MCP client:
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"mcpServers": {
|
|
29
|
+
"accessibility-scanner": {
|
|
30
|
+
"command": "npx",
|
|
31
|
+
"args": ["-y", "accessibility-scanner-mcp"]
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
If Chrome is not auto-detected, add an env block:
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"mcpServers": {
|
|
42
|
+
"accessibility-scanner": {
|
|
43
|
+
"command": "npx",
|
|
44
|
+
"args": ["-y", "accessibility-scanner-mcp"],
|
|
45
|
+
"env": { "CHROME_PATH": "/usr/bin/google-chrome" }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## The tool
|
|
52
|
+
|
|
53
|
+
### `scan_accessibility`
|
|
54
|
+
|
|
55
|
+
| Input | |
|
|
56
|
+
|---|---|
|
|
57
|
+
| `url` (string, required) | The http(s) URL to scan. |
|
|
58
|
+
|
|
59
|
+
Returns a report grouped by severity. For each rule: the WCAG criterion, a fix-guide link, and per
|
|
60
|
+
element the selector, HTML, and exact failure. Example flow with an agent:
|
|
61
|
+
|
|
62
|
+
> **You:** Audit https://example.com for accessibility and fix what you can.
|
|
63
|
+
> **Agent:** *(calls `scan_accessibility`)* → reads the findings → edits the code → re-scans.
|
|
64
|
+
|
|
65
|
+
## Honest about limits
|
|
66
|
+
|
|
67
|
+
Automated testing covers the machine-checkable subset of WCAG (most of the issues on a typical
|
|
68
|
+
page, but not all of it). Items that need human judgement are returned under "Needs manual review."
|
|
69
|
+
It never claims a page is "compliant."
|
|
70
|
+
|
|
71
|
+
## License
|
|
72
|
+
|
|
73
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "accessibility-scanner-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server that scans web pages for WCAG accessibility issues with axe-core, so AI agents can audit and fix accessibility. By accessibilityscanner.app",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"accessibility-scanner-mcp": "server.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"server.mjs",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=18"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"mcp",
|
|
18
|
+
"model-context-protocol",
|
|
19
|
+
"accessibility",
|
|
20
|
+
"a11y",
|
|
21
|
+
"wcag",
|
|
22
|
+
"axe-core",
|
|
23
|
+
"ai-agent"
|
|
24
|
+
],
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
27
|
+
"axe-core": "^4.12.1",
|
|
28
|
+
"playwright-core": "^1.61.0"
|
|
29
|
+
},
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"homepage": "https://accessibilityscanner.app",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/Bishop81/accessibility-scanner-mcp.git"
|
|
35
|
+
},
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/Bishop81/accessibility-scanner-mcp/issues"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/server.mjs
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// MCP server: exposes an accessibility scan tool so an AI agent can audit a web page
|
|
3
|
+
// (axe-core, WCAG 2.2 A & AA) and get per-element selectors + fixes, ready to act on.
|
|
4
|
+
// Runs locally using your system Chrome via playwright-core. By accessibilityscanner.app.
|
|
5
|
+
|
|
6
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
7
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
8
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
9
|
+
import { chromium } from 'playwright-core';
|
|
10
|
+
import { createRequire } from 'module';
|
|
11
|
+
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
const axePath = require.resolve('axe-core');
|
|
14
|
+
|
|
15
|
+
async function scan(url, { timeoutMs = 30000, chromePath = process.env.CHROME_PATH || '' } = {}) {
|
|
16
|
+
if (!/^https?:\/\//i.test(url)) throw new Error('A valid http(s) URL is required.');
|
|
17
|
+
|
|
18
|
+
let browser;
|
|
19
|
+
try {
|
|
20
|
+
browser = await chromium.launch({
|
|
21
|
+
executablePath: chromePath || undefined,
|
|
22
|
+
channel: chromePath ? undefined : 'chrome',
|
|
23
|
+
args: ['--no-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
|
|
24
|
+
});
|
|
25
|
+
} catch (e) {
|
|
26
|
+
throw new Error(`Could not launch Chrome. Install Google Chrome or set CHROME_PATH. (${e?.message || e})`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const ctx = await browser.newContext({
|
|
31
|
+
userAgent: 'Mozilla/5.0 (compatible; A11yScanBot/0.1; +accessibilityscanner.app)',
|
|
32
|
+
});
|
|
33
|
+
const page = await ctx.newPage();
|
|
34
|
+
const res = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: timeoutMs });
|
|
35
|
+
try { await page.waitForLoadState('networkidle', { timeout: 8000 }); } catch { /* chatty page */ }
|
|
36
|
+
await page.addScriptTag({ path: axePath });
|
|
37
|
+
|
|
38
|
+
const out = await page.evaluate(async () => {
|
|
39
|
+
const r = await window.axe.run(document, {
|
|
40
|
+
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa', 'best-practice'] },
|
|
41
|
+
resultTypes: ['violations', 'incomplete', 'passes'],
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Resolve color-contrast axe leaves "incomplete" over CSS gradients (worst-case at a
|
|
45
|
+
// stop -> real pass/fail). Mirrors scripts/scan.mjs. Images/translucent gradients stay incomplete.
|
|
46
|
+
try {
|
|
47
|
+
const ci = r.incomplete.findIndex((x) => x.id === 'color-contrast');
|
|
48
|
+
if (ci !== -1) {
|
|
49
|
+
const entry = r.incomplete[ci];
|
|
50
|
+
const parseRgb = (s) => { const m = (s || '').match(/rgba?\(([^)]+)\)/i); if (!m) return null; const p = m[1].split(',').map((x) => parseFloat(x)); return { r: p[0], g: p[1], b: p[2], a: p.length > 3 ? p[3] : 1 }; };
|
|
51
|
+
const lin = (c) => { c /= 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); };
|
|
52
|
+
const lum = (c) => 0.2126 * lin(c.r) + 0.7152 * lin(c.g) + 0.0722 * lin(c.b);
|
|
53
|
+
const contrast = (a, b) => { const hi = Math.max(lum(a), lum(b)), lo = Math.min(lum(a), lum(b)); return (hi + 0.05) / (lo + 0.05); };
|
|
54
|
+
const keep = [], failed = [];
|
|
55
|
+
for (const node of entry.nodes) {
|
|
56
|
+
try {
|
|
57
|
+
const sel = Array.isArray(node.target) ? node.target[node.target.length - 1] : node.target;
|
|
58
|
+
const el = document.querySelector(sel);
|
|
59
|
+
if (!el) { keep.push(node); continue; }
|
|
60
|
+
const cs = getComputedStyle(el);
|
|
61
|
+
const fg = parseRgb(cs.color);
|
|
62
|
+
if (!fg) { keep.push(node); continue; }
|
|
63
|
+
const fontPx = parseFloat(cs.fontSize) || 16, weight = parseInt(cs.fontWeight, 10) || 400;
|
|
64
|
+
const required = (fontPx >= 24 || (fontPx >= 18.66 && weight >= 700)) ? 3 : 4.5;
|
|
65
|
+
let bg = null;
|
|
66
|
+
for (let hop = el; hop; hop = hop.parentElement) { const bi = getComputedStyle(hop).backgroundImage; if (bi && bi.indexOf('gradient(') !== -1) { bg = bi; break; } }
|
|
67
|
+
if (!bg || bg.indexOf('url(') !== -1) { keep.push(node); continue; }
|
|
68
|
+
const stops = (bg.match(/rgba?\([^)]+\)/gi) || []).map(parseRgb).filter(Boolean);
|
|
69
|
+
if (!stops.length || stops.some((s) => s.a < 1)) { keep.push(node); continue; }
|
|
70
|
+
let worst = Infinity; for (const s of stops) worst = Math.min(worst, contrast(fg, s));
|
|
71
|
+
if (worst < required) { node.failureSummary = `Background is a gradient; lowest-contrast point is ${worst.toFixed(2)}:1, below the required ${required}:1.`; failed.push(node); }
|
|
72
|
+
} catch (e) { keep.push(node); }
|
|
73
|
+
}
|
|
74
|
+
if (keep.length) { entry.nodes = keep; } else { r.incomplete.splice(ci, 1); }
|
|
75
|
+
if (failed.length) {
|
|
76
|
+
let v = r.violations.find((x) => x.id === 'color-contrast');
|
|
77
|
+
if (!v) { v = { id: entry.id, impact: entry.impact || 'serious', help: entry.help, helpUrl: entry.helpUrl, tags: entry.tags, nodes: [] }; r.violations.push(v); }
|
|
78
|
+
for (const n of failed) v.nodes.push(n);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
} catch (e) { /* never break the scan */ }
|
|
82
|
+
|
|
83
|
+
const wcag = (tags) => (tags || []).map((t) => { const m = t.match(/^wcag(\d)(\d)(\d{1,2})$/); return m ? `${m[1]}.${m[2]}.${m[3]}` : null; }).filter(Boolean);
|
|
84
|
+
const slim = (items) => items.map((v) => ({
|
|
85
|
+
rule: v.id, impact: v.impact || 'minor', help: v.help, helpUrl: v.helpUrl,
|
|
86
|
+
wcag: wcag(v.tags), elementCount: v.nodes.length,
|
|
87
|
+
elements: v.nodes.slice(0, 25).map((n) => ({
|
|
88
|
+
selector: Array.isArray(n.target) ? n.target.join(' ') : String(n.target),
|
|
89
|
+
html: (n.html || '').slice(0, 300),
|
|
90
|
+
issue: (n.failureSummary || '').replace(/^Fix (any|all) of the following:\s*/i, '').trim(),
|
|
91
|
+
})),
|
|
92
|
+
}));
|
|
93
|
+
|
|
94
|
+
return { violations: slim(r.violations), needsReview: slim(r.incomplete), passes: r.passes.length, engine: r.testEngine && r.testEngine.version };
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return { url, httpStatus: res ? res.status() : null, ...out };
|
|
98
|
+
} finally {
|
|
99
|
+
await browser.close();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function formatReport(s) {
|
|
104
|
+
const order = ['critical', 'serious', 'moderate', 'minor'];
|
|
105
|
+
const total = s.violations.reduce((a, v) => a + (order.includes(v.impact) ? 1 : 0), 0) || s.violations.length;
|
|
106
|
+
const lines = [];
|
|
107
|
+
lines.push(`# Accessibility scan: ${s.url}`);
|
|
108
|
+
lines.push(`HTTP ${s.httpStatus ?? 'n/a'} · axe-core ${s.engine || ''} · WCAG 2.2 A & AA`);
|
|
109
|
+
lines.push('');
|
|
110
|
+
lines.push(`${s.violations.length} violation rule(s), ${s.needsReview.length} item(s) needing manual review, ${s.passes} checks passed.`);
|
|
111
|
+
lines.push('> Automated testing covers the machine-checkable subset of WCAG. The needs-review items require human judgement.');
|
|
112
|
+
|
|
113
|
+
const byImpact = {};
|
|
114
|
+
for (const v of s.violations) (byImpact[v.impact] || (byImpact[v.impact] = [])).push(v);
|
|
115
|
+
const section = (title, items) => {
|
|
116
|
+
if (!items.length) return;
|
|
117
|
+
lines.push('', `## ${title}`);
|
|
118
|
+
for (const v of items) {
|
|
119
|
+
lines.push('', `### ${v.rule} — ${v.help} (${v.elementCount} element${v.elementCount === 1 ? '' : 's'})`);
|
|
120
|
+
if (v.wcag.length) lines.push(`WCAG: ${v.wcag.join(', ')}`);
|
|
121
|
+
if (v.helpUrl) lines.push(`Fix guide: ${v.helpUrl}`);
|
|
122
|
+
for (const el of v.elements) {
|
|
123
|
+
lines.push(`- selector: \`${el.selector}\``);
|
|
124
|
+
if (el.html) lines.push(` html: \`${el.html.replace(/`/g, "'")}\``);
|
|
125
|
+
if (el.issue) lines.push(` issue: ${el.issue.replace(/\n+/g, ' ')}`);
|
|
126
|
+
}
|
|
127
|
+
if (v.elementCount > v.elements.length) lines.push(` (+${v.elementCount - v.elements.length} more element(s))`);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
for (const imp of order) section(imp[0].toUpperCase() + imp.slice(1), byImpact[imp] || []);
|
|
131
|
+
// any non-standard impacts
|
|
132
|
+
section('Other', s.violations.filter((v) => !order.includes(v.impact)));
|
|
133
|
+
section('Needs manual review', s.needsReview);
|
|
134
|
+
lines.push('', '---', 'Generated by accessibility-scanner-mcp · https://accessibilityscanner.app');
|
|
135
|
+
return lines.join('\n');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const server = new Server(
|
|
139
|
+
{ name: 'accessibility-scanner', version: '0.1.0' },
|
|
140
|
+
{ capabilities: { tools: {} } },
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
144
|
+
tools: [
|
|
145
|
+
{
|
|
146
|
+
name: 'scan_accessibility',
|
|
147
|
+
description:
|
|
148
|
+
'Scan a web page for WCAG 2.2 (A & AA) accessibility issues using axe-core in a real browser. ' +
|
|
149
|
+
'Returns violations grouped by severity, each with the exact element selector, the offending HTML, the ' +
|
|
150
|
+
'specific failure, the WCAG success criterion, and a fix-guide link — ready to act on. Also lists items ' +
|
|
151
|
+
'that need manual human review. Use this to audit a page and then fix the issues. Requires Google Chrome ' +
|
|
152
|
+
'installed locally (or set the CHROME_PATH environment variable).',
|
|
153
|
+
inputSchema: {
|
|
154
|
+
type: 'object',
|
|
155
|
+
properties: {
|
|
156
|
+
url: { type: 'string', description: 'The http(s) URL of the page to scan.' },
|
|
157
|
+
},
|
|
158
|
+
required: ['url'],
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
],
|
|
162
|
+
}));
|
|
163
|
+
|
|
164
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
165
|
+
if (request.params.name !== 'scan_accessibility') {
|
|
166
|
+
throw new Error(`Unknown tool: ${request.params.name}`);
|
|
167
|
+
}
|
|
168
|
+
const url = request.params.arguments?.url;
|
|
169
|
+
try {
|
|
170
|
+
const result = await scan(String(url));
|
|
171
|
+
return { content: [{ type: 'text', text: formatReport(result) }] };
|
|
172
|
+
} catch (e) {
|
|
173
|
+
return { content: [{ type: 'text', text: `Scan failed: ${e?.message || e}` }], isError: true };
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const transport = new StdioServerTransport();
|
|
178
|
+
await server.connect(transport);
|
|
179
|
+
// stderr is safe for logs (stdout is the MCP transport).
|
|
180
|
+
console.error('accessibility-scanner-mcp running on stdio');
|