cbrowser 18.78.0 → 18.80.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (235) hide show
  1. package/dist/analysis/accessibility-empathy.d.ts.map +1 -1
  2. package/dist/analysis/accessibility-empathy.js +395 -66
  3. package/dist/analysis/accessibility-empathy.js.map +1 -1
  4. package/dist/analysis/agent-ready-audit.d.ts.map +1 -1
  5. package/dist/analysis/agent-ready-audit.js +78 -0
  6. package/dist/analysis/agent-ready-audit.js.map +1 -1
  7. package/dist/analysis/page-understanding.d.ts +139 -1
  8. package/dist/analysis/page-understanding.d.ts.map +1 -1
  9. package/dist/analysis/page-understanding.js +179 -12
  10. package/dist/analysis/page-understanding.js.map +1 -1
  11. package/dist/artifact-store.d.ts +19 -5
  12. package/dist/artifact-store.d.ts.map +1 -1
  13. package/dist/artifact-store.js +41 -11
  14. package/dist/artifact-store.js.map +1 -1
  15. package/dist/browser.d.ts +9 -0
  16. package/dist/browser.d.ts.map +1 -1
  17. package/dist/browser.js +81 -5
  18. package/dist/browser.js.map +1 -1
  19. package/dist/cli.js +55 -10
  20. package/dist/cli.js.map +1 -1
  21. package/dist/cognitive/index.d.ts.map +1 -1
  22. package/dist/cognitive/index.js +3 -2
  23. package/dist/cognitive/index.js.map +1 -1
  24. package/dist/config.d.ts +57 -0
  25. package/dist/config.d.ts.map +1 -1
  26. package/dist/config.js +234 -7
  27. package/dist/config.js.map +1 -1
  28. package/dist/index.js +1 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/mcp-server-remote.d.ts.map +1 -1
  31. package/dist/mcp-server-remote.js +108 -4
  32. package/dist/mcp-server-remote.js.map +1 -1
  33. package/dist/mcp-server.d.ts.map +1 -1
  34. package/dist/mcp-server.js +62 -938
  35. package/dist/mcp-server.js.map +1 -1
  36. package/dist/mcp-tools/account-personas.d.ts +41 -0
  37. package/dist/mcp-tools/account-personas.d.ts.map +1 -0
  38. package/dist/mcp-tools/account-personas.js +98 -0
  39. package/dist/mcp-tools/account-personas.js.map +1 -0
  40. package/dist/mcp-tools/base/analysis-tools.d.ts.map +1 -1
  41. package/dist/mcp-tools/base/analysis-tools.js +12 -2
  42. package/dist/mcp-tools/base/analysis-tools.js.map +1 -1
  43. package/dist/mcp-tools/base/artifact-tools.d.ts +3 -0
  44. package/dist/mcp-tools/base/artifact-tools.d.ts.map +1 -0
  45. package/dist/mcp-tools/base/artifact-tools.js +78 -0
  46. package/dist/mcp-tools/base/artifact-tools.js.map +1 -0
  47. package/dist/mcp-tools/base/audit-tools.d.ts +11 -0
  48. package/dist/mcp-tools/base/audit-tools.d.ts.map +1 -1
  49. package/dist/mcp-tools/base/audit-tools.js +595 -340
  50. package/dist/mcp-tools/base/audit-tools.js.map +1 -1
  51. package/dist/mcp-tools/base/browser-management-tools.d.ts.map +1 -1
  52. package/dist/mcp-tools/base/browser-management-tools.js +18 -0
  53. package/dist/mcp-tools/base/browser-management-tools.js.map +1 -1
  54. package/dist/mcp-tools/base/bug-analysis-tools.d.ts.map +1 -1
  55. package/dist/mcp-tools/base/bug-analysis-tools.js +59 -3
  56. package/dist/mcp-tools/base/bug-analysis-tools.js.map +1 -1
  57. package/dist/mcp-tools/base/capture-tools.d.ts +37 -0
  58. package/dist/mcp-tools/base/capture-tools.d.ts.map +1 -1
  59. package/dist/mcp-tools/base/capture-tools.js +267 -6
  60. package/dist/mcp-tools/base/capture-tools.js.map +1 -1
  61. package/dist/mcp-tools/base/cognitive-tools.d.ts +12 -0
  62. package/dist/mcp-tools/base/cognitive-tools.d.ts.map +1 -1
  63. package/dist/mcp-tools/base/cognitive-tools.js +63 -15
  64. package/dist/mcp-tools/base/cognitive-tools.js.map +1 -1
  65. package/dist/mcp-tools/base/extraction-tools.d.ts.map +1 -1
  66. package/dist/mcp-tools/base/extraction-tools.js +33 -2
  67. package/dist/mcp-tools/base/extraction-tools.js.map +1 -1
  68. package/dist/mcp-tools/base/index.d.ts +2 -0
  69. package/dist/mcp-tools/base/index.d.ts.map +1 -1
  70. package/dist/mcp-tools/base/index.js +9 -0
  71. package/dist/mcp-tools/base/index.js.map +1 -1
  72. package/dist/mcp-tools/base/marketing-tools.js +3 -3
  73. package/dist/mcp-tools/base/marketing-tools.js.map +1 -1
  74. package/dist/mcp-tools/base/persona-comparison-tools.d.ts.map +1 -1
  75. package/dist/mcp-tools/base/persona-comparison-tools.js +529 -40
  76. package/dist/mcp-tools/base/persona-comparison-tools.js.map +1 -1
  77. package/dist/mcp-tools/base/site-knowledge-tools.d.ts.map +1 -1
  78. package/dist/mcp-tools/base/site-knowledge-tools.js +36 -3
  79. package/dist/mcp-tools/base/site-knowledge-tools.js.map +1 -1
  80. package/dist/mcp-tools/base/values-tools.d.ts +113 -0
  81. package/dist/mcp-tools/base/values-tools.d.ts.map +1 -1
  82. package/dist/mcp-tools/base/values-tools.js +868 -78
  83. package/dist/mcp-tools/base/values-tools.js.map +1 -1
  84. package/dist/mcp-tools/base/visual-testing-tools.d.ts.map +1 -1
  85. package/dist/mcp-tools/base/visual-testing-tools.js +130 -10
  86. package/dist/mcp-tools/base/visual-testing-tools.js.map +1 -1
  87. package/dist/mcp-tools/index.d.ts +3 -1
  88. package/dist/mcp-tools/index.d.ts.map +1 -1
  89. package/dist/mcp-tools/index.js +3 -28
  90. package/dist/mcp-tools/index.js.map +1 -1
  91. package/dist/mcp-tools/persona-creation-tools.d.ts.map +1 -1
  92. package/dist/mcp-tools/persona-creation-tools.js +363 -20
  93. package/dist/mcp-tools/persona-creation-tools.js.map +1 -1
  94. package/dist/mcp-tools/persona-lifecycle-tools.d.ts +3 -0
  95. package/dist/mcp-tools/persona-lifecycle-tools.d.ts.map +1 -0
  96. package/dist/mcp-tools/persona-lifecycle-tools.js +406 -0
  97. package/dist/mcp-tools/persona-lifecycle-tools.js.map +1 -0
  98. package/dist/mcp-tools/screenshot-utils.d.ts.map +1 -1
  99. package/dist/mcp-tools/screenshot-utils.js +56 -10
  100. package/dist/mcp-tools/screenshot-utils.js.map +1 -1
  101. package/dist/mcp-tools/tier-gate.js +2 -2
  102. package/dist/mcp-tools/tier-gate.js.map +1 -1
  103. package/dist/mcp-tools/tool-categories.d.ts.map +1 -1
  104. package/dist/mcp-tools/tool-categories.js +4 -1
  105. package/dist/mcp-tools/tool-categories.js.map +1 -1
  106. package/dist/mcp-tools/ui-resources.d.ts +167 -0
  107. package/dist/mcp-tools/ui-resources.d.ts.map +1 -0
  108. package/dist/mcp-tools/ui-resources.js +707 -0
  109. package/dist/mcp-tools/ui-resources.js.map +1 -0
  110. package/dist/mcp-tools/widget-kit.d.ts +299 -0
  111. package/dist/mcp-tools/widget-kit.d.ts.map +1 -0
  112. package/dist/mcp-tools/widget-kit.js +2034 -0
  113. package/dist/mcp-tools/widget-kit.js.map +1 -0
  114. package/dist/mcp-ui-resources.d.ts.map +1 -1
  115. package/dist/mcp-ui-resources.js +14 -4
  116. package/dist/mcp-ui-resources.js.map +1 -1
  117. package/dist/persona-questionnaire.d.ts +69 -1
  118. package/dist/persona-questionnaire.d.ts.map +1 -1
  119. package/dist/persona-questionnaire.js +177 -76
  120. package/dist/persona-questionnaire.js.map +1 -1
  121. package/dist/persona-scope.d.ts +20 -0
  122. package/dist/persona-scope.d.ts.map +1 -0
  123. package/dist/persona-scope.js +59 -0
  124. package/dist/persona-scope.js.map +1 -0
  125. package/dist/personas.d.ts +51 -17
  126. package/dist/personas.d.ts.map +1 -1
  127. package/dist/personas.js +947 -286
  128. package/dist/personas.js.map +1 -1
  129. package/dist/recording/capture-player.d.ts +68 -0
  130. package/dist/recording/capture-player.d.ts.map +1 -0
  131. package/dist/recording/capture-player.js +298 -0
  132. package/dist/recording/capture-player.js.map +1 -0
  133. package/dist/recording/engine.d.ts +52 -0
  134. package/dist/recording/engine.d.ts.map +1 -1
  135. package/dist/recording/engine.js +241 -3
  136. package/dist/recording/engine.js.map +1 -1
  137. package/dist/recording/inline-player.d.ts +39 -0
  138. package/dist/recording/inline-player.d.ts.map +1 -0
  139. package/dist/recording/inline-player.js +272 -0
  140. package/dist/recording/inline-player.js.map +1 -0
  141. package/dist/recording/saliency-overlay.d.ts +133 -0
  142. package/dist/recording/saliency-overlay.d.ts.map +1 -0
  143. package/dist/recording/saliency-overlay.js +360 -0
  144. package/dist/recording/saliency-overlay.js.map +1 -0
  145. package/dist/recording/types.d.ts +32 -0
  146. package/dist/recording/types.d.ts.map +1 -1
  147. package/dist/recording/types.js +46 -0
  148. package/dist/recording/types.js.map +1 -1
  149. package/dist/site-model/manager.d.ts +20 -0
  150. package/dist/site-model/manager.d.ts.map +1 -1
  151. package/dist/site-model/manager.js +52 -0
  152. package/dist/site-model/manager.js.map +1 -1
  153. package/dist/trait-reference.d.ts +26 -2
  154. package/dist/trait-reference.d.ts.map +1 -1
  155. package/dist/trait-reference.js +129 -19
  156. package/dist/trait-reference.js.map +1 -1
  157. package/dist/types.d.ts +105 -18
  158. package/dist/types.d.ts.map +1 -1
  159. package/dist/types.js.map +1 -1
  160. package/dist/url-resolution.d.ts +58 -0
  161. package/dist/url-resolution.d.ts.map +1 -0
  162. package/dist/url-resolution.js +143 -0
  163. package/dist/url-resolution.js.map +1 -0
  164. package/dist/values/big-five-inference.d.ts +121 -0
  165. package/dist/values/big-five-inference.d.ts.map +1 -0
  166. package/dist/values/big-five-inference.js +241 -0
  167. package/dist/values/big-five-inference.js.map +1 -0
  168. package/dist/values/big-five-values.d.ts +76 -0
  169. package/dist/values/big-five-values.d.ts.map +1 -0
  170. package/dist/values/big-five-values.js +152 -0
  171. package/dist/values/big-five-values.js.map +1 -0
  172. package/dist/values/index.d.ts +1 -1
  173. package/dist/values/index.d.ts.map +1 -1
  174. package/dist/values/index.js +1 -1
  175. package/dist/values/index.js.map +1 -1
  176. package/dist/values/persona-completeness.d.ts +91 -0
  177. package/dist/values/persona-completeness.d.ts.map +1 -0
  178. package/dist/values/persona-completeness.js +114 -0
  179. package/dist/values/persona-completeness.js.map +1 -0
  180. package/dist/values/persona-diff.d.ts +50 -0
  181. package/dist/values/persona-diff.d.ts.map +1 -0
  182. package/dist/values/persona-diff.js +126 -0
  183. package/dist/values/persona-diff.js.map +1 -0
  184. package/dist/values/persona-values.d.ts +16 -0
  185. package/dist/values/persona-values.d.ts.map +1 -1
  186. package/dist/values/persona-values.js +33 -0
  187. package/dist/values/persona-values.js.map +1 -1
  188. package/dist/values/schwartz-values.d.ts +9 -1
  189. package/dist/values/schwartz-values.d.ts.map +1 -1
  190. package/dist/values/schwartz-values.js +10 -2
  191. package/dist/values/schwartz-values.js.map +1 -1
  192. package/dist/values/value-mappings.d.ts +41 -2
  193. package/dist/values/value-mappings.d.ts.map +1 -1
  194. package/dist/values/value-mappings.js +65 -8
  195. package/dist/values/value-mappings.js.map +1 -1
  196. package/dist/visual/attention-quality.d.ts +27 -0
  197. package/dist/visual/attention-quality.d.ts.map +1 -1
  198. package/dist/visual/attention-quality.js +164 -2
  199. package/dist/visual/attention-quality.js.map +1 -1
  200. package/dist/visual/attention-transport.d.ts +41 -2
  201. package/dist/visual/attention-transport.d.ts.map +1 -1
  202. package/dist/visual/attention-transport.js +98 -26
  203. package/dist/visual/attention-transport.js.map +1 -1
  204. package/dist/visual/barrier-overlay-html.d.ts +10 -0
  205. package/dist/visual/barrier-overlay-html.d.ts.map +1 -1
  206. package/dist/visual/barrier-overlay-html.js.map +1 -1
  207. package/dist/visual/cognitive-models.d.ts +109 -13
  208. package/dist/visual/cognitive-models.d.ts.map +1 -1
  209. package/dist/visual/cognitive-models.js +166 -3
  210. package/dist/visual/cognitive-models.js.map +1 -1
  211. package/dist/visual/cognitive-transport-chain.d.ts +149 -5
  212. package/dist/visual/cognitive-transport-chain.d.ts.map +1 -1
  213. package/dist/visual/cognitive-transport-chain.js +558 -30
  214. package/dist/visual/cognitive-transport-chain.js.map +1 -1
  215. package/dist/visual/cognitive-transport.d.ts +45 -3
  216. package/dist/visual/cognitive-transport.d.ts.map +1 -1
  217. package/dist/visual/cognitive-transport.js +126 -12
  218. package/dist/visual/cognitive-transport.js.map +1 -1
  219. package/dist/visual/fixation-corpus.d.ts +52 -0
  220. package/dist/visual/fixation-corpus.d.ts.map +1 -1
  221. package/dist/visual/fixation-corpus.js +95 -0
  222. package/dist/visual/fixation-corpus.js.map +1 -1
  223. package/dist/visual/llm-relevance.d.ts +193 -0
  224. package/dist/visual/llm-relevance.d.ts.map +1 -0
  225. package/dist/visual/llm-relevance.js +480 -0
  226. package/dist/visual/llm-relevance.js.map +1 -0
  227. package/dist/visual/perceptual-transport.d.ts +60 -0
  228. package/dist/visual/perceptual-transport.d.ts.map +1 -1
  229. package/dist/visual/perceptual-transport.js +228 -19
  230. package/dist/visual/perceptual-transport.js.map +1 -1
  231. package/dist/visual/persona-context.d.ts +23 -0
  232. package/dist/visual/persona-context.d.ts.map +1 -0
  233. package/dist/visual/persona-context.js +44 -0
  234. package/dist/visual/persona-context.js.map +1 -0
  235. package/package.json +2 -1
@@ -5,11 +5,12 @@
5
5
  * @license MIT
6
6
  */
7
7
  import { z } from "zod";
8
+ import { barrierWeightFor, weightedSeverity, weightKeyFor } from "../../visual/perceptual-transport.js";
8
9
  import { randomBytes } from "crypto";
9
- import { htmlUiResource, attachUiResource, uiResourcesEnabled } from "../../mcp-ui-resources.js";
10
10
  import { writeArtifact } from "../../artifact-store.js";
11
11
  import { runAgentReadyAudit, runCompetitiveBenchmark, runEmpathyAudit, runWebMCPReadyAudit, } from "../../analysis/index.js";
12
12
  import { listAccessibilityPersonas } from "../../personas.js";
13
+ import { getDefaultConfig } from "../../config.js";
13
14
  import { writeFileSync, mkdirSync, existsSync } from "fs";
14
15
  import { join } from "path";
15
16
  import { homedir } from "os";
@@ -45,8 +46,12 @@ function writeAuditScreenshot(base64, persona) {
45
46
  try {
46
47
  const nonce = randomBytes(6).toString("hex");
47
48
  const written = writeArtifact(Buffer.from(base64, "base64"), `empathy-${safe}-${nonce}.png`);
48
- if (written)
49
+ // The filename too, not just the URL: the widget sandbox cannot fetch that
50
+ // URL and asks artifact_fetch for the bytes by name instead.
51
+ if (written) {
49
52
  out.url = written.url;
53
+ out.file = written.filename;
54
+ }
50
55
  }
51
56
  catch { /* overlay is optional; the audit is not */ }
52
57
  return out;
@@ -77,7 +82,35 @@ export function registerAuditTools(server, context) {
77
82
  score: result.score,
78
83
  grade: result.grade,
79
84
  summary: result.summary,
80
- topIssues: result.issues.slice(0, 5),
85
+ // Worst-first, and the cut is reported.
86
+ //
87
+ // This was a plain slice(0,5) over the issues in detection order, so
88
+ // severity had no bearing on what survived: a critical finding placed
89
+ // sixth by the order detectors happen to run was dropped in favour of
90
+ // five low ones, with nothing saying anything had been dropped. Found
91
+ // when a new critical detector fired in the audit and was invisible in
92
+ // the tool that wraps it. (2026-08-01)
93
+ ...(() => {
94
+ const rank = (sev) => ({ critical: 0, high: 1, medium: 2, low: 3, info: 4 }[String(sev).toLowerCase()] ?? 5);
95
+ const sorted = [...result.issues].sort((a, b) => rank(a.severity) - rank(b.severity));
96
+ const shown = sorted.slice(0, 5);
97
+ const omitted = sorted.length - shown.length;
98
+ return {
99
+ topIssues: shown,
100
+ issuesFound: result.issues.length,
101
+ ...(omitted > 0
102
+ ? {
103
+ issuesOmitted: omitted,
104
+ issuesNote: `Showing the 5 most severe of ${result.issues.length}. Severity counts across ALL findings: ` +
105
+ Object.entries(result.issues.reduce((m, i) => {
106
+ const k = String(i.severity);
107
+ m[k] = (m[k] ?? 0) + 1;
108
+ return m;
109
+ }, {})).map(([k, v]) => `${k} ${v}`).join(", ") + ".",
110
+ }
111
+ : {}),
112
+ };
113
+ })(),
81
114
  topRecommendations: result.recommendations.slice(0, 5),
82
115
  duration: result.duration,
83
116
  };
@@ -126,333 +159,48 @@ export function registerAuditTools(server, context) {
126
159
  ],
127
160
  };
128
161
  });
129
- server.registerTool("empathy_audit", {
130
- title: "Empathy Accessibility Audit",
131
- description: "Simulate how real users experience a site. Accepts ANY persona — disability personas get specialized barrier detection, non-disability personas get general UX analysis with a flag. Tests ONE persona per call. Disability: motor-impairment-tremor, low-vision-magnified, cognitive-adhd, dyslexic-user, deaf-user, elderly-low-vision, color-blind-deuteranopia. General: first-timer, power-user, mobile-user, elderly-user, impatient-user, or any custom persona.",
162
+ registerEmpathyAuditTool(server);
163
+ server.registerTool("webmcp_ready_audit", {
164
+ title: "WebMCP Readiness Audit",
165
+ description: "Audit an MCP server for Claude in Chrome / WebMCP compatibility. Uses 6-tier evaluation: Server Implementation (25%), Tool Discoverability (20%), Instrumentation (15%), Consistency (15%), Agent Optimizations (15%), Documentation (10%). Returns score (0-100), grade (A-F), tier breakdown, issues, and recommendations.",
132
166
  inputSchema: {
133
- url: z.string().url().describe("URL to audit"),
134
- goal: z.string().describe("Task goal (e.g., 'complete checkout')"),
135
- disabilities: z.array(z.string()).optional().describe("Persona to test. Accepts disability names OR any cognitive persona (first-timer, power-user, etc). Non-disability personas are flagged. Pass ONE for reliable results."),
136
- wcagLevel: z.enum(["A", "AA", "AAA"]).optional().default("AA").describe("WCAG conformance level"),
137
- maxSteps: z.number().optional().default(5).describe("Max cognitive journey steps (keep low for MCP)"),
138
- maxTime: z.number().optional().default(20).describe("Max time per persona in seconds"),
139
- // Base64 page screenshots are large enough to blow an MCP client's context
140
- // on a single call — every empathy_audit in the 18.73.3 test pass had to be
141
- // read off disk instead. Off by default; images go to disk and their paths
142
- // are returned. (2026-07-29)
143
- includeScreenshots: z.boolean().optional().default(false).describe("Inline base64 page screenshots in the response. Off by default because they are large enough to overflow an MCP client's context; when off, screenshots are written to disk and screenshotPath is returned instead."),
144
- scope: z.enum(["viewport", "full_page"]).optional().default("viewport").describe("What to score: 'viewport' (first impression, above-the-fold only — default) or 'full_page' (scroll through entire page, all barriers). Use 'viewport' for landing page optimization; 'full_page' for WCAG compliance audits."),
145
- device: z.string().optional().describe("Device emulation: 'mobile', 'tablet', 'desktop', or specific device like 'iPhone 15', 'Pixel 7'. Essential for mobile WCAG audits — touch targets, viewport sizing, and responsive barriers differ significantly on mobile."),
146
- uiResource: z.boolean().optional().default(true).describe("Return an interactive HTML report as an MCP UI resource alongside the JSON. Hosts that support MCP Apps render it inline; others ignore it. Set false for scripted callers that diff whole responses."),
167
+ url: z.string().url().describe("MCP server URL to audit (e.g., https://demo.cbrowser.ai/mcp)"),
168
+ apiKey: z.string().optional().describe("API key if server requires authentication"),
169
+ oauthToken: z.string().optional().describe("OAuth token if server uses OAuth"),
170
+ timeout: z.number().optional().default(30000).describe("Timeout in ms (default: 30000)"),
147
171
  },
148
172
  annotations: {
149
- title: "Empathy Accessibility Audit",
173
+ title: "WebMCP Readiness Audit",
150
174
  readOnlyHint: true,
151
175
  destructiveHint: false,
152
176
  idempotentHint: false,
153
177
  openWorldHint: true,
154
178
  },
155
- }, async ({ url, goal, disabilities, wcagLevel, maxSteps, maxTime, scope, device, includeScreenshots, uiResource }) => {
156
- try {
157
- // Auto-limit to 1 persona to avoid MCP client timeout on Claude.ai (~60s limit)
158
- const allPersonas = listAccessibilityPersonas();
159
- const requestedList = disabilities || allPersonas;
160
- const wasLimited = requestedList.length > 1;
161
- const singlePersona = [requestedList[0]];
162
- const result = await runEmpathyAudit(url, {
163
- goal,
164
- disabilities: singlePersona,
165
- wcagLevel,
166
- maxSteps,
167
- maxTime,
168
- headless: true,
169
- scope: scope || "viewport",
170
- device: device || undefined,
171
- });
172
- // Build response with guidance for additional personas
173
- const testedPersona = singlePersona[0];
174
- const remainingPersonas = allPersonas.filter(p => p !== testedPersona);
175
- const response = {
176
- url: result.url,
177
- goal: result.goal,
178
- testedPersona,
179
- overallScore: result.overallScore,
180
- scope: scope || "viewport",
181
- device: device || "desktop",
182
- scopeNote: scope === "full_page"
183
- ? "Full-page audit: scrolled through entire page before barrier detection. Scores reflect all content including below-the-fold."
184
- : "Viewport-only audit: scored first impression (above-the-fold). Use scope='full_page' for complete barrier inventory, or cognitive_journey for path-dependent experience.",
185
- resultsSummary: result.results.map((r) => {
186
- const uniqueTypes = new Set(r.barriers.map(b => b.type));
187
- return {
188
- persona: r.persona,
189
- disabilityType: r.disabilityType,
190
- goalAchieved: r.goalAchieved,
191
- empathyScore: r.empathyScore,
192
- // v18.22.0: Added score context for transparency
193
- // Whitelist projection: anything added to scoreContext upstream is
194
- // invisible to the caller unless named here. The blend breakdown
195
- // below is what lets the deductions reconcile to a score the
196
- // caller can actually see. (2026-07-28)
197
- scoreContext: r.scoreContext ? {
198
- explanation: r.scoreContext.explanation,
199
- deductionsByType: r.scoreContext.deductionsByType,
200
- totalBarrierDeduction: r.scoreContext.totalBarrierDeduction,
201
- frictionDeduction: r.scoreContext.frictionDeduction,
202
- goalDeduction: r.scoreContext.goalDeduction,
203
- cognitiveOverloadPenalty: r.scoreContext.cognitiveOverloadPenalty,
204
- appliedBarrierWeights: r.scoreContext.appliedBarrierWeights,
205
- explainsScore: r.scoreContext.explainsScore,
206
- barrierOnlyScore: r.scoreContext.barrierOnlyScore,
207
- perceptualScore: r.scoreContext.perceptualScore,
208
- blendWeights: r.scoreContext.blendWeights,
209
- finalScore: r.scoreContext.finalScore,
210
- // A partial score must be distinguishable from a measured one.
211
- // Set when the navigation simulation threw: the goal traversal
212
- // did not run, so no goal deduction was charged and the score
213
- // reflects barriers only. Without these two names the whitelist
214
- // above would drop the flag and the score would read as clean.
215
- degraded: r.scoreContext.degraded,
216
- degradedReason: r.scoreContext.degradedReason,
217
- } : undefined,
218
- barrierTypeCount: uniqueTypes.size,
219
- barrierTypes: Array.from(uniqueTypes),
220
- affectedElements: r.barriers.length,
221
- wcagViolationCount: r.wcagViolations.length,
222
- // v18.26.0: Perceptual transport metrics (Wasserstein-based)
223
- perceptualTransport: r.perceptualTransport || undefined,
224
- empathyScoreBarrierOnly: r.empathyScoreBarrierOnly || undefined,
225
- // v18.27.0: Cognitive load estimation (optimal transport)
226
- cognitiveLoad: r.cognitiveLoad || undefined,
227
- // Names the three different "cognitive load" numbers in this
228
- // payload and what each measures. Same whitelist caveat as
229
- // scoreContext above: set upstream, invisible unless named here.
230
- cognitiveLoadReadings: r.cognitiveLoadReadings || undefined,
231
- // v18.28.0: Attention transport analysis (W₂ saliency)
232
- attentionAnalysis: r.attentionAnalysis || undefined,
233
- // v18.29.0: Journey validation — evidence, path, forensics
234
- journeyValidation: r.journeyValidation || undefined,
235
- // v18.35.0: Non-disability persona flag
236
- isDisabilityPersona: r.isDisabilityPersona !== false,
237
- personaNote: r.personaNote || undefined,
238
- };
239
- }),
240
- allWcagViolations: result.allWcagViolations,
241
- topBarriers: result.topBarriers.slice(0, 5),
242
- // A barrier's affectedPersonas names the personas it hits HARDEST. It
243
- // is not an exclusion list, which is why a persona absent from it can
244
- // still take a deduction — susceptibility is applied as a weight, not
245
- // a gate (see scoreContext.appliedBarrierWeights). Stating it here
246
- // because the pairing otherwise reads as a contradiction: a
247
- // touch_target barrier listing motor and elderly personas, scored
248
- // against cognitive-adhd. (2026-07-29)
249
- barrierFieldNotes: {
250
- affectedPersonas: "Exemplars — the personas this barrier affects most. NOT an exclusion list; every tested persona is scored against every barrier, weighted by susceptibility.",
251
- severity: "On topBarriers entries this is the group maximum across all elements of that type (severityIsGroupMax), not one element's severity.",
252
- },
253
- topRemediation: result.combinedRemediation.slice(0, 5),
254
- duration: result.duration,
255
- // Errors caught during the run, promoted out of the HTML report. The
256
- // SVG className crash changed goalAchieved and the goal deduction while
257
- // being visible ONLY in the HTML, so the JSON reported a confident
258
- // wrong number. Anything that moves a scored field is surfaced here.
259
- errors: result.results.flatMap((r) => (r.frictionPoints ?? [])
260
- .filter((fp) => fp.type === "error")
261
- .map((fp) => ({ persona: r.persona, message: fp.description }))),
262
- // v18.60.0: Include per-result screenshots and element rects for WCAG overlay
263
- pageScreenshots: result.results?.map((r) => ({
264
- persona: r.persona,
265
- ...(includeScreenshots
266
- ? { screenshot: r.pageScreenshot }
267
- : (() => {
268
- const shot = writeAuditScreenshot(r.pageScreenshot, r.persona);
269
- return {
270
- screenshotPath: shot.path,
271
- screenshotUrl: shot.url,
272
- screenshotNote: "Pass includeScreenshots:true to inline the base64 instead.",
273
- };
274
- })()),
275
- viewportSize: r.viewportSize,
276
- // Rects come from getBoundingClientRect with no scroll compensation,
277
- // so they are VIEWPORT coordinates — which is correct here, because
278
- // the screenshot above is captured with fullPage: false and these are
279
- // meant to overlay it. Converting them to document coordinates would
280
- // break that alignment.
281
- //
282
- // The real hazard is scope: "full_page", where the audit scrolls and
283
- // rects captured at different offsets land in one list against a
284
- // single viewport image; anything above the captured viewport then
285
- // carries a negative y and would render off-canvas. Say which space
286
- // these are in and flag the ones that fall outside the image rather
287
- // than emitting silent negatives. (2026-07-28)
288
- // Rects are now DOCUMENT coordinates (scroll offset added at capture),
289
- // so they are stable across the scrolling that scope:"full_page" does.
290
- // They were previously labelled "viewport" while holding values from
291
- // several scroll positions at once — y:-274 and y:3951 in one list.
292
- coordinateSpace: "document",
293
- // Height of the captured image in document space. A viewport capture
294
- // covers only the scroll position it was taken at; a full_page capture
295
- // covers the document. Without this the bounds test compared document
296
- // coordinates against a viewport height and flagged EVERY rect,
297
- // including ones plainly inside the image. (2026-07-29)
298
- captureHeight: (scope === "full_page" ? (r.documentHeight ?? undefined) : r.viewportSize?.height),
299
- barrierRects: r.barriers?.filter((b) => b.rect).map((b) => {
300
- const vp = r.viewportSize;
301
- const bound = scope === "full_page" ? (r.documentHeight ?? Infinity) : (vp?.height ?? Infinity);
302
- const outside = !!b.rect && (b.rect.y + b.rect.height < 0 || b.rect.x + b.rect.width < 0 ||
303
- b.rect.y > bound || (!!vp && b.rect.x > vp.width));
304
- return {
305
- type: b.type, severity: b.severity, element: b.element,
306
- description: b.description, rect: b.rect,
307
- wcag: b.wcagCriteria,
308
- ...(outside ? { outsideScreenshot: true } : {}),
309
- };
310
- }),
311
- // Keep entries that carry EITHER the inline image or a path to it.
312
- // Filtering on `screenshot` alone silently dropped every entry once
313
- // the default stopped inlining base64, taking the barrierRects with
314
- // them. (2026-07-29)
315
- })).filter((s) => s.screenshot || s.screenshotPath),
316
- };
317
- // Add guidance if we limited the request
318
- if (wasLimited) {
319
- response.note = `Limited to 1 persona to avoid timeout. For full coverage, call again with: ${remainingPersonas.slice(0, 3).join(", ")}${remainingPersonas.length > 3 ? `, and ${remainingPersonas.length - 3} more` : ""}`;
320
- response.remainingPersonas = remainingPersonas;
321
- }
322
- // Auto-save handled by tier-gate wrapper.
323
- //
324
- // The UI resource is APPENDED to the text block, never substituted for
325
- // it: content[0] stays the same JSON every CLI and CI caller already
326
- // parses. `generateEmpathyAuditHtmlReport` is the report the --html flag
327
- // has always produced — self-contained, no remote refs — so this is a
328
- // transport seam rather than new UI. If it cannot be produced safely,
329
- // htmlUiResource returns null and the response is exactly as before.
330
- // (2026-07-29)
331
- const content = [
332
- { type: "text", text: JSON.stringify(response, null, 2) },
333
- ];
334
- if (uiResourcesEnabled(uiResource)) {
335
- // The overlay goes FIRST. It is the thing that makes a score
336
- // falsifiable by eye — barriers drawn on the page at their real
337
- // positions — and the text report is the detail behind it.
338
- try {
339
- const { buildBarrierOverlayHtml } = await import("../../visual/barrier-overlay-html.js");
340
- const shots = (response.pageScreenshots ?? []);
341
- for (const shot of shots) {
342
- const overlayHtml = buildBarrierOverlayHtml({
343
- imageUrl: shot.screenshotUrl,
344
- imageWidth: shot.viewportSize?.width,
345
- captureHeight: shot.captureHeight ?? shot.viewportSize?.height,
346
- barrierRects: shot.barrierRects ?? [],
347
- persona: String(shot.persona ?? testedPersona),
348
- pageUrl: String(result.url),
349
- score: result.overallScore,
350
- });
351
- attachUiResource(content, htmlUiResource(`ui://cbrowser/barrier-overlay/${encodeURIComponent(String(shot.persona ?? testedPersona))}`, overlayHtml ?? undefined, { frameSize: ["100%", "820px"] }));
352
- }
353
- }
354
- catch { /* overlay is additive; never fail the audit for it */ }
355
- let reportHtml;
356
- try {
357
- const { generateEmpathyAuditHtmlReport } = await import("../../analysis/accessibility-empathy.js");
358
- reportHtml = generateEmpathyAuditHtmlReport(result);
359
- }
360
- catch {
361
- reportHtml = undefined;
362
- }
363
- attachUiResource(content, htmlUiResource(`ui://cbrowser/empathy-audit/${encodeURIComponent(testedPersona)}`, reportHtml, { frameSize: ["100%", "760px"] }));
364
- }
365
- return { content };
366
- }
367
- catch (error) {
368
- // Categorize the error for better user feedback
369
- const errorMessage = error instanceof Error ? error.message : String(error);
370
- const errorStack = error instanceof Error ? error.stack : undefined;
371
- // Determine error type for actionable feedback
372
- let errorType = "unknown";
373
- let suggestion = "Please try again or contact support if the issue persists.";
374
- if (errorMessage.includes("timeout") || errorMessage.includes("Timeout")) {
375
- errorType = "timeout";
376
- suggestion = "The page took too long to load. Try increasing maxTime or testing a faster page.";
377
- }
378
- else if (errorMessage.includes("net::") || errorMessage.includes("DNS") || errorMessage.includes("ERR_")) {
379
- errorType = "network";
380
- suggestion = "Unable to reach the URL. Check the URL is valid and accessible.";
381
- }
382
- else if (errorMessage.includes("blocked") || errorMessage.includes("403") || errorMessage.includes("captcha")) {
383
- errorType = "bot-detection";
384
- suggestion = "The site may be blocking automation. Try with a different URL.";
385
- }
386
- else if (errorMessage.includes("chromium") || errorMessage.includes("browser")) {
387
- errorType = "browser";
388
- suggestion = "Browser automation error. The server may need to restart.";
389
- }
390
- // Log the full error for debugging
391
- console.error(`[empathy_audit] Error: ${errorMessage}`);
392
- if (errorStack) {
393
- console.error(`[empathy_audit] Stack: ${errorStack}`);
394
- }
395
- // Return a structured error response
396
- return {
397
- content: [
398
- {
399
- type: "text",
400
- text: JSON.stringify({
401
- error: true,
402
- errorType,
403
- message: errorMessage,
404
- suggestion,
405
- url,
406
- goal,
407
- disabilities: disabilities || "all",
408
- }, null, 2),
409
- },
410
- ],
411
- isError: true,
412
- };
413
- }
414
- });
415
- server.registerTool("webmcp_ready_audit", {
416
- title: "WebMCP Readiness Audit",
417
- description: "Audit an MCP server for Claude in Chrome / WebMCP compatibility. Uses 6-tier evaluation: Server Implementation (25%), Tool Discoverability (20%), Instrumentation (15%), Consistency (15%), Agent Optimizations (15%), Documentation (10%). Returns score (0-100), grade (A-F), tier breakdown, issues, and recommendations.",
418
- inputSchema: {
419
- url: z.string().url().describe("MCP server URL to audit (e.g., https://demo.cbrowser.ai/mcp)"),
420
- apiKey: z.string().optional().describe("API key if server requires authentication"),
421
- oauthToken: z.string().optional().describe("OAuth token if server uses OAuth"),
422
- timeout: z.number().optional().default(30000).describe("Timeout in ms (default: 30000)"),
423
- },
424
- annotations: {
425
- title: "WebMCP Readiness Audit",
426
- readOnlyHint: true,
427
- destructiveHint: false,
428
- idempotentHint: false,
429
- openWorldHint: true,
430
- },
431
- }, async ({ url, apiKey, oauthToken, timeout }) => {
432
- const result = await runWebMCPReadyAudit(url, {
433
- apiKey,
434
- oauthToken,
435
- timeout,
436
- });
437
- return {
438
- content: [
439
- {
440
- type: "text",
441
- text: JSON.stringify({
442
- url: result.url,
443
- score: result.score,
444
- grade: result.grade,
445
- summary: result.summary,
446
- tierScores: result.tiers.map((t) => ({
447
- tier: t.tier,
448
- name: t.name,
449
- score: t.score,
450
- weight: `${Math.round(t.weight * 100)}%`,
451
- })),
452
- topIssues: result.issues.slice(0, 5),
453
- topRecommendations: result.recommendations.slice(0, 5),
454
- duration: result.duration,
455
- }, null, 2),
179
+ }, async ({ url, apiKey, oauthToken, timeout }) => {
180
+ const result = await runWebMCPReadyAudit(url, {
181
+ apiKey,
182
+ oauthToken,
183
+ timeout,
184
+ });
185
+ return {
186
+ content: [
187
+ {
188
+ type: "text",
189
+ text: JSON.stringify({
190
+ url: result.url,
191
+ score: result.score,
192
+ grade: result.grade,
193
+ summary: result.summary,
194
+ tierScores: result.tiers.map((t) => ({
195
+ tier: t.tier,
196
+ name: t.name,
197
+ score: t.score,
198
+ weight: `${Math.round(t.weight * 100)}%`,
199
+ })),
200
+ topIssues: result.issues.slice(0, 5),
201
+ topRecommendations: result.recommendations.slice(0, 5),
202
+ duration: result.duration,
203
+ }, null, 2),
456
204
  },
457
205
  ],
458
206
  };
@@ -465,6 +213,12 @@ export function registerAuditTools(server, context) {
465
213
  url: z.string().url().describe("URL to assess"),
466
214
  personas: z.string().optional().default("first-timer,cognitive-adhd").describe("Comma-separated persona names (default: first-timer,cognitive-adhd)"),
467
215
  threshold: z.number().optional().default(60).describe("Minimum agent-ready score to proceed to persona analysis (default: 60)"),
216
+ // Per RUN, not per persona. The same person is familiar with one site and new
217
+ // to another, so this has no value until a site is named -- a stored value only
218
+ // ever answered "familiar with which site?" by accident. The persona's value is
219
+ // the fallback when a caller does not pass one. (2026-08-01)
220
+ siteFamiliarity: z.number().min(0).max(1).optional()
221
+ .describe("How familiar these personas are with THIS site: 0 first visit, 1 regular user. Overrides any value stored on the persona. Familiarity is a persona-site pair, not a disposition, so set it per run."),
468
222
  userLocation: z.string().optional().describe("User's approximate location (e.g., 'Denver, Colorado, US') — for geo-aware content expectations"),
469
223
  userTimezone: z.string().optional().describe("User's timezone (e.g., 'America/Denver') — for time-sensitive content evaluation"),
470
224
  userLanguage: z.string().optional().describe("User's expected language (e.g., 'en-US') — for readability calibration"),
@@ -483,7 +237,7 @@ export function registerAuditTools(server, context) {
483
237
  idempotentHint: false,
484
238
  openWorldHint: true,
485
239
  },
486
- }, async ({ url, personas, threshold, userLocation, userTimezone, userLanguage, proxy, geoRegion, device, waitAfterLoad, waitForSelector, scope, _browserToken }) => {
240
+ }, async ({ url, personas, threshold, siteFamiliarity, userLocation, userTimezone, userLanguage, proxy, geoRegion, device, waitAfterLoad, waitForSelector, scope, _browserToken }) => {
487
241
  const startTime = Date.now();
488
242
  const personaList = (personas || "first-timer,cognitive-adhd").split(",").map(s => s.trim()).filter(Boolean);
489
243
  // Derive locale from userLanguage (e.g., "en-US" → locale "en-US", language "en")
@@ -737,7 +491,7 @@ export function registerAuditTools(server, context) {
737
491
  const { buildOTCognitiveProfile } = await import("../../visual/cognitive-transport.js");
738
492
  const { computeDemandDistribution, computeSequentialCTC } = await import("../../visual/cognitive-transport-chain.js");
739
493
  const { extractPageMetrics } = await import("../../visual/cognitive-transport.js");
740
- const { getAnyPersona, createCognitivePersona } = await import("../../personas.js");
494
+ const { getAnyPersona, createCognitivePersona, resolvePersonaOrThrow } = await import("../../personas.js");
741
495
  // Navigate in our browser for page metrics
742
496
  await browser.navigate(url);
743
497
  const metricsPage = await browser.getPage();
@@ -771,20 +525,29 @@ export function registerAuditTools(server, context) {
771
525
  for (const personaName of personaList) {
772
526
  try {
773
527
  const existingPersona = getAnyPersona(personaName);
528
+ if (!existingPersona)
529
+ resolvePersonaOrThrow(personaName);
774
530
  const personaObj = existingPersona || createCognitivePersona(personaName, personaName, {});
775
531
  const traits = { ...(personaObj.cognitiveTraits || {}) };
776
- // v18.61.0: siteFamiliarity is a binary gate, not a gradient
777
- // Has site knowledge → persona keeps its configured familiarity (maxed to 1.0 for high-familiarity personas)
778
- // No site knowledge → forced to 0.0 regardless of persona definition
532
+ // Familiarity is a persona-SITE pair, so the caller's value for THIS
533
+ // run wins over anything stored on the persona. A stored value only
534
+ // ever answered "familiar with which site?" by accident.
535
+ // (2026-08-01)
779
536
  const reqFam = traits.siteFamiliarity ?? 0.5;
780
- if (hasSiteKnowledge) {
781
- // Site knowledge exists — high-familiarity personas get max familiarity
782
- if (reqFam > 0.5)
783
- traits.siteFamiliarity = 1.0;
537
+ const familiarityFromCaller = typeof siteFamiliarity === "number";
538
+ if (familiarityFromCaller) {
539
+ traits.siteFamiliarity = siteFamiliarity;
784
540
  }
785
541
  else {
786
- // No site knowledge — everyone is a first-time visitor
787
- traits.siteFamiliarity = 0.0;
542
+ // v18.61.0: without an explicit value it stays a binary gate on
543
+ // whether this install has crawled the site at all.
544
+ if (hasSiteKnowledge) {
545
+ if (reqFam > 0.5)
546
+ traits.siteFamiliarity = 1.0;
547
+ }
548
+ else {
549
+ traits.siteFamiliarity = 0.0;
550
+ }
788
551
  }
789
552
  const otProfile = buildOTCognitiveProfile(personaName, traits);
790
553
  const result = computeSequentialCTC(otProfile, demand, { asymmetric: true, interactions: true });
@@ -798,9 +561,13 @@ export function registerAuditTools(server, context) {
798
561
  name: l.name,
799
562
  cost: Math.round(l.transportCost * 1000) / 1000,
800
563
  })),
801
- ...(reqFam > 0.5 && !hasSiteKnowledge ? {
802
- familiarityWarning: `siteFamiliarity downgraded from ${reqFam} to 0.0 — no site knowledge exists. Run page_understand first for accurate ${personaName} results.`,
564
+ // Only warn about the automatic downgrade. A caller who SET the
565
+ // value for this run has already answered the question the
566
+ // downgrade exists to answer.
567
+ ...(!familiarityFromCaller && reqFam > 0.5 && !hasSiteKnowledge ? {
568
+ familiarityWarning: `siteFamiliarity downgraded from ${reqFam} to 0.0 — no site knowledge exists for this domain. Pass siteFamiliarity on this call to state it directly, or run page_understand first.`,
803
569
  } : {}),
570
+ ...(familiarityFromCaller ? { siteFamiliaritySource: "supplied for this run" } : {}),
804
571
  };
805
572
  }
806
573
  catch (personaErr) {
@@ -921,7 +688,20 @@ export function registerAuditTools(server, context) {
921
688
  else {
922
689
  browser = new BrowserClass({
923
690
  headless: true,
924
- ...(device ? { device: device.toLowerCase() } : { viewportWidth: 1920, viewportHeight: 1080 }),
691
+ // The CONFIGURED viewport, not a hardcoded 1920x1080.
692
+ //
693
+ // cognitive_effort and every other tool render at the configured
694
+ // default (1280x800), and this one alone rendered at 1920x1080. The
695
+ // two tools were therefore measuring different rendered pages while
696
+ // reporting coordinates as if they shared a space -- attention regions
697
+ // came back at x=1312 against a config that says the viewport is 1280
698
+ // wide, which is impossible and was the tell. Any comparison between
699
+ // an attention run and a CTC run was comparing two different layouts.
700
+ // (2026-08-02)
701
+ ...(device ? { device: device.toLowerCase() } : {
702
+ viewportWidth: getDefaultConfig().viewportWidth,
703
+ viewportHeight: getDefaultConfig().viewportHeight,
704
+ }),
925
705
  });
926
706
  await browser.launch();
927
707
  }
@@ -981,8 +761,9 @@ export function registerAuditTools(server, context) {
981
761
  try {
982
762
  const { motorAccessibility } = await import("../../visual/cognitive-models.js");
983
763
  const { buildOTCognitiveProfile } = await import("../../visual/cognitive-transport.js");
984
- const { getAnyPersona, createCognitivePersona } = await import("../../personas.js");
985
- const personaObj = getAnyPersona(persona) || createCognitivePersona(persona, persona, {});
764
+ const { getAnyPersona, createCognitivePersona, resolvePersonaOrThrow } = await import("../../personas.js");
765
+ // A miss throws instead of yielding an all-midpoint stand-in.
766
+ const personaObj = getAnyPersona(persona) || resolvePersonaOrThrow(persona);
986
767
  const traits = (personaObj.cognitiveTraits || {});
987
768
  const otProfile = buildOTCognitiveProfile(persona, traits);
988
769
  const elements = await page.evaluate(() => {
@@ -1110,8 +891,9 @@ export function registerAuditTools(server, context) {
1110
891
  const { buildOTCognitiveProfile } = await import("../../visual/cognitive-transport.js");
1111
892
  const { computeDemandDistribution, computeSequentialCTC } = await import("../../visual/cognitive-transport-chain.js");
1112
893
  const { extractPageMetrics } = await import("../../visual/cognitive-transport.js");
1113
- const { getAnyPersona, createCognitivePersona } = await import("../../personas.js");
1114
- const personaObj = getAnyPersona(persona) || createCognitivePersona(persona, persona, {});
894
+ const { getAnyPersona, createCognitivePersona, resolvePersonaOrThrow } = await import("../../personas.js");
895
+ // A miss throws instead of yielding an all-midpoint stand-in.
896
+ const personaObj = getAnyPersona(persona) || resolvePersonaOrThrow(persona);
1115
897
  const traits = (personaObj.cognitiveTraits || {});
1116
898
  const otProfile = buildOTCognitiveProfile(persona, traits);
1117
899
  const pageMetrics = await extractPageMetrics(page);
@@ -1316,4 +1098,477 @@ export function registerAuditTools(server, context) {
1316
1098
  }
1317
1099
  });
1318
1100
  }
1101
+ /**
1102
+ * The canonical empathy_audit. Exported on its own so the stdio server can
1103
+ * register it too: mcp-server.ts carried a second, older implementation of
1104
+ * the same tool name that returned a thin summary with no barrier rects, no
1105
+ * persona weighting and no UI resource. Two implementations of one tool is a
1106
+ * bug generator -- fixes landed on the remote one for weeks while the local
1107
+ * one silently served stale shapes -- and registerAuditTools cannot simply be
1108
+ * called from there because three of its other tools collide with the stdio
1109
+ * server's own. (2026-07-31)
1110
+ */
1111
+ export function registerEmpathyAuditTool(server) {
1112
+ server.registerTool("empathy_audit", {
1113
+ // Barriers ranked by severity is the artifact a product team acts on, and
1114
+ // nested JSON buries the ordering that makes it actionable.
1115
+ _meta: { ui: { resourceUri: "ui://cbrowser/empathy" } },
1116
+ title: "Empathy Accessibility Audit",
1117
+ description: "Simulate how real users experience a site. Accepts ANY persona — disability personas get specialized barrier detection, non-disability personas get general UX analysis with a flag. Tests ONE persona per call. Disability: motor-impairment-tremor, low-vision-magnified, cognitive-adhd, dyslexic-user, deaf-user, elderly-low-vision, color-blind-deuteranopia. General: first-timer, power-user, mobile-user, elderly-user, impatient-user, or any custom persona.",
1118
+ inputSchema: {
1119
+ url: z.string().url().describe("URL to audit"),
1120
+ // Optional as of 2026-08-02. It was required, which implied every audit
1121
+ // traversed toward it — most do not. A cognitive journey runs only when an
1122
+ // API key is configured; without one, nothing reads this. Barrier
1123
+ // detection, the score, and the noBlockingBarriers verdict never do.
1124
+ goal: z.string().optional().describe("Optional. What the persona is trying to do (e.g. 'complete checkout'). Used ONLY when an API key is set, to drive a cognitive journey and record a goal path. Barrier detection and the empathy score do not use it, so omitting it changes nothing about the accessibility findings."),
1125
+ disabilities: z.array(z.string()).optional().describe("Persona to test. Accepts disability names OR any cognitive persona (first-timer, power-user, etc). Non-disability personas are flagged. Pass ONE for reliable results."),
1126
+ wcagLevel: z.enum(["A", "AA", "AAA"]).optional().default("AA").describe("WCAG conformance level"),
1127
+ maxSteps: z.number().optional().default(5).describe("Max cognitive journey steps (keep low for MCP)"),
1128
+ maxTime: z.number().optional().default(20).describe("Max time per persona in seconds"),
1129
+ // Base64 page screenshots are large enough to blow an MCP client's context
1130
+ // on a single call — every empathy_audit in the 18.73.3 test pass had to be
1131
+ // read off disk instead. Off by default; images go to disk and their paths
1132
+ // are returned. (2026-07-29)
1133
+ includeScreenshots: z.boolean().optional().default(false).describe("Inline base64 page screenshots in the response. Off by default because they are large enough to overflow an MCP client's context; when off, screenshots are written to disk and screenshotPath is returned instead."),
1134
+ scope: z.enum(["viewport", "full_page"]).optional().default("viewport").describe("What to score: 'viewport' (first impression, above-the-fold only — default) or 'full_page' (scroll through entire page, all barriers). Use 'viewport' for landing page optimization; 'full_page' for WCAG compliance audits."),
1135
+ device: z.string().optional().describe("Device emulation: 'mobile', 'tablet', 'desktop', or specific device like 'iPhone 15', 'Pixel 7'. Essential for mobile WCAG audits — touch targets, viewport sizing, and responsive barriers differ significantly on mobile."),
1136
+ uiResource: z.boolean().optional().default(true).describe("Return an interactive HTML report as an MCP UI resource alongside the JSON. Hosts that support MCP Apps render it inline; others ignore it. Set false for scripted callers that diff whole responses."),
1137
+ },
1138
+ annotations: {
1139
+ title: "Empathy Accessibility Audit",
1140
+ readOnlyHint: true,
1141
+ destructiveHint: false,
1142
+ idempotentHint: false,
1143
+ openWorldHint: true,
1144
+ },
1145
+ }, async ({ url, goal, disabilities, wcagLevel, maxSteps, maxTime, scope, device, includeScreenshots, uiResource }) => {
1146
+ try {
1147
+ // Auto-limit to 1 persona to avoid MCP client timeout on Claude.ai (~60s limit)
1148
+ const allPersonas = listAccessibilityPersonas();
1149
+ const requestedList = disabilities || allPersonas;
1150
+ const wasLimited = requestedList.length > 1;
1151
+ const singlePersona = [requestedList[0]];
1152
+ const result = await runEmpathyAudit(url, {
1153
+ goal,
1154
+ disabilities: singlePersona,
1155
+ wcagLevel,
1156
+ maxSteps,
1157
+ maxTime,
1158
+ headless: true,
1159
+ scope: scope || "viewport",
1160
+ device: device || undefined,
1161
+ });
1162
+ // Build response with guidance for additional personas
1163
+ const testedPersona = singlePersona[0];
1164
+ const remainingPersonas = allPersonas.filter(p => p !== testedPersona);
1165
+ // The findings shown, selected once so the overlay and the list agree.
1166
+ // Both the numbered boxes on the map and the numbered entries in the
1167
+ // list index into this array, so box 3 is finding 3 by construction
1168
+ // rather than by two pieces of code happening to sort the same way.
1169
+ const chosenBarriers = (() => {
1170
+ const top = result.topBarriers.slice(0, 5);
1171
+ const covered = new Set(top.flatMap((b) => b.wcagCriteria ?? []));
1172
+ const rescued = [];
1173
+ for (const b of result.topBarriers.slice(5)) {
1174
+ const missing = (b.wcagCriteria ?? []).filter((c) => !covered.has(c));
1175
+ if (missing.length === 0)
1176
+ continue;
1177
+ missing.forEach((c) => covered.add(c));
1178
+ rescued.push(b);
1179
+ }
1180
+ // Numbered in the order they are DISPLAYED (worst-first by the
1181
+ // persona-weighted grade, which is how the widget sorts them), so
1182
+ // the numbers on the map read 1,2,3 down the list instead of
1183
+ // whatever order the analyzer happened to emit.
1184
+ const rank = (b) => {
1185
+ const { weight } = barrierWeightFor(testedPersona, String(b.type ?? ""), b.wcagCriteria);
1186
+ const { severity } = weightedSeverity(String(b.severity ?? "minor"), weight);
1187
+ const order = {
1188
+ critical: 0, blocker: 0, high: 1, major: 1, serious: 1,
1189
+ medium: 2, moderate: 2, low: 3, minor: 3, info: 4, notice: 4
1190
+ };
1191
+ return order[String(severity).toLowerCase()] ?? 9;
1192
+ };
1193
+ return [...top, ...rescued].sort((a, b) => rank(a) - rank(b));
1194
+ })();
1195
+ // Group keys in list order; a rect's key looked up here gives its
1196
+ // finding number.
1197
+ const findingKeys = chosenBarriers.map((b) => `${b.type}|${weightKeyFor(String(b.type ?? ""), b.wcagCriteria) ?? ""}`);
1198
+ const response = {
1199
+ url: result.url,
1200
+ goal: result.goal,
1201
+ testedPersona,
1202
+ overallScore: result.overallScore,
1203
+ scope: scope || "viewport",
1204
+ device: device || "desktop",
1205
+ scopeNote: scope === "full_page"
1206
+ ? "Full-page audit: scrolled through entire page before barrier detection. Scores reflect all content including below-the-fold."
1207
+ : "Viewport-only audit: scored first impression (above-the-fold). Use scope='full_page' for complete barrier inventory, or cognitive_journey for path-dependent experience.",
1208
+ resultsSummary: result.results.map((r) => {
1209
+ const uniqueTypes = new Set(r.barriers.map(b => b.type));
1210
+ return {
1211
+ persona: r.persona,
1212
+ disabilityType: r.disabilityType,
1213
+ // What the audit actually computed: no barrier found that would
1214
+ // block this persona. Renamed from goalAchieved, which asserted a
1215
+ // completed task nobody attempted.
1216
+ noBlockingBarriers: r.noBlockingBarriers,
1217
+ /** @deprecated one release only — use noBlockingBarriers */
1218
+ goalAchieved: r.noBlockingBarriers,
1219
+ empathyScore: r.empathyScore,
1220
+ // v18.22.0: Added score context for transparency
1221
+ // Whitelist projection: anything added to scoreContext upstream is
1222
+ // invisible to the caller unless named here. The blend breakdown
1223
+ // below is what lets the deductions reconcile to a score the
1224
+ // caller can actually see. (2026-07-28)
1225
+ scoreContext: r.scoreContext ? {
1226
+ explanation: r.scoreContext.explanation,
1227
+ deductionsByType: r.scoreContext.deductionsByType,
1228
+ totalBarrierDeduction: r.scoreContext.totalBarrierDeduction,
1229
+ frictionDeduction: r.scoreContext.frictionDeduction,
1230
+ goalDeduction: r.scoreContext.goalDeduction,
1231
+ cognitiveOverloadPenalty: r.scoreContext.cognitiveOverloadPenalty,
1232
+ appliedBarrierWeights: r.scoreContext.appliedBarrierWeights,
1233
+ explainsScore: r.scoreContext.explainsScore,
1234
+ barrierOnlyScore: r.scoreContext.barrierOnlyScore,
1235
+ perceptualScore: r.scoreContext.perceptualScore,
1236
+ blendWeights: r.scoreContext.blendWeights,
1237
+ finalScore: r.scoreContext.finalScore,
1238
+ // A partial score must be distinguishable from a measured one.
1239
+ // Set when the navigation simulation threw: the goal traversal
1240
+ // did not run, so no goal deduction was charged and the score
1241
+ // reflects barriers only. Without these two names the whitelist
1242
+ // above would drop the flag and the score would read as clean.
1243
+ degraded: r.scoreContext.degraded,
1244
+ degradedReason: r.scoreContext.degradedReason,
1245
+ } : undefined,
1246
+ barrierTypeCount: uniqueTypes.size,
1247
+ barrierTypes: Array.from(uniqueTypes),
1248
+ affectedElements: r.barriers.length,
1249
+ wcagViolationCount: r.wcagViolations.length,
1250
+ // v18.26.0: Perceptual transport metrics (Wasserstein-based)
1251
+ perceptualTransport: r.perceptualTransport || undefined,
1252
+ empathyScoreBarrierOnly: r.empathyScoreBarrierOnly || undefined,
1253
+ // v18.27.0: Cognitive load estimation (optimal transport)
1254
+ cognitiveLoad: r.cognitiveLoad || undefined,
1255
+ // Names the three different "cognitive load" numbers in this
1256
+ // payload and what each measures. Same whitelist caveat as
1257
+ // scoreContext above: set upstream, invisible unless named here.
1258
+ cognitiveLoadReadings: r.cognitiveLoadReadings || undefined,
1259
+ // v18.28.0: Attention transport analysis (W₂ saliency)
1260
+ attentionAnalysis: r.attentionAnalysis || undefined,
1261
+ // Same metric names as the attention_analysis tool, different
1262
+ // implementation, and the distributions do not overlap --
1263
+ // measured on one page: alignment 0.884 here against 0.389-0.616
1264
+ // there, concentration 0.292 against 0.65-0.90, transportCost
1265
+ // 0.460 against 0.756-1.010. Comparing a number from one against
1266
+ // a number from the other is meaningless, and nothing said so.
1267
+ // Corrected: this is the SAME analyzeAttention implementation the
1268
+ // attention_analysis tool calls, not a second one. The earlier
1269
+ // note claimed otherwise, which was wrong -- the divergence comes
1270
+ // from parameters, a 16px grid here against the tool's finer
1271
+ // default and a viewport rather than full-page capture.
1272
+ attentionAnalysisNote: r.attentionAnalysis
1273
+ ? "Same analyzeAttention implementation the attention_analysis tool uses, run here on a 16px grid over the viewport rather than the tool's finer default — so the values are not comparable across the two and only entropy, concentration and transportCost are published, because those are the three that feed this audit's perceptual score. Call attention_analysis directly for attention metrics."
1274
+ : undefined,
1275
+ // v18.29.0: Journey validation — evidence, path, forensics
1276
+ journeyValidation: r.journeyValidation || undefined,
1277
+ // v18.35.0: Non-disability persona flag
1278
+ isDisabilityPersona: r.isDisabilityPersona !== false,
1279
+ personaNote: r.personaNote || undefined,
1280
+ // Media this page cannot decide about. Not barriers and not
1281
+ // scored: a third-party embed keeps its caption and description
1282
+ // tracks inside a cross-origin iframe, so the only honest report
1283
+ // is that the question is open and where to go to close it.
1284
+ // Previously these were asserted as missing captions.
1285
+ unverifiableMedia: r.unverifiableMedia || undefined,
1286
+ };
1287
+ }),
1288
+ allWcagViolations: result.allWcagViolations,
1289
+ // Top five, PLUS one barrier for every WCAG criterion the top five
1290
+ // would otherwise drop.
1291
+ //
1292
+ // A flat slice made the audit unusable for compliance: 1.1.1 and
1293
+ // 1.2.2 appeared in allWcagViolations while no barrier, remediation
1294
+ // or rect mentioned them, so fixing everything listed still left two
1295
+ // violations with nothing saying what they were. The criteria are
1296
+ // reported either way, so the slice was hiding the explanation while
1297
+ // keeping the accusation.
1298
+ topBarriers: (() => {
1299
+ const chosen = chosenBarriers;
1300
+ // Severity as this persona experiences it, alongside the base.
1301
+ // The base is what maps to WCAG conformance -- a criterion is not
1302
+ // more or less violated depending on who reads it -- so both ship
1303
+ // and the weight that separates them ships with them.
1304
+ return chosen.map((b, i) => {
1305
+ const { weight, key, defaulted } = barrierWeightFor(testedPersona, String(b.type ?? ""), b.wcagCriteria);
1306
+ const { severity, shifted } = weightedSeverity(String(b.severity ?? "minor"), weight);
1307
+ return {
1308
+ ...b,
1309
+ // Same number the overlay draws on the matching boxes.
1310
+ finding: i + 1,
1311
+ severityForPersona: severity,
1312
+ personaWeight: Math.round(weight * 100) / 100,
1313
+ personaWeightKey: key,
1314
+ // An unmeasured 1.0 and a measured 1.0 mean different things.
1315
+ // Saying which prevents a default from reading as a finding.
1316
+ personaWeightIsDefault: defaulted,
1317
+ ...(shifted !== 0
1318
+ ? { severityShiftedBy: shifted > 0 ? `+${shifted}` : String(shifted) }
1319
+ : {}),
1320
+ };
1321
+ });
1322
+ })(),
1323
+ topBarriersNote: "Ordered worst-first by severityForPersona — the persona-weighted grade — so an entry can sit above a higher WCAG severity when this persona is more susceptible to it. Any entries after the fifth are included because they carry a WCAG criterion the top five do not.",
1324
+ // Asserting full coverage was itself a defect: the claim held only
1325
+ // when a barrier existed for every criterion, and said nothing when
1326
+ // one did not. Now it reports the gap instead of asserting there
1327
+ // isn't one.
1328
+ wcagCriteriaWithoutBarrier: (() => {
1329
+ const covered = new Set(result.topBarriers.flatMap((b) => b.wcagCriteria ?? []));
1330
+ const missing = Array.from(result.allWcagViolations ?? []).filter((c) => !covered.has(c));
1331
+ return missing.length ? missing : undefined;
1332
+ })(),
1333
+ // A barrier's affectedPersonas names the personas it hits HARDEST. It
1334
+ // is not an exclusion list, which is why a persona absent from it can
1335
+ // still take a deduction — susceptibility is applied as a weight, not
1336
+ // a gate (see scoreContext.appliedBarrierWeights). Stating it here
1337
+ // because the pairing otherwise reads as a contradiction: a
1338
+ // touch_target barrier listing motor and elderly personas, scored
1339
+ // against cognitive-adhd. (2026-07-29)
1340
+ // Same treatment cognitiveLoadReadings got: state why two numbers
1341
+ // that look contradictory are both right, rather than leaving a
1342
+ // reader to conclude one is broken.
1343
+ scoreFieldNotes: {
1344
+ cognitiveOverloadPenalty: "Persona susceptibility to visual noise, derived from the persona's own noiseTolerance and charged on every page — NOT a finding that this page overloaded anyone. It is non-zero for a sensitive persona even where cognitiveLoad.overloaded is false, because that flag is a separate page measurement against a threshold. The two disagreeing is expected.",
1345
+ },
1346
+ barrierFieldNotes: {
1347
+ affectedPersonas: "Exemplars — the personas this barrier affects most. NOT an exclusion list; every tested persona is scored against every barrier, weighted by susceptibility.",
1348
+ severity: "On topBarriers entries this is the group maximum across all elements of that type (severityIsGroupMax), not one element's severity.",
1349
+ severityForPersona: "The same barrier re-graded by this persona's susceptibility weight, which is what should drive triage order. `severity` stays the WCAG-facing grade: a criterion is not more or less violated depending on who reads it. A 169-item navigation is genuinely minor to most personas and critical to cognitive-adhd, whose cognitive_load weight is 3.0 — and only the weighted grade says so.",
1350
+ },
1351
+ topRemediation: result.combinedRemediation.slice(0, 5),
1352
+ duration: result.duration,
1353
+ // Errors caught during the run, promoted out of the HTML report. The
1354
+ // SVG className crash changed goalAchieved and the goal deduction while
1355
+ // being visible ONLY in the HTML, so the JSON reported a confident
1356
+ // wrong number. Anything that moves a scored field is surfaced here.
1357
+ errors: result.results.flatMap((r) => (r.frictionPoints ?? [])
1358
+ .filter((fp) => fp.type === "error")
1359
+ .map((fp) => ({ persona: r.persona, message: fp.description }))),
1360
+ // v18.60.0: Include per-result screenshots and element rects for WCAG overlay
1361
+ pageScreenshots: result.results?.map((r) => ({
1362
+ persona: r.persona,
1363
+ ...(includeScreenshots
1364
+ ? { screenshot: r.pageScreenshot }
1365
+ : (() => {
1366
+ const shot = writeAuditScreenshot(r.pageScreenshot, r.persona);
1367
+ return {
1368
+ screenshotPath: shot.path,
1369
+ screenshotUrl: shot.url,
1370
+ screenshotFile: shot.file,
1371
+ screenshotNote: "Pass includeScreenshots:true to inline the base64 instead.",
1372
+ };
1373
+ })()),
1374
+ viewportSize: r.viewportSize,
1375
+ // How much of the barrier set the overlay can actually draw.
1376
+ //
1377
+ // A rect needs an element that resolved to a bounding box at
1378
+ // capture time, so anything below the fold, hidden, or behind an
1379
+ // unresolvable selector produces a barrier with no box. The
1380
+ // overlay then silently understates coverage -- 21 affected
1381
+ // elements against 10 rects reads as "10 problems" unless the gap
1382
+ // is stated. Reported rather than papered over, the same way
1383
+ // cognitiveLoadReadings reports its disagreement.
1384
+ barrierRectCoverage: (() => {
1385
+ // Counted the same way barrierRects is built below, from
1386
+ // barriers that resolved to a rect. Reading r.barrierRects gave
1387
+ // 0 every time: that field is this payload's OUTPUT name, not an
1388
+ // input, so the coverage line reported drawn:0 beside ten
1389
+ // populated rects.
1390
+ // Zero-area rects draw nothing. They were counted as drawn, so
1391
+ // coverage read 19 while only 16 boxes were visible -- an
1392
+ // element that resolved to {0,0,0,0} (display:none, a collapsed
1393
+ // menu item) is undrawn in every sense that matters.
1394
+ const rects = (r.barriers ?? []).filter((b) => b.rect && b.rect.width > 0 && b.rect.height > 0).length;
1395
+ const affected = (r.barriers ?? []).reduce((n, b) => n + (b.affectedElementCount ?? 1), 0);
1396
+ return {
1397
+ drawn: rects,
1398
+ affectedElements: affected,
1399
+ ...(affected > rects
1400
+ ? { undrawn: affected - rects,
1401
+ undrawnReason: "Barriers whose element did not resolve to a bounding box at capture time — typically below the fold, hidden, or an unresolvable selector. They are counted in the score and listed in barriers, but the overlay cannot outline them." }
1402
+ : {}),
1403
+ };
1404
+ })(),
1405
+ // Rects come from getBoundingClientRect with no scroll compensation,
1406
+ // so they are VIEWPORT coordinates — which is correct here, because
1407
+ // the screenshot above is captured with fullPage: false and these are
1408
+ // meant to overlay it. Converting them to document coordinates would
1409
+ // break that alignment.
1410
+ //
1411
+ // The real hazard is scope: "full_page", where the audit scrolls and
1412
+ // rects captured at different offsets land in one list against a
1413
+ // single viewport image; anything above the captured viewport then
1414
+ // carries a negative y and would render off-canvas. Say which space
1415
+ // these are in and flag the ones that fall outside the image rather
1416
+ // than emitting silent negatives. (2026-07-28)
1417
+ // Rects are now DOCUMENT coordinates (scroll offset added at capture),
1418
+ // so they are stable across the scrolling that scope:"full_page" does.
1419
+ // They were previously labelled "viewport" while holding values from
1420
+ // several scroll positions at once — y:-274 and y:3951 in one list.
1421
+ coordinateSpace: "document",
1422
+ // The document-space region the screenshot actually covers, so a
1423
+ // consumer can map a rect onto the image without guessing. A
1424
+ // full_page capture starts at the document origin; a viewport
1425
+ // capture starts wherever the page was scrolled to.
1426
+ //
1427
+ // These two fields exist because captureHeight alone was not
1428
+ // enough: it reported documentHeight while the image was the
1429
+ // viewport, and the overlay scaled 5363px of coordinates onto an
1430
+ // 800px picture. Origin plus size is the whole answer, and it is
1431
+ // read from the PNG header rather than measured separately, so it
1432
+ // cannot disagree with the picture it describes. (2026-07-31)
1433
+ captureOrigin: scope === "full_page"
1434
+ ? { x: 0, y: 0 }
1435
+ : (r.captureScroll ?? { x: 0, y: 0 }),
1436
+ captureSize: r.screenshotSize,
1437
+ // Height of the captured image in document space. A viewport capture
1438
+ // covers only the scroll position it was taken at; a full_page capture
1439
+ // covers the document. Without this the bounds test compared document
1440
+ // coordinates against a viewport height and flagged EVERY rect,
1441
+ // including ones plainly inside the image. (2026-07-29)
1442
+ // The height of the image that exists, preferred over the
1443
+ // separately-measured documentHeight. A page that lazy-loads
1444
+ // during capture makes the two disagree, and the picture is the
1445
+ // one the boxes are drawn on.
1446
+ captureHeight: r.screenshotSize?.height
1447
+ ?? (scope === "full_page" ? (r.documentHeight ?? undefined) : r.viewportSize?.height),
1448
+ barrierRects: r.barriers?.filter((b) => b.rect && b.rect.width > 0 && b.rect.height > 0).map((b) => {
1449
+ // Bounds tested in IMAGE space: document rect minus capture
1450
+ // origin, against the real image size. Testing document
1451
+ // coordinates against documentHeight passed every rect as
1452
+ // "inside" even when the image showed only the top 800px, so
1453
+ // nothing was ever listed as off-capture and the overlay
1454
+ // claimed coverage it did not have.
1455
+ const origin = scope === "full_page"
1456
+ ? { x: 0, y: 0 }
1457
+ : (r.captureScroll ?? { x: 0, y: 0 });
1458
+ const size = r.screenshotSize
1459
+ ?? { width: r.viewportSize?.width ?? Infinity,
1460
+ height: scope === "full_page" ? (r.documentHeight ?? Infinity) : (r.viewportSize?.height ?? Infinity) };
1461
+ const ix = b.rect ? b.rect.x - origin.x : 0;
1462
+ const iy = b.rect ? b.rect.y - origin.y : 0;
1463
+ const outside = !!b.rect && (iy + b.rect.height < 0 || ix + b.rect.width < 0 ||
1464
+ iy > size.height || ix > size.width);
1465
+ // The map is coloured by the SAME grade the findings list is
1466
+ // ranked by. It previously carried only the raw WCAG severity
1467
+ // while the list below it used severityForPersona, so on a
1468
+ // weighted audit one barrier was orange on the map and red in
1469
+ // the list -- two scales, no label saying which was which.
1470
+ // (2026-07-31)
1471
+ const { weight } = barrierWeightFor(r.persona, String(b.type ?? ""), b.wcagCriteria);
1472
+ const { severity: weighted } = weightedSeverity(String(b.severity ?? "minor"), weight);
1473
+ // Which findings-list entry this box belongs to. Boxes are
1474
+ // per-element and findings are grouped by type+criterion, so
1475
+ // ten boxes can map to five findings; without the link there is
1476
+ // no way to tell which box the list is talking about.
1477
+ const gk = `${b.type}|${weightKeyFor(String(b.type ?? ""), b.wcagCriteria) ?? ""}`;
1478
+ const findingIndex = findingKeys.indexOf(gk);
1479
+ return {
1480
+ type: b.type, severity: b.severity, element: b.element,
1481
+ severityForPersona: weighted,
1482
+ ...(findingIndex >= 0 ? { finding: findingIndex + 1 } : {}),
1483
+ description: b.description, rect: b.rect,
1484
+ wcag: b.wcagCriteria,
1485
+ ...(outside ? { outsideScreenshot: true } : {}),
1486
+ };
1487
+ }),
1488
+ // Keep entries that carry EITHER the inline image or a path to it.
1489
+ // Filtering on `screenshot` alone silently dropped every entry once
1490
+ // the default stopped inlining base64, taking the barrierRects with
1491
+ // them. (2026-07-29)
1492
+ })).filter((s) => s.screenshot || s.screenshotPath),
1493
+ };
1494
+ // Add guidance if we limited the request
1495
+ if (wasLimited) {
1496
+ response.note = `Limited to 1 persona to avoid timeout. For full coverage, call again with: ${remainingPersonas.slice(0, 3).join(", ")}${remainingPersonas.length > 3 ? `, and ${remainingPersonas.length - 3} more` : ""}`;
1497
+ response.remainingPersonas = remainingPersonas;
1498
+ }
1499
+ // Auto-save handled by tier-gate wrapper.
1500
+ //
1501
+ // The UI resource is APPENDED to the text block, never substituted for
1502
+ // it: content[0] stays the same JSON every CLI and CI caller already
1503
+ // parses. `generateEmpathyAuditHtmlReport` is the report the --html flag
1504
+ // has always produced — self-contained, no remote refs — so this is a
1505
+ // transport seam rather than new UI. If it cannot be produced safely,
1506
+ // htmlUiResource returns null and the response is exactly as before.
1507
+ // (2026-07-29)
1508
+ const content = [
1509
+ { type: "text", text: JSON.stringify(response, null, 2) },
1510
+ ];
1511
+ // No UI resource is attached here any more.
1512
+ //
1513
+ // Both mcp-ui attachments are retired: the whole-report rawHtml and the
1514
+ // barrier overlay. Both embedded rendered HTML in the result, which
1515
+ // costs payload against the host's ~150k cap, and the overlay's boxes
1516
+ // were drawn over an img on cbrowser.ai that the widget sandbox will
1517
+ // not load -- outlines over empty space. Both are now served by
1518
+ // ui://cbrowser/empathy, declared on the tool, where the host fetches
1519
+ // the view and the overlay pulls its screenshot through artifact_fetch.
1520
+ //
1521
+ // uiResource is still accepted as an argument so existing callers do
1522
+ // not break; it simply no longer changes the response.
1523
+ return { content };
1524
+ }
1525
+ catch (error) {
1526
+ // Categorize the error for better user feedback
1527
+ const errorMessage = error instanceof Error ? error.message : String(error);
1528
+ const errorStack = error instanceof Error ? error.stack : undefined;
1529
+ // Determine error type for actionable feedback
1530
+ let errorType = "unknown";
1531
+ let suggestion = "Please try again or contact support if the issue persists.";
1532
+ if (errorMessage.includes("timeout") || errorMessage.includes("Timeout")) {
1533
+ errorType = "timeout";
1534
+ suggestion = "The page took too long to load. Try increasing maxTime or testing a faster page.";
1535
+ }
1536
+ else if (errorMessage.includes("net::") || errorMessage.includes("DNS") || errorMessage.includes("ERR_")) {
1537
+ errorType = "network";
1538
+ suggestion = "Unable to reach the URL. Check the URL is valid and accessible.";
1539
+ }
1540
+ else if (errorMessage.includes("blocked") || errorMessage.includes("403") || errorMessage.includes("captcha")) {
1541
+ errorType = "bot-detection";
1542
+ suggestion = "The site may be blocking automation. Try with a different URL.";
1543
+ }
1544
+ else if (errorMessage.includes("chromium") || errorMessage.includes("browser")) {
1545
+ errorType = "browser";
1546
+ suggestion = "Browser automation error. The server may need to restart.";
1547
+ }
1548
+ // Log the full error for debugging
1549
+ console.error(`[empathy_audit] Error: ${errorMessage}`);
1550
+ if (errorStack) {
1551
+ console.error(`[empathy_audit] Stack: ${errorStack}`);
1552
+ }
1553
+ // Return a structured error response
1554
+ return {
1555
+ content: [
1556
+ {
1557
+ type: "text",
1558
+ text: JSON.stringify({
1559
+ error: true,
1560
+ errorType,
1561
+ message: errorMessage,
1562
+ suggestion,
1563
+ url,
1564
+ goal,
1565
+ disabilities: disabilities || "all",
1566
+ }, null, 2),
1567
+ },
1568
+ ],
1569
+ isError: true,
1570
+ };
1571
+ }
1572
+ });
1573
+ }
1319
1574
  //# sourceMappingURL=audit-tools.js.map