Skip to content

Commit 639c39c

Browse files
committed
Add accessibility audit API and MCP tool
Expose accessibility auditing across the stack: add IPage.RunAccessibilityAuditAsync and a Page.RunAccessibilityAuditAsync implementation, and register a new MCP tool (audit_accessibility) in AccessibilityTools. Wire the tool into McpServerHost so it is advertised to clients. Enhance PageSnapshotService to build a reverse ref->backend-node map and add GetRefForNodeId so audit results can be tied to element refs. Add unit, integration, and wire tests for the tool and update test fakes to support audit results and failures. Small test/interface adjustments to accommodate the new API.
1 parent 95d77fd commit 639c39c

11 files changed

Lines changed: 514 additions & 0 deletions

File tree

src/Motus.Abstractions/IPage.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,16 @@ public interface IPage : IAsyncDisposable
177177
/// <returns>A snapshot of the accessibility tree.</returns>
178178
Task<AccessibilitySnapshot> AccessibilitySnapshotAsync(CancellationToken ct = default);
179179

180+
/// <summary>
181+
/// Runs the built-in WCAG accessibility rules against the page's accessibility
182+
/// tree and returns the violations found. Each violation carries a rule id,
183+
/// severity, message, and, where the offending element is addressable, the
184+
/// backend DOM node identifier that <see cref="LocatorByBackendNodeId"/> can target.
185+
/// </summary>
186+
/// <param name="ct">A token to cancel the operation.</param>
187+
/// <returns>The audit result, including the violations and pass count.</returns>
188+
Task<AccessibilityAuditResult> RunAccessibilityAuditAsync(CancellationToken ct = default);
189+
180190
// --- Locators ---
181191

182192
/// <summary>

src/Motus.Mcp/McpServerHost.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ internal static async Task RunAsync(
6868
mcpBuilder.WithTools<PageTools>(McpJsonUtilities.DefaultOptions);
6969
mcpBuilder.WithTools<NetworkTools>(McpJsonUtilities.DefaultOptions);
7070
mcpBuilder.WithTools<ConsoleTools>(McpJsonUtilities.DefaultOptions);
71+
mcpBuilder.WithTools<AccessibilityTools>(McpJsonUtilities.DefaultOptions);
7172

7273
using var host = builder.Build();
7374

src/Motus.Mcp/Snapshot/PageSnapshotService.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ public sealed class PageSnapshotService
1111
{
1212
private readonly IPage _page;
1313
private IReadOnlyDictionary<string, long>? _refToBackendNodeId;
14+
private IReadOnlyDictionary<long, string>? _backendNodeIdToRef;
1415

1516
public PageSnapshotService(IPage page)
1617
{
@@ -70,10 +71,21 @@ public async Task<string> TakeSnapshotAsync(string? rootRef, int? maxDepth, Canc
7071
}
7172

7273
_refToBackendNodeId = serialized.RefToBackendNodeId;
74+
_backendNodeIdToRef = BuildReverseMap(serialized.RefToBackendNodeId);
7375
LastSnapshot = serialized.Text;
7476
return serialized.Text;
7577
}
7678

79+
private static Dictionary<long, string> BuildReverseMap(IReadOnlyDictionary<string, long> forward)
80+
{
81+
// The forward map is 1:1 (each ref maps to a distinct backend node), so a
82+
// straight inversion is unambiguous.
83+
var reverse = new Dictionary<long, string>(forward.Count);
84+
foreach (var (refId, backendNodeId) in forward)
85+
reverse[backendNodeId] = refId;
86+
return reverse;
87+
}
88+
7789
/// <summary>
7890
/// Resolves a ref from the current snapshot to a locator. The element is
7991
/// resolved lazily when an action runs on the returned locator; if it has since
@@ -91,4 +103,15 @@ public ILocator ResolveRef(string refId)
91103

92104
return _page.LocatorByBackendNodeId(backendNodeId);
93105
}
106+
107+
/// <summary>
108+
/// Returns the ref the current snapshot assigned to the given backend DOM node,
109+
/// or null when no snapshot has been taken or the node was not assigned a ref
110+
/// (for example, a node the snapshot does not address).
111+
/// </summary>
112+
public string? GetRefForNodeId(long backendNodeId)
113+
=> _backendNodeIdToRef is not null
114+
&& _backendNodeIdToRef.TryGetValue(backendNodeId, out var refId)
115+
? refId
116+
: null;
94117
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
using System.ComponentModel;
2+
using System.Text.Json;
3+
using System.Text.Json.Nodes;
4+
using ModelContextProtocol.Protocol;
5+
using ModelContextProtocol.Server;
6+
using Motus.Abstractions;
7+
8+
namespace Motus.Mcp;
9+
10+
/// <summary>
11+
/// Checks the active page against the built-in WCAG accessibility rules and reports
12+
/// the violations found, each tied to a ref where the offending element can be
13+
/// addressed by other tools.
14+
/// </summary>
15+
[McpServerToolType]
16+
public sealed class AccessibilityTools
17+
{
18+
[McpServerTool(Name = "audit_accessibility", Title = "Audit accessibility", Destructive = false, ReadOnly = true)]
19+
[Description("Runs WCAG 2.1 A and AA accessibility checks against the active page and returns the "
20+
+ "violations found, each with a rule id, severity, message, and (when the element is addressable) "
21+
+ "a ref usable with click, type, and other tools. A fresh snapshot is taken as part of the audit, "
22+
+ "so refs from an earlier snapshot are replaced.")]
23+
public static async Task<CallToolResult> AuditAccessibilityAsync(
24+
[Description("Only return violations at or above this severity: error, warning, or info. "
25+
+ "Omit to return all severities.")] string? min_severity,
26+
ActivePageService pageService,
27+
CancellationToken cancellationToken)
28+
{
29+
AccessibilityViolationSeverity? threshold = null;
30+
if (!string.IsNullOrWhiteSpace(min_severity))
31+
{
32+
if (!Enum.TryParse(min_severity, ignoreCase: true, out AccessibilityViolationSeverity parsed))
33+
return ToolResultHelper.Error(
34+
$"Unknown severity '{min_severity}'. Use error, warning, or info.");
35+
threshold = parsed;
36+
}
37+
38+
try
39+
{
40+
var page = await pageService.GetOrCreateActivePageAsync(cancellationToken).ConfigureAwait(false);
41+
42+
// A fresh snapshot refreshes the ref map so element-level violations can be
43+
// tied back to a ref the other tools accept.
44+
var snapshots = pageService.GetSnapshotService(page);
45+
await snapshots.TakeSnapshotAsync(cancellationToken).ConfigureAwait(false);
46+
47+
var result = await page.RunAccessibilityAuditAsync(cancellationToken).ConfigureAwait(false);
48+
49+
// Severity is ordered Error, Warning, Info, so "at or above" a threshold is
50+
// a lower-or-equal ordinal.
51+
var violations = threshold is { } min
52+
? result.Violations.Where(v => (int)v.Severity <= (int)min).ToList()
53+
: result.Violations.ToList();
54+
55+
if (violations.Count == 0)
56+
{
57+
var none = threshold is { } onlyAbove
58+
? $"No accessibility violations found at or above {onlyAbove.ToString().ToLowerInvariant()} severity."
59+
: "No accessibility violations found.";
60+
return ToolResultHelper.Text(Append(none, result.DiagnosticMessage));
61+
}
62+
63+
var array = new JsonArray();
64+
foreach (var violation in violations)
65+
{
66+
var refId = violation.BackendDOMNodeId is long nodeId
67+
? snapshots.GetRefForNodeId(nodeId)
68+
: null;
69+
70+
array.Add(new JsonObject
71+
{
72+
["ruleId"] = violation.RuleId,
73+
["severity"] = violation.Severity.ToString(),
74+
["message"] = violation.Message,
75+
["nodeRole"] = violation.NodeRole,
76+
["nodeName"] = violation.NodeName,
77+
["ref"] = refId,
78+
});
79+
}
80+
81+
var payload = new JsonObject
82+
{
83+
["violationCount"] = violations.Count,
84+
["violations"] = array,
85+
};
86+
87+
// Build the structured value through the node API and parse it, rather than
88+
// reflection-serializing a type, so the trim and AOT analyzers stay satisfied.
89+
var element = JsonDocument.Parse(payload.ToJsonString()).RootElement.Clone();
90+
var summary = Append(
91+
$"Found {violations.Count} accessibility violation(s).",
92+
result.DiagnosticMessage);
93+
return ToolResultHelper.Structured(element, summary);
94+
}
95+
catch (Exception ex)
96+
{
97+
return ToolResultHelper.Error($"Accessibility audit failed: {ex.Message}");
98+
}
99+
}
100+
101+
private static string Append(string text, string? diagnostic)
102+
=> string.IsNullOrEmpty(diagnostic) ? text : $"{text} {diagnostic}";
103+
}

src/Motus/Page/Page.Accessibility.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ public async Task<AccessibilitySnapshot> AccessibilitySnapshotAsync(Cancellation
2424
DiagnosticMessage: treeResult.DiagnosticMessage);
2525
}
2626

27+
/// <summary>
28+
/// Runs the accessibility rules registered on this page's context against the
29+
/// page's accessibility tree and returns the violations found.
30+
/// </summary>
31+
public Task<AccessibilityAuditResult> RunAccessibilityAuditAsync(CancellationToken ct = default)
32+
=> RunAccessibilityAuditAsync(_context.AccessibilityRules.Snapshot(), ct);
33+
2734
/// <summary>
2835
/// Runs the registered accessibility rules against this page's accessibility tree.
2936
/// Pre-fetches computed styles, duplicate IDs, and document language for rules that need them.

tests/Motus.Mcp.Tests/Snapshot/PageSnapshotServiceTests.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,37 @@ public async Task TakeSnapshot_StoresLastSnapshotText()
5353
StringAssert.Contains(text, "- button \"Go\" [ref=e1]");
5454
}
5555

56+
[TestMethod]
57+
public void GetRefForNodeId_BeforeSnapshot_ReturnsNull()
58+
{
59+
var service = new PageSnapshotService(new FakeAccessibilityPage(EmptySnapshot()));
60+
61+
Assert.IsNull(service.GetRefForNodeId(5));
62+
}
63+
64+
[TestMethod]
65+
public async Task GetRefForNodeId_AfterSnapshot_ReturnsRefForKnownNode_AndNullOtherwise()
66+
{
67+
var snapshot = new AccessibilitySnapshot(
68+
Roots:
69+
[
70+
new AccessibilityNode("1", "img", "", null, null,
71+
new Dictionary<string, string?>(), [], BackendDOMNodeId: 5),
72+
new AccessibilityNode("2", "button", "Go", null, null,
73+
new Dictionary<string, string?>(), [], BackendDOMNodeId: 7),
74+
],
75+
IgnoredCount: 0,
76+
DiagnosticMessage: null);
77+
78+
var service = new PageSnapshotService(new FakeAccessibilityPage(snapshot));
79+
await service.TakeSnapshotAsync();
80+
81+
// Refs are assigned in document order, so the inverse maps each id back to its ref.
82+
Assert.AreEqual("e1", service.GetRefForNodeId(5));
83+
Assert.AreEqual("e2", service.GetRefForNodeId(7));
84+
Assert.IsNull(service.GetRefForNodeId(999));
85+
}
86+
5687
private static AccessibilitySnapshot EmptySnapshot()
5788
=> new([], IgnoredCount: 0, DiagnosticMessage: null);
5889

@@ -65,6 +96,9 @@ private sealed class FakeAccessibilityPage(AccessibilitySnapshot snapshot) : IPa
6596
public Task<AccessibilitySnapshot> AccessibilitySnapshotAsync(CancellationToken ct = default)
6697
=> Task.FromResult(snapshot);
6798

99+
public Task<AccessibilityAuditResult> RunAccessibilityAuditAsync(CancellationToken ct = default)
100+
=> throw new NotImplementedException();
101+
68102
public ILocator LocatorByBackendNodeId(long backendNodeId) => throw new NotImplementedException();
69103

70104
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
using System.Linq;
2+
using System.Text.Json;
3+
using ModelContextProtocol.Protocol;
4+
using Motus.Mcp;
5+
6+
namespace Motus.Mcp.Tests.Tools;
7+
8+
/// <summary>
9+
/// Drives the accessibility tool through a real browser: auditing a page seeded with
10+
/// known defects, confirming the violations come back with refs for the addressable
11+
/// ones, and that the severity filter narrows the result.
12+
/// </summary>
13+
[TestClass]
14+
[TestCategory("Integration")]
15+
public class AccessibilityIntegrationTests
16+
{
17+
// A page with an image missing alt text, an empty button, an unlabeled input, and
18+
// (being a data: document) no lang attribute. Each trips a built-in rule.
19+
private const string DefectivePage =
20+
"data:text/html,<title>A11y</title><img src=x><button></button><input>";
21+
22+
private BrowserSessionManager? _sessions;
23+
private ActivePageService? _pages;
24+
25+
[TestInitialize]
26+
public void Setup()
27+
{
28+
var executablePath = ResolveInstalledBrowser();
29+
if (executablePath is null)
30+
Assert.Inconclusive("No installed browser found; skipping integration test.");
31+
32+
_sessions = new BrowserSessionManager(new McpServerLaunchOptions
33+
{
34+
Headless = true,
35+
ExecutablePath = executablePath,
36+
});
37+
_pages = new ActivePageService(_sessions);
38+
}
39+
40+
[TestCleanup]
41+
public async Task Cleanup()
42+
{
43+
if (_pages is not null)
44+
await _pages.DisposeAsync();
45+
if (_sessions is not null)
46+
await _sessions.DisposeAsync();
47+
}
48+
49+
[TestMethod]
50+
public async Task AuditDefectivePage_OnARealBrowser()
51+
{
52+
var pages = _pages!;
53+
var ct = CancellationToken.None;
54+
55+
AssertOk(await CoreTools.NavigateAsync(DefectivePage, pages, ct), "navigate");
56+
57+
var all = await AccessibilityTools.AuditAccessibilityAsync(null, pages, ct);
58+
AssertOk(all, "audit_accessibility");
59+
60+
var violations = Violations(all);
61+
Assert.IsTrue(violations.Length > 0, "expected at least one violation");
62+
63+
var rules = violations.Select(v => v.GetProperty("ruleId").GetString()).ToArray();
64+
var rulesText = string.Join(", ", rules);
65+
66+
// A data: document carries no lang attribute, so this page-level rule fires.
67+
CollectionAssert.Contains(rules, "a11y-missing-lang", $"rules were: {rulesText}");
68+
69+
// The empty button and unlabeled input are addressable, so at least one
70+
// violation carries a ref back to its element.
71+
Assert.IsTrue(
72+
violations.Any(v => v.GetProperty("ref").ValueKind != JsonValueKind.Null),
73+
$"expected at least one violation to carry a ref; rules were: {rulesText}");
74+
75+
// Filtering to errors returns a subset, all of which are errors.
76+
var errorsOnly = await AccessibilityTools.AuditAccessibilityAsync("error", pages, ct);
77+
AssertOk(errorsOnly, "audit_accessibility error filter");
78+
79+
var errors = Violations(errorsOnly);
80+
Assert.IsTrue(errors.Length <= violations.Length);
81+
Assert.IsTrue(
82+
errors.All(v => v.GetProperty("severity").GetString() == "Error"),
83+
"every filtered violation should be an error");
84+
}
85+
86+
private static JsonElement[] Violations(CallToolResult result)
87+
{
88+
Assert.IsNotNull(result.StructuredContent, "expected structured content");
89+
return result.StructuredContent!.Value.GetProperty("violations").EnumerateArray().ToArray();
90+
}
91+
92+
private static void AssertOk(CallToolResult result, string label)
93+
=> Assert.IsFalse(result.IsError ?? false, $"{label} should succeed: {TextOf(result)}");
94+
95+
private static string TextOf(CallToolResult result)
96+
=> result.Content.Count > 0 && result.Content[0] is TextContentBlock t ? t.Text : string.Empty;
97+
98+
private static string? ResolveInstalledBrowser()
99+
{
100+
var cacheDir = Path.Combine(
101+
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
102+
".motus",
103+
"browsers");
104+
105+
foreach (var marker in new[] { ".installed.chromium", ".installed" })
106+
{
107+
var markerPath = Path.Combine(cacheDir, marker);
108+
if (!File.Exists(markerPath))
109+
continue;
110+
111+
var executablePath = File.ReadAllText(markerPath).Trim();
112+
if (File.Exists(executablePath))
113+
return executablePath;
114+
}
115+
116+
return null;
117+
}
118+
}

0 commit comments

Comments
 (0)