@icjia/contrastcap 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 +250 -0
- package/package.json +46 -0
- package/src/browser.js +58 -0
- package/src/cli.js +125 -0
- package/src/config.js +69 -0
- package/src/engine/axeRunner.js +73 -0
- package/src/engine/colorSuggest.js +104 -0
- package/src/engine/contrastCalc.js +63 -0
- package/src/engine/pixelSampler.js +94 -0
- package/src/server.js +190 -0
- package/src/tools/auditPage.js +238 -0
- package/src/tools/checkElementContrast.js +99 -0
- package/src/tools/checkPageContrast.js +26 -0
- package/src/tools/getContrastSummary.js +26 -0
- package/src/utils/formatResults.js +106 -0
- package/src/utils/largeText.js +14 -0
- package/src/utils/sanitizeError.js +37 -0
- package/src/utils/urlValidate.js +35 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Illinois Criminal Justice Information Authority (ICJIA)
|
|
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,250 @@
|
|
|
1
|
+
# @icjia/contrastcap
|
|
2
|
+
|
|
3
|
+
**MCP server for automated WCAG contrast auditing via pixel-level analysis.**
|
|
4
|
+
|
|
5
|
+
`contrastcap` resolves the "needs review" gap that axe-core and SiteImprove leave behind. When text sits over a complex background (gradient, image, semi-transparent overlay), axe can't determine the rendered contrast ratio from the DOM alone and marks the element `incomplete`. `contrastcap` loads the page in headless Chromium, screenshots the element region with the text hidden, samples actual rendered pixels, and returns a decisive pass / fail / warning with a concrete hex color suggestion for failures.
|
|
6
|
+
|
|
7
|
+
Built for the same triage workflow as `@icjia/lightcap` and `@icjia/viewcap` — stdio transport, ESM, minimal token footprint, `get_status` tool, `publish.sh`.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pnpm install
|
|
15
|
+
# Playwright's Chromium is fetched automatically via postinstall.
|
|
16
|
+
# If that fails (offline, CI), run manually:
|
|
17
|
+
pnpm exec playwright install chromium
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Requires Node 20+.
|
|
21
|
+
|
|
22
|
+
## Claude Desktop / Claude Code configuration
|
|
23
|
+
|
|
24
|
+
Add to `claude_desktop_config.json` (or your IDE's MCP config):
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"mcpServers": {
|
|
29
|
+
"contrastcap": {
|
|
30
|
+
"command": "npx",
|
|
31
|
+
"args": ["-y", "@icjia/contrastcap"]
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Or, pointing at a local checkout:
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"mcpServers": {
|
|
42
|
+
"contrastcap": {
|
|
43
|
+
"command": "node",
|
|
44
|
+
"args": ["/absolute/path/to/contrastcap-mcp/src/server.js"]
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Restart Claude to pick up the new server.
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Tools
|
|
55
|
+
|
|
56
|
+
All four tools default to **WCAG AA**. `AAA` must be explicitly requested via `level: "AAA"`.
|
|
57
|
+
|
|
58
|
+
### `get_contrast_summary`
|
|
59
|
+
|
|
60
|
+
Counts only — the cheapest token footprint. Use this first to decide whether a full audit is warranted.
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{ "url": "https://example.com/about" }
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{
|
|
70
|
+
"url": "https://example.com/about",
|
|
71
|
+
"timestamp": "2026-04-13T14:30:00Z",
|
|
72
|
+
"wcag_level": "AA",
|
|
73
|
+
"counts": {
|
|
74
|
+
"total_elements_checked": 52,
|
|
75
|
+
"pass": 47,
|
|
76
|
+
"fail": 3,
|
|
77
|
+
"warning": 2,
|
|
78
|
+
"skipped": 0
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### `check_page_contrast`
|
|
84
|
+
|
|
85
|
+
Full page audit. Returns detail for failures and warnings only — passing elements are counted, not itemized.
|
|
86
|
+
|
|
87
|
+
```json
|
|
88
|
+
{ "url": "https://example.com/about", "level": "AA" }
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
{
|
|
95
|
+
"url": "...",
|
|
96
|
+
"timestamp": "...",
|
|
97
|
+
"wcag_level": "AA",
|
|
98
|
+
"summary": { "total": 52, "pass": 47, "fail": 3, "warning": 2, "skipped": 0 },
|
|
99
|
+
"failures": [
|
|
100
|
+
{
|
|
101
|
+
"selector": "nav.main-nav > ul > li:nth-child(3) > a",
|
|
102
|
+
"text": "Grant Opportunities",
|
|
103
|
+
"ratio": 3.21,
|
|
104
|
+
"required": 4.5,
|
|
105
|
+
"level": "AA",
|
|
106
|
+
"fontSize": "14px",
|
|
107
|
+
"fontWeight": "400",
|
|
108
|
+
"isLargeText": false,
|
|
109
|
+
"foreground": "#6c757d",
|
|
110
|
+
"background": "#e9ecef",
|
|
111
|
+
"backgroundSource": "pixel-sample",
|
|
112
|
+
"suggestion": "#595f64"
|
|
113
|
+
}
|
|
114
|
+
],
|
|
115
|
+
"warnings": [
|
|
116
|
+
{
|
|
117
|
+
"selector": ".hero-banner h1",
|
|
118
|
+
"text": "Criminal Justice Information…",
|
|
119
|
+
"ratio": 4.62,
|
|
120
|
+
"required": 4.5,
|
|
121
|
+
"level": "AA",
|
|
122
|
+
"foreground": "#ffffff",
|
|
123
|
+
"background": "#5a7a91",
|
|
124
|
+
"backgroundSource": "pixel-sample-over-image",
|
|
125
|
+
"note": "Ratio within 0.3 of threshold — marginal. Background sampled from gradient or image — may vary at other positions."
|
|
126
|
+
}
|
|
127
|
+
]
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**Suggestion format is always hex** (e.g. `"#595f64"`). The caller formats prose.
|
|
132
|
+
|
|
133
|
+
### `check_element_contrast`
|
|
134
|
+
|
|
135
|
+
Single-element check. Use this to verify a fix without re-running the full page audit.
|
|
136
|
+
|
|
137
|
+
```json
|
|
138
|
+
{
|
|
139
|
+
"url": "http://localhost:3000/about",
|
|
140
|
+
"selector": "nav.main-nav > ul > li:nth-child(3) > a"
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Returns a single-element object with `pass: true|false`, the measured `ratio`, `foreground`, `background`, and a `suggestion` hex if failing.
|
|
145
|
+
|
|
146
|
+
### `get_status`
|
|
147
|
+
|
|
148
|
+
Server + axe-core + Playwright versions, plus a non-blocking npm update check.
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## How it works
|
|
153
|
+
|
|
154
|
+
1. Playwright navigates to the URL (30s timeout, `networkidle` fallback to `load`).
|
|
155
|
+
2. The server re-validates `page.url()` against the SSRF denylist (redirect guard).
|
|
156
|
+
3. axe-core is injected via `page.evaluate` and run with `color-contrast` only. Its `violations` (definite failures) and `passes` (definite passes) are trusted as-is.
|
|
157
|
+
4. For every `incomplete` (needs-review) node:
|
|
158
|
+
- Scroll into view
|
|
159
|
+
- Read computed `color`, `fontSize` (always resolved to px), `fontWeight`
|
|
160
|
+
- Save the element's prior inline `color`, set it to `transparent`, screenshot the bounding box, then restore
|
|
161
|
+
- Decode pixels via `sharp`, sample on a 5×3 grid
|
|
162
|
+
- If per-channel stddev > 15, treat as gradient/image and use worst-case pixel (darkest on light text, lightest on dark text)
|
|
163
|
+
- Otherwise take the median per channel
|
|
164
|
+
- Compute the WCAG 2.1 ratio and compare against the required threshold
|
|
165
|
+
5. For failures, compute a hex color suggestion via 16-iteration HSL-lightness binary search in both directions; return whichever candidate has the smaller `|ΔL|` from the original foreground.
|
|
166
|
+
6. Passes bump the `pass` count. Marginal passes or high-variance backgrounds are flagged as warnings, not failures.
|
|
167
|
+
|
|
168
|
+
### Limits & timeouts
|
|
169
|
+
|
|
170
|
+
| Scope | Limit |
|
|
171
|
+
|-------|-------|
|
|
172
|
+
| Page navigation | 30 s |
|
|
173
|
+
| Per-element pixel sampling | 5 s (skipped on timeout, audit continues) |
|
|
174
|
+
| Total audit | 120 s (returns `Audit timed out`) |
|
|
175
|
+
| Max elements pixel-sampled per page | 200 |
|
|
176
|
+
| Concurrent audits per process | 2 (queue-full error beyond that) |
|
|
177
|
+
|
|
178
|
+
### What's out of scope (v1)
|
|
179
|
+
|
|
180
|
+
- Authenticated pages (no cookie/session handling)
|
|
181
|
+
- Multi-page crawling (use `a11yscan` for that)
|
|
182
|
+
- Focus/hover state contrast
|
|
183
|
+
- Dark-mode toggling
|
|
184
|
+
- Non-text contrast (UI components, graphical objects)
|
|
185
|
+
- Elements inside shadow DOM or cross-origin iframes (counted under `skipped`)
|
|
186
|
+
- PDF contrast
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Environment variables
|
|
191
|
+
|
|
192
|
+
| Variable | Default | Purpose |
|
|
193
|
+
|----------|---------|---------|
|
|
194
|
+
| `CONTRASTCAP_NAV_TIMEOUT` | `30000` | Page navigation timeout (ms) |
|
|
195
|
+
| `CONTRASTCAP_ELEMENT_TIMEOUT` | `5000` | Per-element pixel sampling timeout (ms) |
|
|
196
|
+
| `CONTRASTCAP_AUDIT_TIMEOUT` | `120000` | Total audit cap (ms) |
|
|
197
|
+
| `CONTRASTCAP_LEVEL` | `AA` | Default WCAG level (`AA` or `AAA`) |
|
|
198
|
+
| `CONTRASTCAP_MAX_ELEMENTS` | `200` | Max elements to pixel-sample per page |
|
|
199
|
+
| `CONTRASTCAP_MAX_CONCURRENT` | `2` | Max concurrent audits per process |
|
|
200
|
+
| `CONTRASTCAP_VIEWPORT_WIDTH` | `1280` | Chromium viewport width |
|
|
201
|
+
| `CONTRASTCAP_VIEWPORT_HEIGHT` | `800` | Chromium viewport height |
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## CLI
|
|
206
|
+
|
|
207
|
+
The package also exposes a CLI for local use without an MCP client:
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
npx @icjia/contrastcap summary https://example.com/about
|
|
211
|
+
npx @icjia/contrastcap page https://example.com/about --level AAA
|
|
212
|
+
npx @icjia/contrastcap element http://localhost:3000 'nav a'
|
|
213
|
+
npx @icjia/contrastcap status
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
With no subcommand, the binary starts the MCP server on stdio.
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
## Publishing
|
|
221
|
+
|
|
222
|
+
`./publish.sh` mirrors the pattern used by `@icjia/lightcap` and `@icjia/viewcap`:
|
|
223
|
+
|
|
224
|
+
```bash
|
|
225
|
+
./publish.sh # bump patch version and publish (default)
|
|
226
|
+
./publish.sh minor # bump minor version and publish
|
|
227
|
+
./publish.sh major # bump major version and publish
|
|
228
|
+
./publish.sh --dry-run # dry run only, no publish
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
First-time publish is auto-detected (no existing version on npm) — the current `package.json` version is used as-is. Subsequent releases bump + tag + push.
|
|
232
|
+
|
|
233
|
+
---
|
|
234
|
+
|
|
235
|
+
## Security
|
|
236
|
+
|
|
237
|
+
- Scheme allowlist: `http:` and `https:` only. `file:`, `javascript:`, `data:`, `ftp:` etc. are rejected with a generic `Blocked URL scheme` error.
|
|
238
|
+
- Cloud-metadata hostnames blocked: `169.254.169.254`, `metadata.google.internal`, `metadata.azure.com`, `0.0.0.0`.
|
|
239
|
+
- IP-prefix denylist (DNS-resolved): IPv4 link-local (`169.254.`), IPv6 unique-local (`fd00:`), link-local (`fe80:`), unspecified (`::`). DNS-resolution failures fail closed.
|
|
240
|
+
- Post-navigation re-check: after `page.goto` settles, `page.url()` is re-validated before any pixel sampling.
|
|
241
|
+
- Generic error messages — no filesystem paths or stack traces are returned to MCP clients.
|
|
242
|
+
- No file writes. Screenshots are in-memory buffers consumed by `sharp` and discarded.
|
|
243
|
+
|
|
244
|
+
Private / localhost IPs are **allowed** by design — the primary use case is auditing dev servers.
|
|
245
|
+
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
## License
|
|
249
|
+
|
|
250
|
+
MIT © 2026 Illinois Criminal Justice Information Authority (ICJIA)
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@icjia/contrastcap",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for automated WCAG contrast auditing via pixel-level analysis — resolves axe-core 'needs review' items by sampling actual rendered pixels in headless Chromium",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/server.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"contrastcap": "src/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "node src/server.js",
|
|
12
|
+
"test": "node --test test/*.test.js",
|
|
13
|
+
"postinstall": "node -e \"try { require('child_process').execSync('playwright install chromium', { stdio: 'inherit' }) } catch (e) { console.error('[contrastcap] playwright install chromium failed:', e.message); process.exit(0) }\""
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src/",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"mcp",
|
|
24
|
+
"accessibility",
|
|
25
|
+
"wcag",
|
|
26
|
+
"contrast",
|
|
27
|
+
"axe-core",
|
|
28
|
+
"playwright",
|
|
29
|
+
"claude",
|
|
30
|
+
"a11y"
|
|
31
|
+
],
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/ICJIA/contrastcap-mcp.git"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@cfworker/json-schema": "^4.1.1",
|
|
39
|
+
"@modelcontextprotocol/server": "^2.0.0-alpha.2",
|
|
40
|
+
"axe-core": "^4.10.0",
|
|
41
|
+
"commander": "^14.0.3",
|
|
42
|
+
"playwright": "^1.49.0",
|
|
43
|
+
"sharp": "^0.34.0",
|
|
44
|
+
"zod": "^4.3.6"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/browser.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { chromium } from 'playwright';
|
|
2
|
+
import { CONFIG, log } from './config.js';
|
|
3
|
+
|
|
4
|
+
let _browser = null;
|
|
5
|
+
let _launching = null;
|
|
6
|
+
|
|
7
|
+
export async function getBrowser() {
|
|
8
|
+
if (_browser) return _browser;
|
|
9
|
+
if (_launching) return _launching;
|
|
10
|
+
|
|
11
|
+
_launching = chromium.launch({
|
|
12
|
+
headless: true,
|
|
13
|
+
args: [
|
|
14
|
+
'--disable-dev-shm-usage',
|
|
15
|
+
'--disable-gpu',
|
|
16
|
+
'--disable-extensions',
|
|
17
|
+
'--disable-background-networking',
|
|
18
|
+
'--no-first-run',
|
|
19
|
+
'--no-default-browser-check',
|
|
20
|
+
...(process.platform === 'linux' ? ['--no-sandbox', '--disable-setuid-sandbox'] : []),
|
|
21
|
+
],
|
|
22
|
+
}).then((b) => {
|
|
23
|
+
_browser = b;
|
|
24
|
+
_launching = null;
|
|
25
|
+
log('info', 'Chromium launched');
|
|
26
|
+
return b;
|
|
27
|
+
}).catch((err) => {
|
|
28
|
+
_launching = null;
|
|
29
|
+
throw err;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
return _launching;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function closeBrowser() {
|
|
36
|
+
if (!_browser) return;
|
|
37
|
+
try { await _browser.close(); } catch { /* ignore */ }
|
|
38
|
+
_browser = null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Open a fresh context + page, run `fn`, then tear it down.
|
|
43
|
+
* Context is disposable per call to avoid state leaks between audits.
|
|
44
|
+
*/
|
|
45
|
+
export async function withPage(fn) {
|
|
46
|
+
const browser = await getBrowser();
|
|
47
|
+
const context = await browser.newContext({
|
|
48
|
+
viewport: { width: CONFIG.VIEWPORT_WIDTH, height: CONFIG.VIEWPORT_HEIGHT },
|
|
49
|
+
deviceScaleFactor: 1, // pixel sampling needs CSS pixels, not retina
|
|
50
|
+
userAgent: CONFIG.USER_AGENT,
|
|
51
|
+
});
|
|
52
|
+
const page = await context.newPage();
|
|
53
|
+
try {
|
|
54
|
+
return await fn(page);
|
|
55
|
+
} finally {
|
|
56
|
+
await context.close().catch(() => { /* ignore */ });
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { program } from 'commander';
|
|
4
|
+
import { readFileSync } from 'fs';
|
|
5
|
+
import { execFile } from 'child_process';
|
|
6
|
+
import { setVerbosity, CONFIG } from './config.js';
|
|
7
|
+
import { closeBrowser } from './browser.js';
|
|
8
|
+
import { checkPageContrast } from './tools/checkPageContrast.js';
|
|
9
|
+
import { checkElementContrast } from './tools/checkElementContrast.js';
|
|
10
|
+
import { getContrastSummary } from './tools/getContrastSummary.js';
|
|
11
|
+
import { sanitizeError } from './utils/sanitizeError.js';
|
|
12
|
+
|
|
13
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url)));
|
|
14
|
+
|
|
15
|
+
program
|
|
16
|
+
.name('contrastcap')
|
|
17
|
+
.description('WCAG contrast auditor — pixel-level resolution of axe-core "needs review" items')
|
|
18
|
+
.version(pkg.version);
|
|
19
|
+
|
|
20
|
+
program
|
|
21
|
+
.option('--verbose', 'Verbose logging')
|
|
22
|
+
.option('--quiet', 'Errors only');
|
|
23
|
+
|
|
24
|
+
function applyGlobalOptions() {
|
|
25
|
+
const opts = program.opts();
|
|
26
|
+
if (opts.verbose) setVerbosity('verbose');
|
|
27
|
+
if (opts.quiet) setVerbosity('quiet');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function validLevel(v) {
|
|
31
|
+
return v === 'AAA' ? 'AAA' : 'AA';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function runAndPrint(fn) {
|
|
35
|
+
try {
|
|
36
|
+
const result = await fn();
|
|
37
|
+
console.log(JSON.stringify(result, null, 2));
|
|
38
|
+
} catch (err) {
|
|
39
|
+
console.error(`Error: ${sanitizeError(err)}`);
|
|
40
|
+
process.exitCode = 1;
|
|
41
|
+
} finally {
|
|
42
|
+
await closeBrowser();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
program
|
|
47
|
+
.command('summary <url>')
|
|
48
|
+
.description('Summary counts only (lowest token cost)')
|
|
49
|
+
.option('-l, --level <AA|AAA>', 'WCAG level', 'AA')
|
|
50
|
+
.action(async (url, opts) => {
|
|
51
|
+
applyGlobalOptions();
|
|
52
|
+
await runAndPrint(() => getContrastSummary({ url, level: validLevel(opts.level) }));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
program
|
|
56
|
+
.command('page <url>')
|
|
57
|
+
.description('Full page audit — failures and warnings with detail')
|
|
58
|
+
.option('-l, --level <AA|AAA>', 'WCAG level', 'AA')
|
|
59
|
+
.action(async (url, opts) => {
|
|
60
|
+
applyGlobalOptions();
|
|
61
|
+
await runAndPrint(() => checkPageContrast({ url, level: validLevel(opts.level) }));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
program
|
|
65
|
+
.command('element <url> <selector>')
|
|
66
|
+
.description('Check a single element by CSS selector')
|
|
67
|
+
.option('-l, --level <AA|AAA>', 'WCAG level', 'AA')
|
|
68
|
+
.action(async (url, selector, opts) => {
|
|
69
|
+
applyGlobalOptions();
|
|
70
|
+
await runAndPrint(() => checkElementContrast({ url, selector, level: validLevel(opts.level) }));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
program
|
|
74
|
+
.command('status')
|
|
75
|
+
.description('Show version info')
|
|
76
|
+
.action(async () => {
|
|
77
|
+
let axeVersion = 'unknown';
|
|
78
|
+
let playwrightVersion = 'unknown';
|
|
79
|
+
try {
|
|
80
|
+
axeVersion = JSON.parse(readFileSync(new URL('../node_modules/axe-core/package.json', import.meta.url))).version;
|
|
81
|
+
} catch { /* ignore */ }
|
|
82
|
+
try {
|
|
83
|
+
playwrightVersion = JSON.parse(readFileSync(new URL('../node_modules/playwright/package.json', import.meta.url))).version;
|
|
84
|
+
} catch { /* ignore */ }
|
|
85
|
+
|
|
86
|
+
let latest = 'unknown';
|
|
87
|
+
try {
|
|
88
|
+
latest = await new Promise((resolve, reject) => {
|
|
89
|
+
execFile('npm', ['view', '@icjia/contrastcap', 'version'], { timeout: 5000 }, (err, stdout) => {
|
|
90
|
+
if (err) reject(err);
|
|
91
|
+
else {
|
|
92
|
+
const raw = stdout.trim();
|
|
93
|
+
resolve(/^\d+\.\d+\.\d+/.test(raw) ? raw : 'unknown');
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
} catch { /* ignore */ }
|
|
98
|
+
|
|
99
|
+
const updateNote = (latest === 'unknown' || latest === pkg.version)
|
|
100
|
+
? '(latest)'
|
|
101
|
+
: `(latest: v${latest} — update available)`;
|
|
102
|
+
|
|
103
|
+
console.log('contrastcap status');
|
|
104
|
+
console.log(` Server: @icjia/contrastcap v${pkg.version} ${updateNote}`);
|
|
105
|
+
console.log(` axe-core: v${axeVersion}`);
|
|
106
|
+
console.log(` playwright: v${playwrightVersion}`);
|
|
107
|
+
console.log(` Node: v${process.versions.node}`);
|
|
108
|
+
console.log(` Platform: ${process.platform} ${process.arch}`);
|
|
109
|
+
console.log(` Default: WCAG ${CONFIG.DEFAULT_LEVEL}`);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Default: start the MCP server when invoked with no subcommand (the npx entry)
|
|
113
|
+
const subcommands = ['summary', 'page', 'element', 'status', 'help'];
|
|
114
|
+
const arg2 = process.argv[2];
|
|
115
|
+
const isSubcommand = arg2 && (
|
|
116
|
+
subcommands.includes(arg2) ||
|
|
117
|
+
arg2 === '--help' || arg2 === '-h' ||
|
|
118
|
+
arg2 === '--version' || arg2 === '-V'
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
if (!arg2 || (!isSubcommand && arg2.startsWith('-'))) {
|
|
122
|
+
await import('./server.js');
|
|
123
|
+
} else {
|
|
124
|
+
program.parse();
|
|
125
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
const envInt = (key, fallback) => {
|
|
2
|
+
const v = process.env[key];
|
|
3
|
+
if (!v) return fallback;
|
|
4
|
+
const n = parseInt(v, 10);
|
|
5
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const envEnum = (key, allowed, fallback) => {
|
|
9
|
+
const v = process.env[key];
|
|
10
|
+
return allowed.includes(v) ? v : fallback;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const CONFIG = {
|
|
14
|
+
// Timeouts
|
|
15
|
+
NAV_TIMEOUT: envInt('CONTRASTCAP_NAV_TIMEOUT', 30_000),
|
|
16
|
+
ELEMENT_TIMEOUT: envInt('CONTRASTCAP_ELEMENT_TIMEOUT', 5_000),
|
|
17
|
+
AUDIT_TIMEOUT: envInt('CONTRASTCAP_AUDIT_TIMEOUT', 120_000),
|
|
18
|
+
|
|
19
|
+
// Audit behavior
|
|
20
|
+
DEFAULT_LEVEL: envEnum('CONTRASTCAP_LEVEL', ['AA', 'AAA'], 'AA'),
|
|
21
|
+
MAX_ELEMENTS: envInt('CONTRASTCAP_MAX_ELEMENTS', 200),
|
|
22
|
+
MAX_CONCURRENT: envInt('CONTRASTCAP_MAX_CONCURRENT', 2),
|
|
23
|
+
|
|
24
|
+
// Viewport
|
|
25
|
+
VIEWPORT_WIDTH: envInt('CONTRASTCAP_VIEWPORT_WIDTH', 1280),
|
|
26
|
+
VIEWPORT_HEIGHT: envInt('CONTRASTCAP_VIEWPORT_HEIGHT', 800),
|
|
27
|
+
|
|
28
|
+
// Input caps
|
|
29
|
+
MAX_URL_LENGTH: 2048,
|
|
30
|
+
SELECTOR_MAX_LEN: 1024,
|
|
31
|
+
|
|
32
|
+
// Warning heuristics
|
|
33
|
+
MARGINAL_DELTA: 0.3, // ratio within this of threshold → warning, not fail
|
|
34
|
+
VARIANCE_STDDEV: 15, // per-channel stddev above this → high variance
|
|
35
|
+
|
|
36
|
+
USER_AGENT: 'contrastcap-mcp/0.1 (WCAG contrast auditor)',
|
|
37
|
+
|
|
38
|
+
// SSRF denylist — mirrors lightcap.
|
|
39
|
+
BLOCKED_HOSTNAMES: [
|
|
40
|
+
'169.254.169.254',
|
|
41
|
+
'metadata.google.internal',
|
|
42
|
+
'metadata.azure.com',
|
|
43
|
+
'0.0.0.0',
|
|
44
|
+
],
|
|
45
|
+
BLOCKED_IP_PREFIXES: [
|
|
46
|
+
'169.254.', // IPv4 link-local (AWS IMDS)
|
|
47
|
+
'fd00:', // IPv6 unique-local
|
|
48
|
+
'fe80:', // IPv6 link-local
|
|
49
|
+
'::', // IPv6 unspecified/loopback-equivalent
|
|
50
|
+
],
|
|
51
|
+
LOCALHOST_HOSTS: [
|
|
52
|
+
'localhost', '127.0.0.1', '::1', '[::1]',
|
|
53
|
+
],
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// ─── Logging ──────────────────────────────────────────────────────
|
|
57
|
+
// Verbosity: 'quiet' = errors only, 'normal' = error+info, 'verbose' = +debug
|
|
58
|
+
|
|
59
|
+
let verbosity = 'normal';
|
|
60
|
+
|
|
61
|
+
export function setVerbosity(level) {
|
|
62
|
+
if (['quiet', 'normal', 'verbose'].includes(level)) verbosity = level;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function log(level, msg) {
|
|
66
|
+
if (verbosity === 'quiet' && level !== 'error') return;
|
|
67
|
+
if (verbosity === 'normal' && level === 'debug') return;
|
|
68
|
+
console.error(`[contrastcap] ${msg}`);
|
|
69
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import axeCore from 'axe-core';
|
|
2
|
+
|
|
3
|
+
const AXE_SOURCE = axeCore.source;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Inject axe-core into the page and run the color-contrast rule only.
|
|
7
|
+
*
|
|
8
|
+
* Returns flattened arrays of nodes (not rules) for violations / incomplete / passes.
|
|
9
|
+
* Each node retains its original axe shape: { target, html, any, all, none, ... }.
|
|
10
|
+
*
|
|
11
|
+
* We trust axe's result for `violations` (definite failures with known colors)
|
|
12
|
+
* and `passes` (definite passes). Our job is to re-resolve every `incomplete`
|
|
13
|
+
* node via pixel sampling.
|
|
14
|
+
*/
|
|
15
|
+
export async function runContrastAudit(page) {
|
|
16
|
+
await page.evaluate((source) => {
|
|
17
|
+
// Guard against double-injection on re-runs
|
|
18
|
+
if (!window.axe) {
|
|
19
|
+
const s = document.createElement('script');
|
|
20
|
+
s.textContent = source;
|
|
21
|
+
document.head.appendChild(s);
|
|
22
|
+
}
|
|
23
|
+
}, AXE_SOURCE);
|
|
24
|
+
|
|
25
|
+
const results = await page.evaluate(async () => {
|
|
26
|
+
return await window.axe.run(document, {
|
|
27
|
+
runOnly: { type: 'rule', values: ['color-contrast'] },
|
|
28
|
+
resultTypes: ['violations', 'incomplete', 'passes'],
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const nodesOf = (arr) => (arr || []).flatMap(rule => rule.nodes || []);
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
violations: nodesOf(results.violations),
|
|
36
|
+
incomplete: nodesOf(results.incomplete),
|
|
37
|
+
passes: nodesOf(results.passes),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Extract the first usable CSS selector from an axe node.
|
|
43
|
+
* axe sometimes returns nested selectors (shadow DOM, frames) — fall back to the
|
|
44
|
+
* first element of the path.
|
|
45
|
+
*/
|
|
46
|
+
export function selectorFromNode(node) {
|
|
47
|
+
if (!node || !node.target) return null;
|
|
48
|
+
const t = node.target[0];
|
|
49
|
+
if (typeof t === 'string') return t;
|
|
50
|
+
if (Array.isArray(t)) return t[0]; // nested context (shadow/iframe) — not reachable via page.$()
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Pull axe's computed fg/bg (available on definite violations and many passes)
|
|
56
|
+
* from the standard `color-contrast` data shape.
|
|
57
|
+
*/
|
|
58
|
+
export function axeColors(node) {
|
|
59
|
+
const checks = [...(node.any || []), ...(node.all || []), ...(node.none || [])];
|
|
60
|
+
for (const c of checks) {
|
|
61
|
+
if (c?.data && (c.data.fgColor || c.data.bgColor)) {
|
|
62
|
+
return {
|
|
63
|
+
fgColor: c.data.fgColor || null,
|
|
64
|
+
bgColor: c.data.bgColor || null,
|
|
65
|
+
contrastRatio: typeof c.data.contrastRatio === 'number' ? c.data.contrastRatio : null,
|
|
66
|
+
fontSize: c.data.fontSize || null,
|
|
67
|
+
fontWeight: c.data.fontWeight || null,
|
|
68
|
+
expectedContrastRatio: typeof c.data.expectedContrastRatio === 'number' ? c.data.expectedContrastRatio : null,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|