-
-
-
Before adaptation the size options aren't in the tab order. Focus jumps from the last product straight to Add to cart. The task stops here.
-
Judge mode: one button, eight steps. Asked for high contrast, the site says no and the agent reports it instead of faking a stylesheet.
-
A visible focus ring, and sizes 8/9/10 in the tab order. Arrow keys move and select, the way a radiogroup is supposed to behave.
-
verify_accessibility_profile is scoped to the negotiated profile. Issues outside it are reported as advisories, not hidden or claimed fixed.
-
Without a contract: 3 unauthorised DOM writes and 0 verification. With WebMCP: order placed, verified by the site, nothing unauthorised.
-
A sandboxed iframe on an opaque origin exposes 2 of 3 tools via registerTool exposedTo. Cross-origin access is default-deny, per tool.
-
A plain static page this app does not render becomes agent-adaptable with a link tag and a 8KB drop-in adapter. No framework, no build step.
-
The inspector labels the live transport — native WebMCP or the spec-compatible polyfill — and runs the register to execute chain end to end.
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:
- Discover —
get_accessibility_capabilitiesreads the site's declared capability manifest, served at/.well-known/a11ymcp - Audit — task-scoped checks, each finding tagged
blocking/degrading/informational - Negotiate —
negotiate_accessibility_profilemaps needs to capabilities and returnsaccepted,partial (with a stated limitation), andrejectedwith a reason - Ask — remediation requires a literal
approval: true, enforced at the schema level - Adapt — the site's own adapter applies only its own declared directives, reversibly
- Verify —
verify_accessibility_profilereturns PASS/BLOCKED scoped to what was negotiated, with everything else listed as advisories - Finish the task — the actual purchase, behind a second
confirmation: truegate
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 —
contentblocks plusstructuredContentandisError, 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, andtoolchangefires when they go - Cross-origin
exposedTo— a widget in an iframe sandboxed withoutallow-same-origin(an opaque origin, sopostMessageis 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, andtoolautosubmithonoured 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
- a11y
- accessibility
- ai-agents
- aria
- css
- document.modelcontext
- javascript
- json-schema
- mcp
- model-context-protocol
- next.js
- playwright
- react
- typescript
- vercel
- vitest
- wcag
- webmcp
- zod
Log in or sign up for Devpost to join the conversation.