MCP server for automated WCAG contrast auditing via pixel-level analysis.
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.
Built for the same triage workflow as @icjia/lightcap and @icjia/viewcap — stdio transport, ESM, minimal token footprint, get_status tool, publish.sh.
pnpm install
# Playwright's Chromium is fetched automatically via postinstall.
# If that fails (offline, CI), run manually:
pnpm exec playwright install chromiumRequires Node 20+.
Add to claude_desktop_config.json (or your IDE's MCP config):
{
"mcpServers": {
"contrastcap": {
"command": "npx",
"args": ["-y", "@icjia/contrastcap"]
}
}
}Or, pointing at a local checkout:
{
"mcpServers": {
"contrastcap": {
"command": "node",
"args": ["/absolute/path/to/contrastcap-mcp/src/server.js"]
}
}
}Restart Claude to pick up the new server.
All four tools default to WCAG AA. AAA must be explicitly requested via level: "AAA".
Counts only — the cheapest token footprint. Use this first to decide whether a full audit is warranted.
{ "url": "https://example.com/about" }Returns:
{
"url": "https://example.com/about",
"timestamp": "2026-04-13T14:30:00Z",
"wcag_level": "AA",
"counts": {
"total_elements_checked": 52,
"pass": 47,
"fail": 3,
"warning": 2,
"skipped": 0
}
}Full page audit. Returns detail for failures and warnings only — passing elements are counted, not itemized.
{ "url": "https://example.com/about", "level": "AA" }Returns:
{
"url": "...",
"timestamp": "...",
"wcag_level": "AA",
"summary": { "total": 52, "pass": 47, "fail": 3, "warning": 2, "skipped": 0 },
"failures": [
{
"selector": "nav.main-nav > ul > li:nth-child(3) > a",
"text": "Grant Opportunities",
"ratio": 3.21,
"required": 4.5,
"level": "AA",
"fontSize": "14px",
"fontWeight": "400",
"isLargeText": false,
"foreground": "#6c757d",
"background": "#e9ecef",
"backgroundSource": "pixel-sample",
"suggestion": "#595f64"
}
],
"warnings": [
{
"selector": ".hero-banner h1",
"text": "Criminal Justice Information…",
"ratio": 4.62,
"required": 4.5,
"level": "AA",
"foreground": "#ffffff",
"background": "#5a7a91",
"backgroundSource": "pixel-sample-over-image",
"note": "Ratio within 0.3 of threshold — marginal. Background sampled from gradient or image — may vary at other positions."
}
]
}Suggestion format is always hex (e.g. "#595f64"). The caller formats prose.
Single-element check. Use this to verify a fix without re-running the full page audit.
{
"url": "http://localhost:3000/about",
"selector": "nav.main-nav > ul > li:nth-child(3) > a"
}Returns a single-element object with pass: true|false, the measured ratio, foreground, background, and a suggestion hex if failing.
Server + axe-core + Playwright versions, plus a non-blocking npm update check.
- Playwright navigates to the URL (30s timeout,
networkidlefallback toload). - The server re-validates
page.url()against the SSRF denylist (redirect guard). - axe-core is injected via
page.evaluateand run withcolor-contrastonly. Itsviolations(definite failures) andpasses(definite passes) are trusted as-is. - For every
incomplete(needs-review) node:- Scroll into view
- Read computed
color,fontSize(always resolved to px),fontWeight - Save the element's prior inline
color, set it totransparent, screenshot the bounding box, then restore - Decode pixels via
sharp, sample on a 5×3 grid - If per-channel stddev > 15, treat as gradient/image and use worst-case pixel (darkest on light text, lightest on dark text)
- Otherwise take the median per channel
- Compute the WCAG 2.1 ratio and compare against the required threshold
- 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. - Passes bump the
passcount. Marginal passes or high-variance backgrounds are flagged as warnings, not failures.
| Scope | Limit |
|---|---|
| Page navigation | 30 s |
| Per-element pixel sampling | 5 s (skipped on timeout, audit continues) |
| Total audit | 120 s (returns Audit timed out) |
| Max elements pixel-sampled per page | 200 |
| Concurrent audits per process | 2 (queue-full error beyond that) |
- Authenticated pages (no cookie/session handling)
- Multi-page crawling (use
a11yscanfor that) - Focus/hover state contrast
- Dark-mode toggling
- Non-text contrast (UI components, graphical objects)
- Elements inside shadow DOM or cross-origin iframes (counted under
skipped) - PDF contrast
| Variable | Default | Purpose |
|---|---|---|
CONTRASTCAP_NAV_TIMEOUT |
30000 |
Page navigation timeout (ms) |
CONTRASTCAP_ELEMENT_TIMEOUT |
5000 |
Per-element pixel sampling timeout (ms) |
CONTRASTCAP_AUDIT_TIMEOUT |
120000 |
Total audit cap (ms) |
CONTRASTCAP_LEVEL |
AA |
Default WCAG level (AA or AAA) |
CONTRASTCAP_MAX_ELEMENTS |
200 |
Max elements to pixel-sample per page |
CONTRASTCAP_MAX_CONCURRENT |
2 |
Max concurrent audits per process |
CONTRASTCAP_VIEWPORT_WIDTH |
1280 |
Chromium viewport width |
CONTRASTCAP_VIEWPORT_HEIGHT |
800 |
Chromium viewport height |
CONTRASTCAP_BLOCK_PRIVATE |
unset | Set to 1 to block RFC1918 / loopback / CGNAT addresses (production hardening). See Security. |
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD |
unset | Set to 1 to skip the Chromium download in postinstall (offline / air-gapped installs). |
PLAYWRIGHT_DOWNLOAD_HOST |
unset | Mirror host for Playwright's Chromium download. |
The package also exposes a CLI for local use without an MCP client:
npx @icjia/contrastcap summary https://example.com/about
npx @icjia/contrastcap page https://example.com/about --level AAA
npx @icjia/contrastcap element http://localhost:3000 'nav a'
npx @icjia/contrastcap statusWith no subcommand, the binary starts the MCP server on stdio.
./publish.sh mirrors the pattern used by @icjia/lightcap and @icjia/viewcap:
./publish.sh # bump patch version and publish (default)
./publish.sh minor # bump minor version and publish
./publish.sh major # bump major version and publish
./publish.sh --dry-run # dry run only, no publishFirst-time publish is auto-detected (no existing version on npm) — the current package.json version is used as-is. Subsequent releases bump + tag + push.
contrastcap is an MCP server invoked by an LLM that may be acting on prompt-injected, attacker-controlled content. The dangerous tools are check_page_contrast and check_element_contrast — both accept a URL and load it in headless Chromium. A malicious URL could attempt to pivot to internal network resources (SSRF), exfiltrate page content via element text, or load adversarial schemes (file:, javascript:, data:).
- Scheme allowlist:
http:andhttps:only.file:,javascript:,data:,ftp:, etc. are rejected with a genericBlocked URL schemeerror. - Cloud-metadata blocklist (always on):
169.254.169.254,metadata.google.internal,metadata.azure.com,0.0.0.0. - CIDR-classified IP blocking (always on): IPv4 link-local (
169.254.0.0/16), IPv6 link-local (fe80::/10), IPv6 unspecified (::), IPv4 multicast/reserved (224.0.0.0/4+), IPv6 multicast (ff00::/8). IPv4-mapped IPv6 addresses are unwrapped first so::ffff:169.254.169.254is recognized as link-local. DNS-resolution failures fail closed. - Optional private-IP blocking: set
CONTRASTCAP_BLOCK_PRIVATE=1to also block RFC1918 (10/8,172.16/12,192.168/16), CGNAT (100.64/10), loopback (127/8,::1), and IPv6 ULA (fc00::/7). Off by default — the primary use case is auditing dev servers — but strongly recommended when running the server in a trusted internal network where the LLM should not be able to pivot to internal services via prompt injection. - Post-navigation re-check: after
page.gotosettles,page.url()is re-validated against the same SSRF policy. This catcheshttp://attacker.com/redirect→http://10.0.0.5/admin. - Selector hardening:
check_element_contrastrejects Playwright engine prefixes (xpath=,text=,role=,internal:*,_react=,_vue=, etc.) and chain operators (>>). Only plain CSS selectors are accepted, so a malicious selector cannot pivot to XPath / text-content matching to read arbitrary DOM text. - Generic error messages — no filesystem paths or stack traces are returned to MCP clients.
- No file writes. Screenshots are in-memory buffers consumed by
sharpand discarded. - Hardened postinstall: Playwright's CLI is resolved through Node's module resolver (
require.resolve) rather than$PATH, so a shadowedplaywrightbinary cannot hijack the install. Chromium download can be skipped (PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1) or mirrored (PLAYWRIGHT_DOWNLOAD_HOST). If Chromium is missing at runtime, the launcher emits an actionable error rather than a Playwright-internal stack trace.
A red/blue team audit covering the MCP tool surface, Playwright/browser launch, dependency posture, and publish pipeline was performed in 0.1.4 (see CHANGELOG). pnpm audit is clean (0 vulnerabilities across all dependencies).
MIT © 2026 Illinois Criminal Justice Information Authority (ICJIA)