How to Build an MCP Server for Web Accessibility
Build a focused MCP accessibility server with Playwright and axe-core, safe tool boundaries, structured evidence, and human-review limits.
Short answer: build a small MCP server that exposes one accessibility workflow, validates an authorized URL, drives a browser to the intended state, runs automated checks such as axe-core, and returns structured evidence. Treat the result as test evidence, never as a blanket WCAG conformance claim.
This guide uses Python, Playwright, FastMCP, and axe-core. It runs over MCP stdio for local hosts. For a remote deployment, use Streamable HTTP with authentication, host allow-lists, rate limits, and isolation.
1. What an accessibility MCP server should do
MCP servers expose tools, resources, and prompts to an MCP host. A useful first tool has a recognizable job: scan one authorized page in one defined state and return findings. The server should expose only the data and actions needed for that job; do not provide an unrestricted browser or network shell. See the MCP tools model and the official Python SDK.
- Input: an HTTPS URL, optional CSS selectors to activate, and a bounded timeout.
- Browser state: navigation, optional clicks, and a wait for the page to settle.
- Checks: an accessibility tree snapshot plus axe-core findings.
- Output: URL, timestamp, page title, state actions, ruleset version, violations, incomplete checks, and a limitation note.
Automated output is incomplete. W3C says WCAG testing combines automated testing with human evaluation, and its accessibility evaluation overview says knowledgeable human evaluation is required. Hidden menus, dialogs, and other inactive regions are not tested until activated or rendered. Use WCAG-EM for scope and sampling, and review axe-core documentation.
2. Choose the SDK and transport
| Choice | Use it when | Operational notes |
|---|---|---|
| Python SDK + stdio | A desktop host starts your server as a child process. | Simple local setup; credentials stay on the machine. |
| Python SDK + Streamable HTTP | Several hosts need a shared endpoint. | Add authentication, TLS, tenant isolation, request limits, and observability. |
| TypeScript SDK | Your team already runs Node.js. | Check the current v2 package and host compatibility; older v1 docs describe HTTP+SSE compatibility. |
The official Python documentation covers Python 3.10+, stdio, Streamable HTTP, and SSE. TypeScript v2 documentation identifies the stable line implementing the 2026-07-28 MCP specification. Verify versions against your host before shipping.
3. Build a minimal, safe server
Install dependencies
python -m venv .venv
. .venv/bin/activate
pip install "mcp[cli]" playwright
playwright install chromium
Save this as server.py. Set ALLOWED_HOSTS to domains you own or are authorized to test. An empty value rejects every target.
import os
from datetime import datetime, timezone
from urllib.parse import urlparse
from mcp.server.fastmcp import FastMCP
from playwright.async_api import async_playwright, TimeoutError as PlaywrightTimeout
mcp = FastMCP('web-accessibility')
ALLOWED_HOSTS = {h.strip().lower() for h in os.environ.get('ALLOWED_HOSTS', '').split(',') if h.strip()}
MAX_TIMEOUT_MS = 30_000
AXE_URL = 'https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.10.2/axe.min.js'
def validate_url(raw):
parsed = urlparse(raw)
host = (parsed.hostname or '').lower()
if parsed.scheme != 'https' or not host:
raise ValueError('url must be an https URL')
if host not in ALLOWED_HOSTS and not any(host.endswith('.' + suffix) for suffix in ALLOWED_HOSTS):
raise ValueError('host is not in ALLOWED_HOSTS')
return raw
@mcp.tool()
async def scan_page(url: str, activate: list[str] | None = None, timeout_ms: int = 15000) -> dict:
"""Scan one authorized rendered page and return evidence, not a conformance verdict."""
target = validate_url(url)
timeout_ms = max(1000, min(timeout_ms, MAX_TIMEOUT_MS))
activate = (activate or [])[:10]
if any(len(selector) > 200 for selector in activate):
raise ValueError('each selector must be 200 characters or fewer')
result = {
'url': target,
'checked_at': datetime.now(timezone.utc).isoformat(),
'activated_selectors': [],
'violations': [],
'incomplete': [],
'passes': [],
'limitations': [
'Automated results do not establish WCAG conformance.',
'Human review is required, including keyboard, focus, content meaning, and relevant states.'
]
}
async with async_playwright() as pw:
browser = await pw.chromium.launch(headless=True)
page = await browser.new_page()
try:
await page.goto(target, wait_until='networkidle', timeout=timeout_ms)
result['title'] = await page.title()
for selector in activate:
try:
await page.locator(selector).first.click(timeout=3000)
result['activated_selectors'].append(selector)
except Exception as exc:
result.setdefault('activation_errors', []).append({'selector': selector, 'error': str(exc)})
await page.add_script_tag(url=AXE_URL)
axe = await page.evaluate('''async () => { const r = await axe.run(document, { resultTypes: ['violations', 'incomplete', 'passes'] }); return { testEngine: r.testEngine, violations: r.violations, incomplete: r.incomplete, passes: r.passes }; }''')
result.update(axe)
result['accessibility_snapshot'] = await page.locator('body').aria_snapshot()
except PlaywrightTimeout:
result['error'] = 'navigation_timeout'
result['error_detail'] = 'The page did not reach network idle before timeout_ms.'
finally:
await browser.close()
return result
if __name__ == '__main__':
mcp.run(transport='stdio')
Run it locally:
export ALLOWED_HOSTS=staging.example.com,example.com
python server.py
Configure your MCP host to spawn python /absolute/path/server.py. Keep stdout reserved for MCP traffic and send logs to stderr. The example loads a pinned axe-core script from a CDN; vendor that script for reproducible builds and report its version.
4. Connect the server to a host
{
"mcpServers": {
"web-accessibility": {
"command": "python",
"args": ["/absolute/path/server.py"],
"env": {"ALLOWED_HOSTS": "staging.example.com"}
}
}
}
For a remote service, expose Streamable HTTP behind TLS. Authenticate every request, bind each tenant to its own allow-list, and reject arbitrary proxy destinations. HTTP plus SSE appears in older documentation as a compatibility transport; prefer Streamable HTTP for new remote deployments.
5. Exercise interactive states deliberately
- Define scope: routes, authentication state, viewport, locale, and feature flags.
- Open menus, dialogs, accordions, tab panels, validation errors, and other relevant states.
- Run the scan after each state change and retain the action that produced it.
- Review keyboard order, focus visibility, names and descriptions, color meaning, zoom/reflow, and screen-reader behavior manually.
- Sample representative templates instead of claiming one route represents the whole product.
axe does not inspect hidden regions until they are active or rendered. A clean report from the default state only says that the checked state had no reported violations.
6. Return evidence an agent can use
Include URL and route, timestamp, browser and engine versions, viewport and locale, actions taken, ruleset version, each rule ID and help URL, affected selectors or snippets, incomplete checks, navigation errors, and a human-review checklist. Preserve raw findings for CI artifacts, then provide a short summary.
Do not return compliant: true. Prefer automated_findings_available and state what was not evaluated. W3C guidance makes clear that tools vary and conformance requires automated and human evaluation.
7. Security boundaries
- Allow-list exact hosts and block private IP ranges, loopback, metadata endpoints, and unexpected redirects.
- Use a separate browser context per request and clear cookies and storage between tenants.
- Set navigation, total-job, response-size, and concurrency limits.
- Redact authorization headers, cookies, page content, and screenshots from logs.
- Run Chromium in an isolated worker with a read-only filesystem and no production credentials.
- Do not enable arbitrary JavaScript tools for untrusted clients. Playwright MCP documents arbitrary code execution as equivalent to remote code execution.
8. Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Host rejected | URL is not HTTPS or not allow-listed. | Set ALLOWED_HOSTS and check redirects. |
| Navigation timeout | Slow app, blocked request, or never-ending network activity. | Use a bounded timeout, wait for a page-specific selector, and report the timeout as evidence. |
| Empty findings | Consent dialog, login wall, or inactive state. | Authenticate in a dedicated context, activate the state, and record the action. |
axe is not defined |
The script failed to load. | Vendor the pinned script or permit the CDN; fail clearly when unavailable. |
| MCP disconnects | Logs written to stdout or SDK/host mismatch. | Log to stderr, verify SDK versions, and use the transport your host supports. |
| Browser launch fails | Chromium is missing or sandbox permissions are wrong. | Run playwright install chromium in an isolated worker. |
9. Performance, reliability, and cost
- Browser startup dominates short scans; use a bounded worker pool while isolating contexts and capping concurrency.
- Use
domcontentloadedplus a known-ready selector when network idle is too slow. Record the readiness rule. - Cache immutable fixtures, not authenticated or rapidly changing pages. Include URL, state, viewport, browser, and ruleset in the key.
- Retry only transient navigation or transport failures. Do not retry deterministic validation or accessibility findings.
- Track latency, timeout rate, browser crashes, incomplete checks, and ruleset version. A failed load is not a pass.
- Bound page count and resource size per request and expose usage to callers.
10. Or skip the browser setup
If you need a clean page image for an accessibility review or an agent’s visual context, ScreenshotNeo provides one GET request. It accepts cookie and consent banners before capture and removes more than 60 known consent platforms, newsletter popups, and chat widgets; each step can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and the response reports the verdict in X-Page-Verdict and X-Billed headers. Its MCP server gives Claude, Cursor, and other MCP clients take_screenshot, get_page_info, and capture_pdf tools.
See the ScreenshotNeo API docs. cURL:
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
Python:
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
Node.js:
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
There are 1,000 free screenshots each month with no card. Paid plans start at $5 for 3,000 shots, and every feature is on every plan. Create a free ScreenshotNeo account.
11. FAQ
Can an MCP scan prove WCAG compliance?
No. It provides automated evidence. Conformance requires applicable success criteria, representative scope, and knowledgeable human evaluation.
Should I expose a generic browser tool?
No. Start with a narrow scan tool, strict schemas, allow-listed hosts, and bounded actions.
When should I use stdio?
Use stdio when the host launches a local process. Use Streamable HTTP for a remotely hosted service.
Why scan more than one state?
Inactive menus, dialogs, and other hidden regions are not evaluated until activated or rendered.


