This guide walks you through creating a new accessibility tool for AccessibilityHub, following the project’s modular structure and conventions.
Overview
Tools are the core components of AccessibilityHub. Each tool integrates an external accessibility testing library or implements specific analysis functionality.
Existing tools:
Axe - axe-core integration
Pa11y - Pa11y integration
Lighthouse - Lighthouse integration
Contrast - Color contrast analysis
AnalyzeMixed - Combined multi-tool analysis
Every tool follows this consistent structure:
ToolName/
├── index.ts # Public exports
├── main.ts # Main logic, MCP registration, handlers
├── adapters/ # External library integrations
│ ├── toolname.adapter.ts
│ └── index.ts
├── normalizers/ # Input/output data transformation
│ ├── toolname.normalizer.ts
│ └── index.ts
├── types/ # TypeScript types and interfaces
│ ├── toolname.type.ts
│ └── index.ts
└── utils/ # Helper functions
├── toolname.utils.ts
└── index.ts
Use PascalCase for the tool folder name (e.g., AnalyzeMixed, Lighthouse) Use lowercase for subfolders (adapters, normalizers, etc.)
Step-by-Step Guide
Create tool folder structure
Create the tool folder in src/tools/: cd src/tools
mkdir YourToolName
cd YourToolName
mkdir adapters normalizers types utils
touch index.ts main.ts
touch adapters/yourtool.adapter.ts adapters/index.ts
touch normalizers/yourtool.normalizer.ts normalizers/index.ts
touch types/yourtool.types.ts types/index.ts
touch utils/yourtool.utils.ts utils/index.ts
Replace YourToolName with your tool name in PascalCase (e.g., WaveAnalyzer) Replace yourtool with kebab-case (e.g., wave-analyzer)
Define types
Create type definitions in types/yourtool.types.ts: import { z } from 'zod' ;
// Input type
export interface YourToolInput {
url ?: string ;
html ?: string ;
options ?: YourToolOptions ;
}
// Options type
export interface YourToolOptions {
wcagLevel ?: 'A' | 'AA' | 'AAA' ;
rules ?: string [];
excludeRules ?: string [];
browser ?: BrowserOptions ;
}
// Input validation schema
export const YourToolInputSchema = z . object ({
url: z . string (). url (). optional (),
html: z . string (). optional (),
options: z . object ({
wcagLevel: z . enum ([ 'A' , 'AA' , 'AAA' ]). optional (),
rules: z . array ( z . string ()). optional (),
excludeRules: z . array ( z . string ()). optional (),
browser: z . object ({
viewport: z . object ({
width: z . number (),
height: z . number ()
}). optional (),
waitForTimeout: z . number (). optional (),
ignoreHTTPSErrors: z . boolean (). optional ()
}). optional ()
}). optional ()
}). refine (
( data ) => data . url || data . html ,
{ message: 'Either url or html must be provided' }
);
// Output type
export interface YourToolResult {
success : boolean ;
issues : Issue [];
issueCount : number ;
summary : Summary ;
metadata : Metadata ;
error ?: string ;
}
Export from types/index.ts: export * from './yourtool.types.js' ;
Create adapter
Implement the adapter in adapters/yourtool.adapter.ts: import type { Browser , Page } from 'puppeteer' ;
import puppeteer from 'puppeteer' ;
import type { YourToolOptions , YourToolResult } from '../types' ;
export class YourToolAdapter {
private browser : Browser | null = null ;
private config : AdapterConfig ;
constructor ( config : AdapterConfig = {}) {
this . config = {
headless: true ,
timeout: 30000 ,
ignoreHTTPSErrors: false ,
... config
};
}
async initialize () : Promise < void > {
if ( this . browser ) return ;
this . browser = await puppeteer . launch ({
headless: this . config . headless ,
args: [ '--no-sandbox' , '--disable-setuid-sandbox' ]
});
}
async analyze (
target : AnalysisTarget ,
options : YourToolOptions = {}
) : Promise < YourToolResult > {
await this . initialize ();
const page = await this . browser ! . newPage ();
try {
// Navigate to URL or set HTML content
if ( target . type === 'url' ) {
await page . goto ( target . value , {
waitUntil: 'networkidle0' ,
timeout: this . config . timeout
});
} else {
await page . setContent ( target . value );
}
// Wait for any custom selectors or timeouts
if ( options . browser ?. waitForTimeout ) {
await page . waitForTimeout ( options . browser . waitForTimeout );
}
// Run your accessibility analysis here
// This is where you integrate the external library
const results = await page . evaluate (() => {
// Call your external library
// return analysisResults;
});
return {
success: true ,
issues: results . violations ,
issueCount: results . violations . length ,
summary: this . buildSummary ( results ),
metadata: this . buildMetadata ( results )
};
} catch ( error ) {
return {
success: false ,
issues: [],
issueCount: 0 ,
summary: {},
metadata: {},
error: error instanceof Error ? error . message : 'Unknown error'
};
} finally {
await page . close ();
}
}
async isAvailable () : Promise < boolean > {
try {
await this . initialize ();
return this . browser !== null ;
} catch {
return false ;
}
}
async dispose () : Promise < void > {
if ( this . browser ) {
await this . browser . close ();
this . browser = null ;
}
}
private buildSummary ( results : any ) : Summary {
// Build summary from results
return {};
}
private buildMetadata ( results : any ) : Metadata {
// Build metadata from results
return {};
}
}
Export from adapters/index.ts: export * from './yourtool.adapter.js' ;
Create normalizer
Implement the normalizer in normalizers/yourtool.normalizer.ts: import type { YourToolResult } from '../types' ;
import type { Issue } from '../../Base/types' ;
import { enrichIssueWithWCAG } from '../../../shared/utils/wcag-mapper' ;
export function normalizeYourToolResult (
rawResult : YourToolResult
) : NormalizedResult {
if ( ! rawResult . success ) {
return {
success: false ,
issues: [],
issueCount: 0 ,
error: rawResult . error
};
}
const normalizedIssues = rawResult . issues . map ( normalizeIssue );
return {
success: true ,
issues: normalizedIssues ,
issueCount: normalizedIssues . length ,
summary: buildSummary ( normalizedIssues ),
metadata: rawResult . metadata
};
}
function normalizeIssue ( rawIssue : RawIssue ) : Issue {
// Map raw issue to standard Issue format
const baseIssue = {
ruleId: rawIssue . id ,
severity: mapSeverity ( rawIssue . severity ),
message: rawIssue . description ,
location: {
selector: rawIssue . selector ,
html: rawIssue . html
},
wcag: {
criterion: extractWCAGCriterion ( rawIssue ),
level: extractWCAGLevel ( rawIssue ),
principle: extractWCAGPrinciple ( rawIssue )
}
};
// Enrich with WCAG context
return enrichIssueWithWCAG ( baseIssue );
}
function mapSeverity ( severity : string ) : Issue [ 'severity' ] {
const severityMap : Record < string , Issue [ 'severity' ]> = {
'critical' : 'critical' ,
'error' : 'serious' ,
'warning' : 'moderate' ,
'notice' : 'minor'
};
return severityMap [ severity ] || 'moderate' ;
}
function extractWCAGCriterion ( issue : RawIssue ) : string {
// Extract WCAG criterion from issue
// e.g., parse from ruleId or tags
return '1.1.1' ;
}
function extractWCAGLevel ( issue : RawIssue ) : 'A' | 'AA' | 'AAA' {
// Extract WCAG level from issue
return 'AA' ;
}
function extractWCAGPrinciple ( issue : RawIssue ) : string {
// Extract WCAG principle from criterion
const criterion = extractWCAGCriterion ( issue );
const principleMap : Record < string , string > = {
'1' : 'perceivable' ,
'2' : 'operable' ,
'3' : 'understandable' ,
'4' : 'robust'
};
return principleMap [ criterion [ 0 ]] || 'perceivable' ;
}
function buildSummary ( issues : Issue []) : Summary {
return {
total: issues . length ,
bySeverity: {
critical: issues . filter ( i => i . severity === 'critical' ). length ,
serious: issues . filter ( i => i . severity === 'serious' ). length ,
moderate: issues . filter ( i => i . severity === 'moderate' ). length ,
minor: issues . filter ( i => i . severity === 'minor' ). length
}
};
}
Export from normalizers/index.ts: export * from './yourtool.normalizer.js' ;
Create utilities
Add helper functions in utils/yourtool.utils.ts: import type { YourToolInput , YourToolOptions } from '../types' ;
export function buildAnalysisTarget ( input : YourToolInput ) : AnalysisTarget {
if ( input . url ) {
return { type: 'url' , value: input . url };
}
if ( input . html ) {
return { type: 'html' , value: input . html };
}
throw new Error ( 'Either url or html must be provided' );
}
export function buildAnalysisOptions (
input : YourToolInput
) : YourToolOptions {
return {
wcagLevel: input . options ?. wcagLevel ?? 'AA' ,
rules: input . options ?. rules ,
excludeRules: input . options ?. excludeRules ,
browser: input . options ?. browser
};
}
export function formatOutput ( result : YourToolResult ) : FormattedOutput {
return {
tool: 'your-tool-name' ,
success: result . success ,
issueCount: result . issueCount ,
issues: result . issues ,
summary: result . summary ,
metadata: result . metadata ,
error: result . error
};
}
Export from utils/index.ts: export * from './yourtool.utils.js' ;
Implement main tool
Create the tool in main.ts: import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' ;
import { YourToolAdapter } from './adapters/index.js' ;
import {
YourToolInputSchema ,
type YourToolInput
} from './types/index.js' ;
import {
buildAnalysisTarget ,
buildAnalysisOptions ,
formatOutput
} from './utils/index.js' ;
import {
type ToolDefinition ,
type ToolResponse ,
createJsonResponse ,
createErrorResponse ,
withToolContext ,
} from '../Base/index.js' ;
// Shared adapter instance
let sharedAdapter : YourToolAdapter | null = null ;
let currentIgnoreHTTPS = false ;
function getAdapter ( ignoreHTTPSErrors = false ) : YourToolAdapter {
if ( ! sharedAdapter || currentIgnoreHTTPS !== ignoreHTTPSErrors ) {
if ( sharedAdapter ) {
sharedAdapter . dispose (). catch (() => {});
}
sharedAdapter = new YourToolAdapter ({
headless: true ,
timeout: 30000 ,
ignoreHTTPSErrors ,
});
currentIgnoreHTTPS = ignoreHTTPSErrors ;
}
return sharedAdapter ;
}
async function disposeAdapter () : Promise < void > {
if ( sharedAdapter ) {
await sharedAdapter . dispose ();
sharedAdapter = null ;
}
}
// Cleanup on process exit
process . on ( 'SIGINT' , () => {
disposeAdapter (). finally (() => process . exit ( 0 ));
});
process . on ( 'SIGTERM' , () => {
disposeAdapter (). finally (() => process . exit ( 0 ));
});
// Tool handler
const handleYourToolAnalysis = withToolContext < YourToolInput >(
'analyze-with-yourtool' ,
async ( input , context ) : Promise < ToolResponse > => {
const ignoreHTTPSErrors =
input . options ?. browser ?. ignoreHTTPSErrors ?? false ;
context . logger . debug ( 'Building analysis configuration' , {
hasUrl: !! input . url ,
hasHtml: !! input . html ,
wcagLevel: input . options ?. wcagLevel ?? 'AA' ,
ignoreHTTPSErrors ,
});
const adapter = getAdapter ( ignoreHTTPSErrors );
const isAvailable = await adapter . isAvailable ();
if ( ! isAvailable ) {
return createErrorResponse (
new Error ( 'Adapter is not available. Browser may have failed to launch.' )
);
}
const target = buildAnalysisTarget ( input );
const options = buildAnalysisOptions ( input );
context . logger . info ( 'Starting analysis' , {
targetType: target . type ,
target: target . type === 'url' ? target . value : '[html content]' ,
});
const result = await adapter . analyze ( target , options );
if ( ! result . success ) {
context . logger . warn ( 'Analysis completed with errors' , {
error: result . error ,
});
}
const output = formatOutput ( result );
return createJsonResponse ( output , ! result . success );
}
);
// Tool definition
export const analyzeWithYourToolTool : ToolDefinition = {
name: 'analyze-with-yourtool' ,
description: `Analyze a web page or HTML content for accessibility issues using YourTool.
Returns accessibility violations based on WCAG guidelines.
Input options:
- url: URL of the page to analyze
- html: Raw HTML content to analyze (alternative to url)
- options.wcagLevel: WCAG level to check (A, AA, or AAA). Default: AA
- options.rules: Specific rule IDs to run
- options.excludeRules: Rule IDs to exclude
- options.browser.viewport: Viewport size { width, height }
- options.browser.waitForTimeout: Wait time before analysis (ms)
- options.browser.ignoreHTTPSErrors: Ignore HTTPS certificate errors
Output includes:
- Issues with enriched human context (WCAG explanations, user impact)
- Severity levels and priority
- Affected user groups
- Remediation effort estimates
- Suggested fixes
` ,
inputSchema: YourToolInputSchema ,
handler: handleYourToolAnalysis ,
};
export const disposeYourToolAdapter = disposeAdapter ;
Create public exports
Export the tool in index.ts: export * from './main.js' ;
export * from './types/index.js' ;
Register tool
Add your tool to src/tools/index.ts: export * from './Base/index.js' ;
export { analyzeWithAxeTool , disposeAxeAdapter } from './Axe/index.js' ;
export { analyzeWithPa11yTool , disposePa11yAdapter } from './Pa11y/index.js' ;
export { analyzeMixedTool , disposeAnalyzeMixedAdapters } from './AnalyzeMixed/index.js' ;
export { analyzeContrastTool , disposeContrastAdapter } from './Contrast/index.js' ;
export { analyzeWithLighthouseTool , disposeLighthouseAdapter } from './Lighthouse/index.js' ;
// Add your tool:
export { analyzeWithYourToolTool , disposeYourToolAdapter } from './YourToolName/index.js' ;
Register in the server (if not auto-registered): // src/server.ts
import { analyzeWithYourToolTool } from './tools' ;
server . tool (
analyzeWithYourToolTool . name ,
analyzeWithYourToolTool . description ,
analyzeWithYourToolTool . inputSchema ,
analyzeWithYourToolTool . handler
);
Add tests
Create tests in tests/tools/YourToolName/: // tests/tools/YourToolName/main.test.ts
import { describe , it , expect , beforeAll , afterAll } from 'vitest' ;
import { analyzeWithYourToolTool } from '../../../src/tools/YourToolName' ;
import { createMockServer } from '../../helpers/mock-server' ;
describe ( 'YourToolName Tool' , () => {
let server : MockServer ;
beforeAll (() => {
server = createMockServer ();
});
afterAll ( async () => {
await server . close ();
});
it ( 'should analyze URL successfully' , async () => {
const result = await analyzeWithYourToolTool . handler ({
url: 'https://example.com' ,
options: { wcagLevel: 'AA' }
});
expect ( result . success ). toBe ( true );
expect ( result . issues ). toBeInstanceOf ( Array );
});
it ( 'should analyze HTML successfully' , async () => {
const html = '<img src="test.png">' ;
const result = await analyzeWithYourToolTool . handler ({
html ,
options: { wcagLevel: 'AA' }
});
expect ( result . success ). toBe ( true );
expect ( result . issueCount ). toBeGreaterThan ( 0 );
});
it ( 'should require url or html' , async () => {
await expect (
analyzeWithYourToolTool . handler ({})
). rejects . toThrow ( 'Either url or html must be provided' );
});
});
Document your tool
Create documentation in docs/tools/analyze-with-yourtool.mdx: ---
title : analyze-with-yourtool
description : Accessibility analysis using YourTool
---
## Overview
The ` analyze-with-yourtool ` tool provides accessibility analysis using YourTool...
## Usage
\` ``json
{
"url" : "https://example.com" ,
"options" : {
"wcagLevel" : "AA"
}
}
\` ``
## Parameters
...
## Examples
...
Build and test
pnpm build
pnpm test
pnpm inspect
Test your tool with the MCP Inspector!
Naming Conventions Checklist
Best Practices
1. Adapter Pattern
Always use the adapter pattern to wrap external libraries. This allows:
Easy mocking in tests
Consistent error handling
Resource cleanup
Switching implementations
2. Shared Browser Instance
Good: Shared Instance
Bad: New Instance Each Time
// Reuse browser instance across requests
let sharedAdapter : YourToolAdapter | null = null ;
function getAdapter () : YourToolAdapter {
if ( ! sharedAdapter ) {
sharedAdapter = new YourToolAdapter ();
}
return sharedAdapter ;
}
3. Error Handling
Always handle errors gracefully:
try {
const result = await adapter . analyze ( target , options );
return createJsonResponse ( result );
} catch ( error ) {
context . logger . error ( 'Analysis failed' , { error });
return createErrorResponse ( error );
}
4. Resource Cleanup
Implement cleanup handlers:
process . on ( 'SIGINT' , () => {
disposeAdapter (). finally (() => process . exit ( 0 ));
});
process . on ( 'SIGTERM' , () => {
disposeAdapter (). finally (() => process . exit ( 0 ));
});
5. Enrichment with WCAG Context
Always enrich issues with human context:
import { enrichIssueWithWCAG } from '../../../shared/utils/wcag-mapper' ;
function normalizeIssue ( rawIssue : RawIssue ) : Issue {
const baseIssue = {
ruleId: rawIssue . id ,
severity: mapSeverity ( rawIssue . severity ),
// ... other fields
};
return enrichIssueWithWCAG ( baseIssue ); // ✅ Add human context
}
View Axe Tool Implementation
Troubleshooting
Tool not appearing in MCP client
Common issues:
Chrome not downloading: Check network/proxy
Browser launch fails: Try headless: 'new'
Timeout errors: Increase timeout in adapter config
Debug: const adapter = new YourToolAdapter ({
headless: false , // See browser window
timeout: 60000 // Longer timeout
});
Common causes:
Missing .js extension in imports (ESM requirement)
Circular dependencies
Missing type exports
Fix: // ✅ Include .js extension
import { Something } from './module.js' ;
// ❌ Missing extension
import { Something } from './module' ;
Debug tests: pnpm test -- --reporter=verbose
pnpm test -- --run # No watch mode
pnpm test -- YourToolName # Run specific test
Next Steps
After creating your tool:
Test with Inspector Interactive testing of your tool
Add to analyze-mixed Integrate with the combined analysis tool for maximum coverage
Create Prompts Add MCP prompts for common workflows using your tool
Documentation Write comprehensive docs in docs/tools/
Project Structure Understand the codebase organization
Contributing Overview General contributing guidelines