Skip to main content
Glama

Canvas MCP Server

accessibility.py16.3 kB
"""Accessibility-related MCP tools for Canvas API. This module provides tools to fetch and parse UFIXIT accessibility reports, format violations for easy consumption, and optionally apply automated fixes for common accessibility issues. """ import json import re from typing import Any, Dict, List, Optional, Union from mcp.server.fastmcp import FastMCP from ..core.cache import get_course_id from ..core.client import fetch_all_paginated_results, make_canvas_request from ..core.validation import validate_params def register_accessibility_tools(mcp: FastMCP) -> None: """Register all accessibility-related MCP tools.""" @mcp.tool() @validate_params async def fetch_ufixit_report( course_identifier: Union[str, int], page_title: str = "UFIXIT" ) -> str: """Fetch UFIXIT accessibility report from Canvas course pages. UFIXIT reports are typically stored as Canvas pages. This tool fetches the report content for further analysis. Args: course_identifier: The Canvas course code (e.g., badm_554_120251_246794) or ID page_title: Title of the page containing the UFIXIT report (default: "UFIXIT") Returns: JSON string with report content or error message """ course_id = await get_course_id(course_identifier) # First, try to find the page by title pages = await fetch_all_paginated_results( f"/courses/{course_id}/pages", {"per_page": 100, "search_term": page_title} ) if isinstance(pages, dict) and "error" in pages: return json.dumps({"error": f"Error fetching pages: {pages['error']}"}) if not pages: return json.dumps({ "error": f"No page found with title containing '{page_title}'", "suggestion": "Try specifying a different page_title parameter" }) # Get the first matching page target_page = pages[0] page_url = target_page.get("url") if not page_url: return json.dumps({"error": "Found page but no URL available"}) # Fetch the full page content page_response = await make_canvas_request( "get", f"/courses/{course_id}/pages/{page_url}" ) if "error" in page_response: return json.dumps({"error": f"Error fetching page content: {page_response['error']}"}) return json.dumps({ "page_title": page_response.get("title", "Unknown"), "page_url": page_url, "page_id": page_response.get("page_id"), "body": page_response.get("body", ""), "updated_at": page_response.get("updated_at"), "course_id": course_id }) @mcp.tool() @validate_params async def parse_ufixit_violations(report_json: str) -> str: """Parse UFIXIT report content to extract accessibility violations. Takes the output from fetch_ufixit_report and extracts structured violation data for analysis and remediation. Args: report_json: JSON string from fetch_ufixit_report containing the report Returns: JSON string with parsed violations and summary statistics """ try: report = json.loads(report_json) except json.JSONDecodeError: return json.dumps({"error": "Invalid JSON input"}) if "error" in report: return json.dumps(report) body = report.get("body", "") if not body: return json.dumps({"error": "Report body is empty"}) violations = _extract_violations_from_html(body) # Generate summary statistics summary = _generate_violation_summary(violations) return json.dumps({ "summary": summary, "violations": violations, "report_metadata": { "page_title": report.get("page_title"), "updated_at": report.get("updated_at"), "course_id": report.get("course_id") } }) @mcp.tool() @validate_params async def format_accessibility_summary(violations_json: str) -> str: """Format parsed violations into a human-readable summary. Args: violations_json: JSON string from parse_ufixit_violations Returns: Formatted text summary of accessibility violations """ try: data = json.loads(violations_json) except json.JSONDecodeError: return "Error: Invalid JSON input" if "error" in data: return f"Error: {data['error']}" summary = data.get("summary", {}) violations = data.get("violations", []) metadata = data.get("report_metadata", {}) # Build formatted output lines = ["# Accessibility Report Summary", ""] # Metadata if metadata.get("page_title"): lines.append(f"**Report**: {metadata['page_title']}") if metadata.get("updated_at"): lines.append(f"**Last Updated**: {metadata['updated_at']}") lines.append("") # Summary statistics lines.append("## Overview") lines.append(f"- **Total Violations**: {summary.get('total_violations', 0)}") lines.append("") if summary.get("by_severity"): lines.append("### By Severity") for severity, count in summary["by_severity"].items(): lines.append(f"- {severity.title()}: {count}") lines.append("") if summary.get("by_wcag_criterion"): lines.append("### By WCAG Criterion") for criterion, count in sorted(summary["by_wcag_criterion"].items()): lines.append(f"- WCAG {criterion}: {count}") lines.append("") # Detailed violations if violations: lines.append("## Detailed Violations") lines.append("") for i, violation in enumerate(violations[:20], 1): # Limit to first 20 lines.append(f"### {i}. {violation.get('type', 'Unknown Issue')}") if violation.get("wcag_criterion"): lines.append(f"**WCAG**: {violation['wcag_criterion']}") if violation.get("severity"): lines.append(f"**Severity**: {violation['severity']}") if violation.get("description"): lines.append(f"**Description**: {violation['description']}") if violation.get("location"): lines.append(f"**Location**: {violation['location']}") if violation.get("remediation"): lines.append(f"**How to Fix**: {violation['remediation']}") lines.append("") if len(violations) > 20: lines.append(f"*...and {len(violations) - 20} more violations*") return "\n".join(lines) @mcp.tool() @validate_params async def scan_course_content_accessibility( course_identifier: Union[str, int], content_types: str = "pages,assignments" ) -> str: """Scan Canvas course content for basic accessibility issues. This provides a lightweight alternative to UFIXIT by scanning course content directly for common accessibility problems. Args: course_identifier: The Canvas course code or ID content_types: Comma-separated list of content types to scan (pages, assignments, discussions, syllabus) Returns: JSON string with detected accessibility issues """ course_id = await get_course_id(course_identifier) types = [t.strip() for t in content_types.split(",")] all_issues: List[Dict[str, Any]] = [] # Scan pages if "pages" in types: pages = await fetch_all_paginated_results( f"/courses/{course_id}/pages", {"per_page": 100} ) if isinstance(pages, list): for page in pages: issues = _check_content_accessibility( page.get("body", ""), content_type="page", content_id=page.get("page_id"), content_title=page.get("title") ) all_issues.extend(issues) # Scan assignments if "assignments" in types: assignments = await fetch_all_paginated_results( f"/courses/{course_id}/assignments", {"per_page": 100} ) if isinstance(assignments, list): for assignment in assignments: issues = _check_content_accessibility( assignment.get("description", ""), content_type="assignment", content_id=assignment.get("id"), content_title=assignment.get("name") ) all_issues.extend(issues) # Generate summary summary = _generate_violation_summary(all_issues) return json.dumps({ "summary": summary, "issues": all_issues, "scanned_types": types }) def _extract_violations_from_html(html_content: str) -> List[Dict[str, Any]]: """Extract accessibility violations from UFIXIT report HTML. This parser handles common UFIXIT/UDOIT report formats. """ violations: List[Dict[str, Any]] = [] # Try to find violation patterns in the HTML # UFIXIT reports often use tables or lists to display violations # Pattern 1: Look for WCAG criterion mentions wcag_pattern = r'WCAG\s+(\d+\.\d+\.\d+)' wcag_matches = re.finditer(wcag_pattern, html_content, re.IGNORECASE) # Pattern 2: Look for severity indicators severity_pattern = r'(critical|serious|moderate|minor|error|warning)' # Pattern 3: Look for common issue types issue_patterns = [ (r'missing\s+alt\s+text', 'missing_alt_text', 'Images missing alternative text'), (r'heading\s+structure', 'heading_structure', 'Improper heading hierarchy'), (r'color\s+contrast', 'color_contrast', 'Insufficient color contrast'), (r'link\s+text', 'link_text', 'Non-descriptive link text'), (r'table\s+header', 'table_headers', 'Tables missing proper headers'), (r'form\s+label', 'form_labels', 'Form inputs missing labels'), ] # Extract structured violations from HTML # This is a simplified parser - real UFIXIT reports may have different formats lines = html_content.split('\n') current_violation: Dict[str, Any] = {} for line in lines: # Check for WCAG criterion wcag_match = re.search(wcag_pattern, line, re.IGNORECASE) if wcag_match: if current_violation: violations.append(current_violation) current_violation = { "wcag_criterion": wcag_match.group(1), "type": "unknown", "severity": "moderate" } # Check for severity severity_match = re.search(severity_pattern, line, re.IGNORECASE) if severity_match and current_violation: current_violation["severity"] = severity_match.group(1).lower() # Check for issue types for pattern, issue_type, description in issue_patterns: if re.search(pattern, line, re.IGNORECASE): if current_violation: current_violation["type"] = issue_type current_violation["description"] = description # Extract location information if 'page' in line.lower() or 'assignment' in line.lower(): if current_violation and "location" not in current_violation: current_violation["location"] = re.sub(r'<[^>]+>', '', line).strip()[:100] if current_violation: violations.append(current_violation) return violations def _check_content_accessibility( html_content: str, content_type: str, content_id: Optional[int], content_title: Optional[str] ) -> List[Dict[str, Any]]: """Check HTML content for basic accessibility issues.""" issues: List[Dict[str, Any]] = [] if not html_content: return issues # Check for images without alt text img_pattern = r'<img(?![^>]*alt=)[^>]*>' for match in re.finditer(img_pattern, html_content, re.IGNORECASE): issues.append({ "type": "missing_alt_text", "wcag_criterion": "1.1.1", "wcag_level": "A", "severity": "serious", "content_type": content_type, "content_id": content_id, "content_title": content_title, "description": "Image missing alt attribute", "remediation": "Add descriptive alt text to all images", "auto_fixable": False }) # Check for empty headings empty_heading_pattern = r'<h[1-6][^>]*>\s*</h[1-6]>' for match in re.finditer(empty_heading_pattern, html_content, re.IGNORECASE): issues.append({ "type": "empty_heading", "wcag_criterion": "2.4.6", "wcag_level": "AA", "severity": "moderate", "content_type": content_type, "content_id": content_id, "content_title": content_title, "description": "Empty heading element found", "remediation": "Remove empty headings or add descriptive text", "auto_fixable": False }) # Check for tables without headers table_without_th = r'<table(?:(?!<th).)*?</table>' for match in re.finditer(table_without_th, html_content, re.IGNORECASE | re.DOTALL): issues.append({ "type": "table_without_headers", "wcag_criterion": "1.3.1", "wcag_level": "A", "severity": "serious", "content_type": content_type, "content_id": content_id, "content_title": content_title, "description": "Table missing header cells", "remediation": "Add <th> elements to define table headers", "auto_fixable": False }) # Check for non-descriptive link text bad_link_patterns = [ r'<a[^>]*>click here</a>', r'<a[^>]*>here</a>', r'<a[^>]*>read more</a>', r'<a[^>]*>more</a>', ] for pattern in bad_link_patterns: for match in re.finditer(pattern, html_content, re.IGNORECASE): issues.append({ "type": "non_descriptive_link", "wcag_criterion": "2.4.4", "wcag_level": "A", "severity": "moderate", "content_type": content_type, "content_id": content_id, "content_title": content_title, "description": "Link text is not descriptive", "remediation": "Use descriptive link text that explains the destination", "auto_fixable": False }) return issues def _generate_violation_summary(violations: List[Dict[str, Any]]) -> Dict[str, Any]: """Generate summary statistics from violations.""" summary: Dict[str, Any] = { "total_violations": len(violations), "by_severity": {}, "by_type": {}, "by_wcag_criterion": {}, "by_content_type": {} } for violation in violations: # Count by severity severity = violation.get("severity", "unknown") summary["by_severity"][severity] = summary["by_severity"].get(severity, 0) + 1 # Count by type vtype = violation.get("type", "unknown") summary["by_type"][vtype] = summary["by_type"].get(vtype, 0) + 1 # Count by WCAG criterion wcag = violation.get("wcag_criterion", "unknown") summary["by_wcag_criterion"][wcag] = summary["by_wcag_criterion"].get(wcag, 0) + 1 # Count by content type content_type = violation.get("content_type", "unknown") summary["by_content_type"][content_type] = summary["by_content_type"].get(content_type, 0) + 1 return summary

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/vishalsachdev/canvas-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server