DEV Community

perceivable
perceivable

Posted on

I turned my accessibility checker into an MCP server, so the agent that writes the HTML can also fix it

Last week I wrote about running my WCAG checker against gov.uk and finding that every one of the 72 "violations" was a bug in my checker. Fixing those left me with an engine I trust on real sites, packaged as a Chrome extension.

But the people who most need an accessibility check right now are not people. They are coding agents. Claude Code, Cursor and friends generate a huge share of new HTML, and they ship <img> without alt, grey-on-white placeholders and outline: none just like we did, only faster. An extension that a human clicks does nothing for that.

So the same engine is now an MCP server: a11yscope-mcp.

What it does

Three tools:

  • scan_page loads a URL or a local HTML file in headless Chrome and returns violations grouped by rule. Every finding has a CSS selector, an HTML snippet and a message that says what to change.
  • scan_html does the same for a string of markup, which is the one agents actually need: you just wrote this component, check it before you save it.
  • list_rules returns the 31 checks with the WCAG 2.2 success criteria they map to.

The checks cover text alternatives (images, labels, buttons, links, frames, SVG), colour contrast with the large-text and bold exemptions applied and translucent backgrounds composited, document structure (language, title, heading order, main landmark, skip link, tables, lists), and keyboard, pointer and ARIA (zoom lock, positive tabindex, aria-hidden focusables, nested controls, the new 24×24 target size, autoplay, captions, invalid roles, required ARIA states, autocomplete, removed focus outlines).

Everything runs locally. It uses the Chrome, Chromium, Edge or Brave you already have, downloads nothing at install time, sends nothing anywhere.

Install

Claude Code:

claude mcp add a11yscope -- npx -y a11yscope-mcp
Enter fullscreen mode Exit fullscreen mode

Cursor, Claude Desktop or any other MCP client:

{
  "mcpServers": {
    "a11yscope": { "command": "npx", "args": ["-y", "a11yscope-mcp"] }
  }
}
Enter fullscreen mode Exit fullscreen mode

Then a prompt like "scan http://localhost:3000 with a11yscope and fix every violation, then scan again" closes the loop: the agent reads the selector and the message, edits the file, and re-scans until the list is empty.

What a result looks like

For a component with an unlabelled image and an empty link:

{
  "summary": { "violations": 2, "review": 1, "rulesRun": 31, "rulesFailed": 2, "rulesPassed": 28 },
  "rules": [
    {
      "id": "img-alt", "wcag": ["1.1.1"], "level": "A", "impact": "critical",
      "help": "Add alt text describing what the image conveys. If the image is purely decorative, use alt=\"\" ...",
      "violations": [{ "selector": "html > body > main > img", "message": "<img> has no alt attribute (src: a.png)", "snippet": "<img src=\"a.png\" width=\"120\" height=\"80\">" }]
    },
    {
      "id": "link-name", "wcag": ["2.4.4", "4.1.2"], "level": "A", "impact": "critical",
      "violations": [{ "selector": "html > body > main > a", "message": "Link has no discernible text", "snippet": "<a href=\"/x\">" }]
    }
  ],
  "disclaimer": "Automated checks find roughly a third of accessibility barriers. A clean result is a good sign, not a conformance claim; items marked review need a human decision."
}
Enter fullscreen mode Exit fullscreen mode

That disclaimer is in every response on purpose. An agent will happily tell its user "the page is now WCAG compliant" if you let it. Roughly a third of barriers are machine-checkable; whether the alt text is true, whether the page makes sense in a screen reader, whether a keyboard user can finish the checkout, no tool can say. Anything the engine cannot decide comes back as review, not as pass.

Calibration

The gov.uk exercise became the acceptance test. gov.uk, webaim.org, deque.com, a11yproject.com and w3.org/WAI all return zero violations from this engine. Anything reported on those sites is treated as my bug until proven otherwise. If you hit a false positive, the issue tracker is the most useful place to put it: https://github.com/perceivable/a11yscope/issues

npm: https://www.npmjs.com/package/a11yscope-mcp

Top comments (10)

Collapse
 
arhancanli profile image
Arhan Canli •

The closed loop ("fix every violation, then scan again") has a failure mode that the violation count can't see: the cheapest way to make img-alt or link-name go to zero is to delete the element, or to write alt="image". A re-scan reports a clean page either way. It would help to return something the agent can't satisfy by deletion, for example the element count per rule before and after (so "3 images checked, 0 now" reads differently from "3 images, all with alt"), and a review item for alt text that equals the filename or a stock word.

Same thing for rulesPassed: 28 in your sample: with two violations and one review out of 31, most of those passes are rules that found nothing to test, not rules that tested something and passed. For scan_html on a fragment it matters more, since contrast, heading order, landmark and skip-link rules depend on the page around the component. A separate notApplicable bucket (and a note when a fragment was scanned without the app's stylesheet) would stop the agent from reading "28 passed" as evidence about the whole page.

Collapse
 
perceivable profile image
perceivable •

@arhancanli You are right on both counts, and the first one was a real hole in the "fix it, then scan again" loop. Shipped today as a11yscope-mcp 0.2.0 (engine commit 6480ca5):

  • Every rule now declares what it looks at, and reports checked (how many such elements were in the DOM: every <img> for img-alt, every link for link-name, 1 for page-level rules) plus a status: failed, review, passed or inapplicable. A rule with checked: 0 and no findings is inapplicable, never passed.
  • The summary splits rulesPassed from rulesNotApplicable. Your reading of my sample was correct: re-running it, <main><h1>Hi</h1><img><a href="/x"></a></main> gives 1 passed and 26 not applicable, not 28 passed. The server instructions now tell the agent to compare checked before and after, so "3 images checked, 0 now" is visible as deletion.
  • scan_html adds notes when the markup is a fragment (page-level checks then describe the fragment, not the page it will live in) and when there is no stylesheet at all (contrast, focus and target-size then reflect browser defaults). Both scan tools also report iframesNotEntered.

On alt="image" and alt equal to the file name: that was already covered by a separate rule, img-alt-redundant, which flags an alt that ends in an image extension or is a filler word (image, img, photo, picture, graphic, icon, logo, spacer). It is a violation, not a review item, so an agent that "fixes" img-alt by writing alt="image" trades one violation for another. What it cannot judge is whether alt="Team photo" is honest, and that stays with the human.

One caveat I want to be precise about: checked counts elements present in the DOM, before the rule's own exclusions (hidden, aria-hidden, decorative). So a hidden <img> with no alt shows as checked 1 / passed, because the rule deliberately does not report what nobody can reach. The count answers "did the elements survive", not "did every one get judged".

Collapse
 
arhancanli profile image
Arhan Canli •

The caveat you flag points at the next loophole, I think: if checked counts elements before the exclusions, an agent can clear a failing img-alt by adding aria-hidden="true" (or role="presentation", or hidden) to the image. checked stays 1, the status flips to passed, and the before/after comparison shows nothing, because the element survived and was never judged.

A cheap closer is a third number per rule, excluded (how many of the checked elements the rule skipped as hidden or decorative), with the same compare-before-and-after instruction. A scan that goes from checked 3 / excluded 0 to checked 3 / excluded 3 reads as "everything was hidden", which is the case worth stopping on. Genuine decorative images exist, so I would report it as review, not failed.

Thread Thread
 
perceivable profile image
perceivable •

@arhancanli Yes, that is the next loophole, and the cheap closer is the right one. Committed to main today, goes out as 0.2.1:

  • Every element-level rule now reports excluded next to checked: how many of the candidates it skipped because they are hidden from assistive technology (aria-hidden, display:none, hidden) or marked decorative (role="presentation" / "none"). Your case, <img src="a.png" aria-hidden="true"> with no alt, now comes back as img-alt: passed, checked 1, excluded 1 instead of a silent pass, and the test suite pins that.
  • The server instructions tell the agent to compare checked and excluded before and after, and to treat a rise in excluded as a question for a human rather than a fix, for exactly the reason you give: decorative images are real, so the tool cannot call it a failure, but it can refuse to call it progress.
  • One rule opts out: aria-hidden-focusable, whose candidates are aria-hidden by definition, so counting them as excluded would say nothing.

What this still does not catch is an agent that keeps the element visible and writes a plausible lie in the alt. Nothing automated does, and the result says so in every response.

Collapse
 
autenai profile image
Auten •

The "you just wrote this component, check it before you save it" framing for scan_html is the right one, and the disclaimer in every response is a good call.

One angle from the other side, from the Auten team: we build an MCP server that lets agents operate desktop apps through the accessibility tree. The same bugs your rules catch (unnamed buttons, empty links, nested controls) are exactly what makes an app hard for an agent to drive. An icon button with no name shows up as just "button", and the agent has to guess from pixels. So these fixes help screen reader users first, and agents that use the UI later too.

Question: how does scan_page handle components inside shadow DOM or same-origin iframes? That is where we most often see controls with no name in modern web apps.

Collapse
 
perceivable profile image
perceivable •

@autenai That framing matches what I see from the other direction: a button whose only name is "button" is the same bug whether a screen reader or an agent is driving, and the fix is one attribute.

To answer precisely, from the engine source rather than from memory:

  • Shadow DOM: open shadow roots are walked. The element collector descends into every shadowRoot it meets, so a <button> inside a web component is checked like any other, and its selector in the report crosses the boundary with >>> (my-card >>> button) so you can find it again. Closed shadow roots are not reachable by any script, including this one, so controls in there are invisible to the scan. Same limit as any in-page tool; only the browser's own accessibility tree sees through it.
  • iframes: not entered, same-origin or not. The scan runs in the top document only. iframe-title checks the frame element itself (a frame with no title is a violation), and as of 0.2.0 today the summary reports iframesNotEntered with a note telling the agent to scan each frame's URL separately with scan_page. Entering same-origin frames is doable from headless Chrome and is on the list, but I would rather say "not covered" than scan half of it quietly.

Where your tool and mine would meet, I think, is the accessibility tree: a name computed from the live tree is what both a screen reader and an agent get, and what my accessibleName() reimplements from markup. If you ever see a control that your side reports as unnamed and mine passes, that is a false negative I want; the repo issues are open.

Collapse
 
autenai profile image
Auten •

Thanks for checking the source rather than answering from memory, and for "not covered" over a quiet half-scan.

The closed-shadow-root limit is where our two sides complement each other. We read the accessibility tree the browser exposes to the OS, and Chrome and Firefox build that from the flattened tree, so controls inside closed shadow roots and inside iframes show up there with their computed names. If I hit a control that comes through unnamed on our side and passes scan_page, I'll open an issue with the markup.

Thread Thread
 
perceivable profile image
perceivable •

@autenai That is a useful asymmetry to have on record: the OS-level tree is built from the flattened tree, so it sees through closed shadow roots and into frames, where a markup scanner stops; the markup scanner can run on HTML that has never been rendered in a real app, where there is no tree yet. An issue with the markup is exactly the input I want, and if it turns out to be a gap in accessibleName() rather than a closed root, it gets fixed and credited.

Thread Thread
 
perceivable profile image
perceivable •

@autenai Follow-up on the frame limit, since I said "not covered" rather than promising: as of a11yscope-mcp 0.3.0 (today) scan_page enters every frame of the page's own origin, at any depth, and runs the element rules inside. Findings carry the frame's path in the selector (iframe#chat >>> button), page-level checks stay on the top document, and cross-origin frames are still counted and named rather than scanned, since that is someone else's markup. Closed shadow roots remain yours.

Thread Thread
 
autenai profile image
Auten •

Shipping it beats promising it, nice. The iframe#chat >>> button path is the part that helps us most: when an agent can't find a control on screen, the first question is which frame it lives in, and that selector answers it from the markup side before anything is rendered. Counting and naming cross-origin frames instead of silently skipping them is the right call too.