Skip to main content
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

Tool Structure

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

1

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)
2

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';
3

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';
4

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';
5

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';
6

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;
7

Create public exports

Export the tool in index.ts:
export * from './main.js';
export * from './types/index.js';
8

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
);
9

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');
  });
});
10

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

...
11

Build and test

pnpm build
pnpm test
pnpm inspect
Test your tool with the MCP Inspector!

Naming Conventions Checklist

  • Tool folder is PascalCase: YourToolName/
  • Subfolders are lowercase: adapters/, normalizers/, types/, utils/
  • Main files: index.ts, main.ts
  • Category files: yourtool.adapter.ts, yourtool.normalizer.ts, etc.
  • Each subfolder has index.ts that only re-exports
  • Classes: PascalCase (YourToolAdapter)
  • Functions: camelCase (buildAnalysisTarget)
  • Interfaces: PascalCase (YourToolInput)
  • Constants: UPPER_SNAKE_CASE (DEFAULT_TIMEOUT)
  • Tool definition exported from main.ts
  • Dispose function exported from main.ts
  • Public API exported from index.ts
  • Tool registered in src/tools/index.ts

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

// 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
}

Example: Real Tool Implementation

// src/tools/Axe/main.ts
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { AxeAdapter } from './adapters/index.js';
import { AxeToolInputSchema, type AxeToolInput } from './types/index.js';
import { buildAnalysisTarget, buildAnalysisOptions, formatOutput } from './utils/index.js';
import {
  type ToolDefinition,
  type ToolResponse,
  createJsonResponse,
  createErrorResponse,
  withToolContext,
} from '../Base/index.js';

let sharedAdapter: AxeAdapter | null = null;
let currentIgnoreHTTPS = false;

function getAdapter(ignoreHTTPSErrors = false): AxeAdapter {
  if (!sharedAdapter || currentIgnoreHTTPS !== ignoreHTTPSErrors) {
    if (sharedAdapter) {
      sharedAdapter.dispose().catch(() => {});
    }
    sharedAdapter = new AxeAdapter({
      headless: true,
      timeout: 30000,
      ignoreHTTPSErrors,
    });
    currentIgnoreHTTPS = ignoreHTTPSErrors;
  }
  return sharedAdapter;
}

async function disposeAdapter(): Promise<void> {
  if (sharedAdapter) {
    await sharedAdapter.dispose();
    sharedAdapter = null;
  }
}

process.on('SIGINT', () => {
  disposeAdapter().finally(() => process.exit(0));
});

process.on('SIGTERM', () => {
  disposeAdapter().finally(() => process.exit(0));
});

const handleAxeAnalysis = withToolContext<AxeToolInput>(
  'analyze-with-axe',
  async (input, context): Promise<ToolResponse> => {
    const ignoreHTTPSErrors = input.options?.browser?.ignoreHTTPSErrors ?? false;
    
    const adapter = getAdapter(ignoreHTTPSErrors);
    const isAvailable = await adapter.isAvailable();
    
    if (!isAvailable) {
      return createErrorResponse(
        new Error('Axe adapter is not available.')
      );
    }
    
    const target = buildAnalysisTarget(input);
    const options = buildAnalysisOptions(input);
    
    const result = await adapter.analyze(target, options);
    const output = formatOutput(result);
    
    return createJsonResponse(output, !result.success);
  }
);

export const analyzeWithAxeTool: ToolDefinition = {
  name: 'analyze-with-axe',
  description: 'Analyze with axe-core...',
  inputSchema: AxeToolInputSchema,
  handler: handleAxeAnalysis,
};

export const disposeAxeAdapter = disposeAdapter;

Troubleshooting

Checklist:
  • Tool exported from src/tools/index.ts
  • Server rebuilt: pnpm build
  • MCP client restarted
  • Tool registered in server (if manual registration)
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

pnpm inspect
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

Build docs developers (and LLMs) love