The problem I couldn't stop thinking about

A website can pass an accessibility audit and still stop one specific person from finishing one specific task.

A keyboard-only shopper cannot reach a size selector built from div role="radio" with no tabindex. It isn't broken and it isn't disabled — to a keyboard it simply isn't there. Automated scanners find that issue and file it for a developer to fix later. Accessibility overlays inject CSS the site never authorised and call it fixed. Neither helps the person standing at the checkout now.

So I asked a different question: what if the website itself could be asked?

What A11yMCP does

A11yMCP treats accessibility adaptation as a contract between three parties — the human, their agent, and the website — where the website is a first-class participant that declares what it can safely adapt, how, and how to check it worked.

Give an agent a need, and through WebMCP tools it will:

  1. Discover — get_accessibility_capabilities reads the site's declared capability manifest, served at /.well-known/a11ymcp
  2. Audit — task-scoped checks, each finding tagged blocking / degrading / informational
  3. Negotiate — negotiate_accessibility_profile maps needs to capabilities and returns accepted, partial (with a stated limitation), and rejected with a reason
  4. Ask — remediation requires a literal approval: true, enforced at the schema level
  5. Adapt — the site's own adapter applies only its own declared directives, reversibly
  6. Verify — verify_accessibility_profile returns PASS/BLOCKED scoped to what was negotiated, with everything else listed as advisories
  7. Finish the task — the actual purchase, behind a second confirmation: true gate

The step I care most about is the fourth word in point 3: rejected. Ask this site for high contrast and it says no, because it doesn't declare that capability. The agent reports that honestly instead of inventing a stylesheet. Every accessibility overlay on the web does the opposite.

How I built it

Next.js App Router, TypeScript, zero runtime dependencies beyond React and Zod. 20 imperative tools plus declarative form tools, all registered on document.modelContext.

Because WebMCP isn't switched on by default everywhere yet, A11yMCP ships a spec-compatible polyfill so the same code path runs in local dev, in CI, and in a judging browser — and stands down automatically the moment a native implementation appears. It did: ChatGPT's desktop browser ran the whole thing on its own document.modelContext.

Depth I'm proud of:

  • Tools return real MCP results — content blocks plus structuredContent and isError, not a bespoke shape an agent has to guess at
  • Task-scoped lifecycle via the spec's AbortSignal — commerce tools exist only while the storefront is mounted, and toolchange fires when they go
  • Cross-origin exposedTo — a widget in an iframe sandboxed without allow-same-origin (an opaque origin, so postMessage is the only channel) registers three tools, exposes two, and refuses the third by name, with a reason
  • Declarative schemas derived from markup — types, formats, enums, bounds, required, toolparamdescription, and toolautosubmit honoured so a consequential form is filled by the agent and submitted by a person
  • A drop-in adapter — any static page becomes agent-adaptable with a manifest file and one script tag

What happened when a real agent used it

I pointed ChatGPT's desktop-app browser at the deployed site — running the browser's own native document.modelContext, which the agent read back on request — and gave it one sentence naming no tools, no capabilities and no expected outcome:

"I can only use a keyboard, and I have low vision, so I need high contrast. Help me buy the NOMA Runner in size 9."

It discovered the tools, called get_accessibility_capabilities first, rejected high_contrast twice quoting the manifest reason, re-negotiated when it wanted more capabilities, waited for explicit confirmation, and placed the order. Asked what it had changed that the site had not offered:

"None. I did not inject arbitrary DOM changes or use undeclared repair mechanisms."

Asked whether it had told the user the need was unsupported, or only logged it:

"I stated the high-contrast limitation in my reply to you, not only in tool results."

That second answer is the one I care about. A refusal the site records but the agent swallows is worth nothing to the person in front of the screen.

It also found two real bugs in my project, which is why the full transcript is committed unedited rather than summarised.

What I learned

A polyfill is a bad oracle. Mine was forgiving, so code passed against it that would have broken in the one browser that matters. I ended up writing a strict stand-in that implements only the documented spec surface — a promise-returning registerTool, no unregisterTool, and an executeTool that throws for anything but a tool descriptor plus a JSON string — and drove the whole app through it. Several real defects only appeared there.

It also certified one, because I'd written it from the published examples rather than from a browser. A test double is only as good as the contract you wrote it from.

"You may not" and "there is nothing" are different answers. When the embedder asks for a tool it wasn't given, returning an empty list is worse than useless — it's misleading. A refusal with a reason is the correct answer, and an agent deserves to know which one it got. That principle ended up shaping the whole tool surface.

Challenges

Five defects, and not one was findable by reading the code.

The first: requestAnimationFrame doesn't fire in a hidden or backgrounded tab, and my remediation awaited a render before re-auditing. Every repair_* tool therefore hung forever — silently, no error, no timeout — whenever the page wasn't the foreground tab. Which is exactly how an agent driving it from a side panel would find it. My Playwright suite never caught it because Playwright keeps the page active.

The second: I wasn't honouring toolautosubmit, so every declarative form tool submitted itself. On the one project arguing that consequential actions need a human gate, an agent could complete a checkout with nobody in the loop.

The third came from reading the shipping Chrome 152 binary rather than the docs:

ScriptPromise<IDLNullable<IDLString>>
blink::ModelContext::executeTool(ScriptState*, RegisteredTool*, String,
                                 const ExecuteToolOptions*)

Native executeTool resolves a JSON string, not an object. My unwrapper expected an object, so under a real native implementation every call would have handed the UI a raw string, silently.

The last two came from the agent. Verification returned PASS for a profile in which the site had accepted nothing — an empty scope has nothing left in it to fail, so the site said "I cannot help you" and then said "PASS". And the agent had to keyboard-click a form by hand, on the project whose whole argument is that agents shouldn't have to: syncDeclarativeForms only ran inside the polyfill's construction path, so under a native implementation A11yMCP registered no declarative forms at all and assumed the browser would synthesize them. Chrome does. ChatGPT's browser doesn't.

All five are fixed, each with a regression test. The same agent then re-verified the last one over the same native transport: it now reports 21 tools where it previously reported 20.

Proving the comparison isn't rigged. It would have been easy to animate a stored benchmark. Instead the side-by-side runs both lanes live on the same page: the actuation lane genuinely walks the tab order, genuinely probes the computed focus style, and genuinely injects the tabindex it was never authorised to inject — then undoes it. The "unauthorised mutations" counter on screen is a count of real DOM writes.

Making an invisible barrier visible. The storefront sets outline: none on :focus, so before adaptation keyboard focus is genuinely invisible. That is the barrier — but on camera it meant nothing appeared to happen. The demo now annotates where focus actually is, in a style deliberately unlike a real focus ring, so you can watch it skip the size selector entirely.

Honest limits

No WCAG or legal compliance claims. Verification is scoped evidence, not certification. Needs the site doesn't declare are reported as unsupported, never faked. The measured benchmark names the task where plain actuation wins.

And one the agent surfaced that I'm not going to paper over: it sent approval: true without separately asking me first. A schema can require the literal. It cannot require that a person meant it. That's a limitation of the trust model rather than a defect in this code, and it's recorded in the transcript rather than smoothed away.

Built With

Share this project:

Updates

Submission history