Skip to content

ICJIA/contrastcap-mcp

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

7 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@icjia/contrastcap

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.


Install

pnpm install
# Playwright's Chromium is fetched automatically via postinstall.
# If that fails (offline, CI), run manually:
pnpm exec playwright install chromium

Requires Node 20+.

Claude Desktop / Claude Code configuration

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.


Tools

All four tools default to WCAG AA. AAA must be explicitly requested via level: "AAA".

get_contrast_summary

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
  }
}

check_page_contrast

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.

check_element_contrast

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.

get_status

Server + axe-core + Playwright versions, plus a non-blocking npm update check.


How it works

  1. Playwright navigates to the URL (30s timeout, networkidle fallback to load).
  2. The server re-validates page.url() against the SSRF denylist (redirect guard).
  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.
  4. 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 to transparent, 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
  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.
  6. Passes bump the pass count. Marginal passes or high-variance backgrounds are flagged as warnings, not failures.

Limits & timeouts

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)

What's out of scope (v1)

  • Authenticated pages (no cookie/session handling)
  • Multi-page crawling (use a11yscan for 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

Environment variables

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.

CLI

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 status

With no subcommand, the binary starts the MCP server on stdio.


Publishing

./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 publish

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.


Security

Threat model

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:).

Controls

  • Scheme allowlist: http: and https: only. file:, javascript:, data:, ftp:, etc. are rejected with a generic Blocked URL scheme error.
  • 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.254 is recognized as link-local. DNS-resolution failures fail closed.
  • Optional private-IP blocking: set CONTRASTCAP_BLOCK_PRIVATE=1 to 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.goto settles, page.url() is re-validated against the same SSRF policy. This catches http://attacker.com/redirecthttp://10.0.0.5/admin.
  • Selector hardening: check_element_contrast rejects 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 sharp and discarded.
  • Hardened postinstall: Playwright's CLI is resolved through Node's module resolver (require.resolve) rather than $PATH, so a shadowed playwright binary 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.

Audit history

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).


License

MIT © 2026 Illinois Criminal Justice Information Authority (ICJIA)

About

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 and screenshots .

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors