@rgaaudit/mcp-server 0.2.0 → 0.3.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.
package/dist/index.js CHANGED
@@ -19,40 +19,127 @@
19
19
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
20
20
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
21
21
  import { z } from 'zod';
22
+ import { isLocalUrl, scanLocal, fetchRgaaMappings } from './local-scanner.js';
22
23
  const API_URL = process.env.RGAA_API_URL || 'https://app.rgaaudit.fr';
23
24
  const API_KEY = process.env.RGAA_API_KEY || '';
24
25
  const server = new McpServer({
25
26
  name: 'rgaaudit',
26
- version: '0.2.0',
27
+ version: '0.3.0',
27
28
  });
29
+ // ── Cache d'audit (TTL 10 min, évite double consommation de quota) ──
30
+ const CACHE_TTL_MS = 10 * 60 * 1000;
31
+ const auditCache = new Map();
28
32
  /**
29
- * Appelle POST /api/ci/scan
33
+ * Nettoie les entrées expirées du cache
34
+ */
35
+ function cleanCache() {
36
+ const now = Date.now();
37
+ for (const [key, entry] of auditCache) {
38
+ if (now - entry.timestamp > CACHE_TTL_MS)
39
+ auditCache.delete(key);
40
+ }
41
+ }
42
+ /**
43
+ * Scan local pour URLs localhost — utilise puppeteer-core + axe-core.
44
+ * Enrichit les résultats avec le mapping RGAA via /api/ci/map si l'API est disponible.
45
+ *
46
+ * @param url - URL locale à scanner
47
+ * @param options - details pour inclure les nœuds HTML
48
+ */
49
+ async function callLocalScan(url, options) {
50
+ const localResult = await scanLocal(url);
51
+ // Enrichir avec le mapping RGAA si API disponible
52
+ const ruleIds = localResult.violations.map(v => v.ruleId);
53
+ const mapping = API_KEY ? await fetchRgaaMappings(ruleIds, API_URL, API_KEY) : null;
54
+ const violations = localResult.violations;
55
+ const score = violations.length === 0 ? 100 : Math.max(0, Math.round(100 - (violations.length * 3)));
56
+ const conformity = score >= 100 ? 'conforme' : score >= 50 ? 'partiellement_conforme' : 'non_conforme';
57
+ const threshold = options?.threshold ?? 0;
58
+ const result = {
59
+ url,
60
+ score,
61
+ conformity,
62
+ violations: violations.length,
63
+ pages: 1,
64
+ pass: threshold > 0 ? score >= threshold : true,
65
+ threshold: threshold > 0 ? threshold : null,
66
+ auditId: `local-${Date.now()}`,
67
+ auditUrl: url,
68
+ coverage: mapping?.coverage,
69
+ };
70
+ if (options?.details) {
71
+ result.violationDetails = violations.map(v => {
72
+ const m = mapping?.mappings[v.ruleId];
73
+ return {
74
+ ruleId: v.ruleId,
75
+ criteria: m?.criteria || null,
76
+ criteriaTitle: m?.title || null,
77
+ theme: m?.theme || null,
78
+ description: m?.help || v.description,
79
+ impact: m?.impact || v.impact,
80
+ nodesCount: v.nodesCount,
81
+ zone: null,
82
+ suggestion: m?.help || v.help,
83
+ nodes: v.nodes,
84
+ };
85
+ });
86
+ }
87
+ return result;
88
+ }
89
+ /**
90
+ * Appelle POST /api/ci/scan (distant) ou scanLocal (localhost).
91
+ * Cache intelligent : réutilise l'auditId si un audit récent existe.
92
+ *
93
+ * @param url - URL à auditer
94
+ * @param options - details, threshold, auditId (pour réutiliser un audit existant)
30
95
  */
31
96
  async function callScan(url, options) {
32
- if (!API_KEY) {
33
- throw new Error('RGAA_API_KEY non configurée.\n\n' +
34
- '1. Créez un compte sur https://app.rgaaudit.fr\n' +
35
- '2. Allez dans Paramètres > API\n' +
36
- '3. Générez une clé API\n' +
37
- '4. Configurez : RGAA_API_KEY=rga_...');
38
- }
39
- const res = await fetch(`${API_URL}/api/ci/scan`, {
40
- method: 'POST',
41
- headers: {
42
- 'Content-Type': 'application/json',
43
- 'X-API-Key': API_KEY,
44
- },
45
- body: JSON.stringify({
46
- url,
47
- details: options?.details ?? false,
48
- threshold: options?.threshold ?? 0,
49
- }),
50
- });
51
- if (!res.ok) {
52
- const body = await res.json().catch(() => ({}));
53
- throw new Error(`API erreur ${res.status}: ${body.message || res.statusText}`);
54
- }
55
- return res.json();
97
+ cleanCache();
98
+ // Vérifier le cache
99
+ const cacheKey = url.toLowerCase().replace(/\/+$/, '');
100
+ const cached = auditCache.get(cacheKey);
101
+ if (cached && !options?.auditId) {
102
+ // Cache hit — retourner directement si on a déjà les détails ou si on n'en demande pas
103
+ if (!options?.details || cached.result.violationDetails) {
104
+ return cached.result;
105
+ }
106
+ }
107
+ // Route : localhost → scan local, sinon → API distante
108
+ let result;
109
+ if (isLocalUrl(url)) {
110
+ result = await callLocalScan(url, options);
111
+ }
112
+ else {
113
+ if (!API_KEY) {
114
+ throw new Error('RGAA_API_KEY non configurée.\n\n' +
115
+ '1. Créez un compte sur https://app.rgaaudit.fr\n' +
116
+ '2. Allez dans Paramètres > API\n' +
117
+ '3. Générez une clé API\n' +
118
+ '4. Configurez : RGAA_API_KEY=rga_...');
119
+ }
120
+ const auditId = options?.auditId || cached?.result.auditId;
121
+ const res = await fetch(`${API_URL}/api/ci/scan`, {
122
+ method: 'POST',
123
+ headers: {
124
+ 'Content-Type': 'application/json',
125
+ 'X-API-Key': API_KEY,
126
+ },
127
+ body: JSON.stringify({
128
+ url,
129
+ details: options?.details ?? false,
130
+ threshold: options?.threshold ?? 0,
131
+ ...(auditId ? { auditId } : {}),
132
+ }),
133
+ });
134
+ if (!res.ok) {
135
+ const body = await res.json().catch(() => ({}));
136
+ throw new Error(`API erreur ${res.status}: ${body.message || res.statusText}`);
137
+ }
138
+ result = await res.json();
139
+ }
140
+ // Mettre en cache
141
+ auditCache.set(cacheKey, { result, timestamp: Date.now() });
142
+ return result;
56
143
  }
57
144
  // ── Outil 1 : rgaa_audit ──
58
145
  server.tool('rgaa_audit', 'Lancer un audit d\'accessibilité RGAA 4.1 complet sur une URL. Retourne les violations avec critères RGAA, impact, et suggestions de correction.', {
@@ -64,10 +151,20 @@ server.tool('rgaa_audit', 'Lancer un audit d\'accessibilité RGAA 4.1 complet su
64
151
  const critique = violations.filter(v => v.impact === 'critical').length;
65
152
  const serieux = violations.filter(v => v.impact === 'serious').length;
66
153
  const modere = violations.filter(v => v.impact === 'moderate').length;
154
+ const uniqueViolations = result.violationsInSharedZones
155
+ ? result.violations - result.violationsInSharedZones
156
+ : result.violations;
157
+ const sharedInfo = result.violationsInSharedZones
158
+ ? ` (dont ${result.violationsInSharedZones} dans des zones partagées nav/header/footer)`
159
+ : '';
67
160
  let text = `# Audit RGAA 4.1 — ${url}\n\n` +
68
161
  `**Score** : ${result.score}% — ${result.conformity}\n` +
69
- `**${result.violations} non-conformités** sur ${result.pages} pages\n` +
70
- `- ${critique} critique(s), ${serieux} sérieuse(s), ${modere} modérée(s)\n\n`;
162
+ `**${result.violations} non-conformités**${sharedInfo} sur ${result.pages} pages\n` +
163
+ `- ${critique} critique(s), ${serieux} sérieuse(s), ${modere} modérée(s)\n`;
164
+ if (result.coverage) {
165
+ text += `- **Couverture RGAA** : ${result.coverage.percentage}% (${result.coverage.mappedCriteria}/${result.coverage.totalCriteria} critères testés)\n`;
166
+ }
167
+ text += '\n';
71
168
  if (result.patterns && result.patterns.length > 0) {
72
169
  text += `## Patterns de correction (${result.patterns.length})\n\n`;
73
170
  for (const p of result.patterns) {
@@ -81,7 +178,7 @@ server.tool('rgaa_audit', 'Lancer un audit d\'accessibilité RGAA 4.1 complet su
81
178
  text += `### ${i + 1}. ${v.criteriaTitle || v.description}\n`;
82
179
  text += `- **Critère RGAA** : ${v.criteria || 'N/A'}${v.theme ? ` (${v.theme})` : ''}\n`;
83
180
  text += `- **Impact** : ${v.impact}\n`;
84
- text += `- **Éléments** : ${v.nodesCount} nœuds sur ${v.pagesCount} pages\n`;
181
+ text += `- **Éléments** : ${v.nodesCount} nœuds\n`;
85
182
  if (v.zone)
86
183
  text += `- **Zone** : ${v.zone}\n`;
87
184
  if (v.suggestion)
@@ -104,13 +201,29 @@ server.tool('rgaa_score', 'Score de conformité RGAA rapide pour une URL. Retour
104
201
  url: z.string().url().describe('URL du site web'),
105
202
  }, async ({ url }) => {
106
203
  try {
107
- const result = await callScan(url);
108
- const text = `**Score RGAA** : ${result.score}%\n` +
204
+ // Appeler avec details pour inclure un résumé des top violations
205
+ const result = await callScan(url, { details: true });
206
+ const violations = result.violationDetails || [];
207
+ // Top 5 violations triées par impact (critique > sérieux > modéré)
208
+ const impactOrder = { critical: 0, critique: 0, serious: 1, serieux: 1, moderate: 2, modere: 2, minor: 3, mineur: 3 };
209
+ const top5 = [...violations]
210
+ .sort((a, b) => (impactOrder[a.impact] ?? 9) - (impactOrder[b.impact] ?? 9))
211
+ .slice(0, 5);
212
+ let text = `**Score RGAA** : ${result.score}%\n` +
109
213
  `**Conformité** : ${result.conformity}\n` +
110
214
  `**Violations** : ${result.violations}\n` +
111
- `**Pages analysées** : ${result.pages}\n` +
112
- `**URL** : ${url}\n\n` +
113
- `🔗 Détails : ${result.auditUrl}`;
215
+ `**Pages analysées** : ${result.pages}\n`;
216
+ if (result.coverage) {
217
+ text += `**Couverture** : ${result.coverage.percentage}% des critères RGAA testés\n`;
218
+ }
219
+ text += `**URL** : ${url}\n`;
220
+ if (top5.length > 0) {
221
+ text += `\n**Top violations** :\n`;
222
+ for (const v of top5) {
223
+ text += `- [${v.impact}] ${v.criteria || ''} ${v.criteriaTitle || v.description} (${v.nodesCount} nœuds)\n`;
224
+ }
225
+ }
226
+ text += `\n🔗 Détails : ${result.auditUrl}`;
114
227
  return { content: [{ type: 'text', text }] };
115
228
  }
116
229
  catch (error) {
@@ -130,7 +243,7 @@ server.tool('rgaa_check', 'Vérifier un critère RGAA spécifique sur une URL. P
130
243
  }
131
244
  const text = `❌ Critère RGAA ${criteria} : **${matching.length} violation(s)** sur ${url}\n\n` +
132
245
  matching.map(v => `- **${v.criteriaTitle || v.description}** (${v.impact})\n` +
133
- ` ${v.nodesCount} nœuds sur ${v.pagesCount} pages\n` +
246
+ ` ${v.nodesCount} nœuds\n` +
134
247
  (v.suggestion ? ` 💡 ${v.suggestion}\n` : '')).join('\n') +
135
248
  `\n🔗 Détails : ${result.auditUrl}`;
136
249
  return { content: [{ type: 'text', text }] };
@@ -171,7 +284,275 @@ server.tool('rgaa_explain', 'Explique un critère RGAA 4.1 avec sa description,
171
284
  (info.exemple ? `## Exemple\n${info.exemple}\n` : '');
172
285
  return { content: [{ type: 'text', text }] };
173
286
  });
174
- // ── Outil 5 : rgaa_report ──
287
+ // ── Outil 5 : rgaa_fix (local, pas d'API) ──
288
+ /**
289
+ * Base de corrections pour les règles axe-core / RGAA les plus courantes.
290
+ * Chaque entrée contient des exemples de code avant/après pour HTML, Vue et React.
291
+ */
292
+ const RGAA_FIXES = {
293
+ 'image-alt': {
294
+ rule: 'image-alt',
295
+ criteria: '1.1',
296
+ description: 'Image sans alternative textuelle',
297
+ fixes: {
298
+ html: '```html\n<!-- Avant -->\n<img src="photo.jpg">\n\n<!-- Après (informative) -->\n<img src="photo.jpg" alt="Description de l\'image">\n\n<!-- Après (décorative) -->\n<img src="decoration.svg" alt="" role="presentation">\n```',
299
+ vue: '```vue\n<!-- Avant -->\n<img :src="imageUrl" />\n\n<!-- Après -->\n<img :src="imageUrl" :alt="imageAlt" />\n\n<!-- Décorative -->\n<img :src="bgUrl" alt="" role="presentation" />\n```',
300
+ react: '```tsx\n// Avant\n<img src={imageUrl} />\n\n// Après\n<img src={imageUrl} alt="Description de l\'image" />\n\n// Décorative\n<img src={bgUrl} alt="" role="presentation" />\n```',
301
+ },
302
+ },
303
+ 'color-contrast': {
304
+ rule: 'color-contrast',
305
+ criteria: '3.3',
306
+ description: 'Contraste insuffisant entre texte et fond',
307
+ fixes: {
308
+ html: '```css\n/* Avant — ratio 2.1:1 */\ncolor: #999; background: #fff;\n\n/* Après — ratio 7.4:1 */\ncolor: #333; background: #fff;\n\n/* Vérifier : https://webaim.org/resources/contrastchecker/ */\n/* Minimum : 4.5:1 (texte normal), 3:1 (texte ≥18pt ou 14pt gras) */\n```',
309
+ vue: '```vue\n<!-- Utiliser des tokens sémantiques au lieu de couleurs hardcodées -->\n<p class="text-foreground">Texte principal</p>\n<p class="text-muted-foreground">Texte secondaire</p>\n\n<!-- Vérifier dans tailwind.config.ts que les tokens respectent le ratio 4.5:1 -->\n```',
310
+ react: '```tsx\n// Utiliser des tokens CSS au lieu de couleurs inline\n<p className="text-foreground">Texte principal</p>\n<p className="text-muted-foreground">Texte secondaire</p>\n```',
311
+ },
312
+ },
313
+ 'link-name': {
314
+ rule: 'link-name',
315
+ criteria: '6.1',
316
+ description: 'Lien sans intitulé accessible',
317
+ fixes: {
318
+ html: '```html\n<!-- Avant (lien icône sans texte) -->\n<a href="/menu"><svg>...</svg></a>\n\n<!-- Après — aria-label -->\n<a href="/menu" aria-label="Ouvrir le menu"><svg aria-hidden="true">...</svg></a>\n\n<!-- Après — texte sr-only -->\n<a href="/menu"><svg aria-hidden="true">...</svg><span class="sr-only">Ouvrir le menu</span></a>\n\n<!-- Après — SVG title -->\n<a href="/menu"><svg><title>Ouvrir le menu</title>...</svg></a>\n```',
319
+ vue: '```vue\n<!-- Avant -->\n<a :href="url"><Icon name="menu" /></a>\n\n<!-- Après -->\n<a :href="url" aria-label="Ouvrir le menu"><Icon name="menu" aria-hidden="true" /></a>\n\n<!-- Avec composant -->\n<NuxtLink :to="url" aria-label="Ouvrir le menu"><Icon name="menu" aria-hidden="true" /></NuxtLink>\n```',
320
+ react: '```tsx\n// Avant\n<a href={url}><MenuIcon /></a>\n\n// Après\n<a href={url} aria-label="Ouvrir le menu"><MenuIcon aria-hidden /></a>\n```',
321
+ },
322
+ },
323
+ 'label': {
324
+ rule: 'label',
325
+ criteria: '11.1',
326
+ description: 'Champ de formulaire sans étiquette',
327
+ fixes: {
328
+ html: '```html\n<!-- Avant -->\n<input type="email" placeholder="Email">\n\n<!-- Après — label explicite -->\n<label for="email">Adresse email</label>\n<input id="email" type="email" placeholder="ex: nom@domaine.fr">\n\n<!-- Après — aria-label (si label visuel impossible) -->\n<input type="search" aria-label="Rechercher sur le site" placeholder="Rechercher...">\n```',
329
+ vue: '```vue\n<!-- Avant -->\n<Input type="email" placeholder="Email" />\n\n<!-- Après -->\n<div class="space-y-1">\n <Label for="email">Adresse email</Label>\n <Input id="email" type="email" placeholder="ex: nom@domaine.fr" />\n</div>\n```',
330
+ react: '```tsx\n// Avant\n<input type="email" placeholder="Email" />\n\n// Après\n<label htmlFor="email">Adresse email</label>\n<input id="email" type="email" placeholder="ex: nom@domaine.fr" />\n```',
331
+ },
332
+ },
333
+ 'button-name': {
334
+ rule: 'button-name',
335
+ criteria: '11.9',
336
+ description: 'Bouton sans intitulé accessible',
337
+ fixes: {
338
+ html: '```html\n<!-- Avant -->\n<button><svg>...</svg></button>\n\n<!-- Après -->\n<button aria-label="Fermer"><svg aria-hidden="true">...</svg></button>\n\n<!-- Ou avec texte visible -->\n<button><svg aria-hidden="true">...</svg> Fermer</button>\n```',
339
+ vue: '```vue\n<!-- Avant -->\n<Button size="icon"><X /></Button>\n\n<!-- Après -->\n<Button size="icon" aria-label="Fermer"><X aria-hidden="true" /></Button>\n```',
340
+ react: '```tsx\n// Avant\n<button><XIcon /></button>\n\n// Après\n<button aria-label="Fermer"><XIcon aria-hidden /></button>\n```',
341
+ },
342
+ },
343
+ 'html-has-lang': {
344
+ rule: 'html-has-lang',
345
+ criteria: '8.3',
346
+ description: 'Langue de la page non déclarée',
347
+ fixes: {
348
+ html: '```html\n<!-- Avant -->\n<html>\n\n<!-- Après -->\n<html lang="fr">\n```',
349
+ vue: '```typescript\n// nuxt.config.ts\nexport default defineNuxtConfig({\n app: {\n head: {\n htmlAttrs: { lang: \'fr\' },\n },\n },\n})\n```',
350
+ react: '```tsx\n// Avant\n<html>\n\n// Après\n<html lang="fr">\n\n// Next.js — layout.tsx\nexport default function RootLayout({ children }) {\n return <html lang="fr">{children}</html>\n}\n```',
351
+ },
352
+ },
353
+ 'bypass': {
354
+ rule: 'bypass',
355
+ criteria: '12.7',
356
+ description: 'Pas de lien d\'évitement vers le contenu principal',
357
+ fixes: {
358
+ html: '```html\n<!-- Ajouter en tout premier dans <body> -->\n<a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:bg-background focus:p-2 focus:rounded">\n Aller au contenu principal\n</a>\n\n<!-- Ajouter id sur le contenu principal -->\n<main id="main-content">...</main>\n```',
359
+ vue: '```vue\n<!-- layouts/default.vue ou app.vue -->\n<template>\n <a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:bg-background focus:p-2 focus:rounded">\n Aller au contenu principal\n </a>\n <header>...</header>\n <main id="main-content"><slot /></main>\n</template>\n```',
360
+ react: '```tsx\n// Layout.tsx\nexport default function Layout({ children }) {\n return (\n <>\n <a href="#main-content" className="sr-only focus:not-sr-only ...">\n Aller au contenu principal\n </a>\n <Header />\n <main id="main-content">{children}</main>\n </>\n )\n}\n```',
361
+ },
362
+ },
363
+ 'landmark-one-main': {
364
+ rule: 'landmark-one-main',
365
+ criteria: '12.9',
366
+ description: 'Page sans élément <main>',
367
+ fixes: {
368
+ html: '```html\n<!-- Avant -->\n<body>\n <div class="content">...</div>\n</body>\n\n<!-- Après -->\n<body>\n <header>...</header>\n <nav>...</nav>\n <main>...</main>\n <footer>...</footer>\n</body>\n```',
369
+ vue: '```vue\n<!-- layouts/default.vue -->\n<template>\n <header><AppNav /></header>\n <main><slot /></main>\n <footer><AppFooter /></footer>\n</template>\n```',
370
+ react: '```tsx\n// Layout.tsx\nexport default function Layout({ children }) {\n return (\n <>\n <header><Nav /></header>\n <main>{children}</main>\n <footer><Footer /></footer>\n </>\n )\n}\n```',
371
+ },
372
+ },
373
+ 'link-in-text-block': {
374
+ rule: 'link-in-text-block',
375
+ criteria: '10.6',
376
+ description: 'Liens non distinguables du texte environnant',
377
+ fixes: {
378
+ html: '```css\n/* Ajouter un soulignement ou un indicateur non-couleur */\na { text-decoration: underline; }\na:hover { text-decoration: none; border-bottom: 2px solid; }\n```',
379
+ vue: '```vue\n<!-- Tailwind -->\n<a href="/page" class="underline hover:no-underline hover:border-b-2 hover:border-current">\n Lien dans un paragraphe\n</a>\n```',
380
+ react: '```tsx\n<a href="/page" className="underline hover:no-underline hover:border-b-2">\n Lien dans un paragraphe\n</a>\n```',
381
+ },
382
+ },
383
+ 'target-blank': {
384
+ rule: 'custom-new-window-warning',
385
+ criteria: '13.2',
386
+ description: 'Lien target="_blank" sans avertissement',
387
+ fixes: {
388
+ html: '```html\n<!-- Avant -->\n<a href="https://example.com" target="_blank">Site externe</a>\n\n<!-- Après — texte sr-only -->\n<a href="https://example.com" target="_blank" rel="noopener">\n Site externe <span class="sr-only">(nouvelle fenêtre)</span>\n</a>\n\n<!-- Après — aria-label -->\n<a href="https://example.com" target="_blank" rel="noopener"\n aria-label="Site externe (nouvelle fenêtre)">\n Site externe\n</a>\n```',
389
+ vue: '```vue\n<!-- Composant réutilisable -->\n<template>\n <a :href="href" target="_blank" rel="noopener">\n <slot />\n <ExternalLinkIcon class="inline h-3 w-3 ml-1" aria-hidden="true" />\n <span class="sr-only">(nouvelle fenêtre)</span>\n </a>\n</template>\n```',
390
+ react: '```tsx\n// Composant réutilisable\nfunction ExternalLink({ href, children }) {\n return (\n <a href={href} target="_blank" rel="noopener">\n {children}\n <ExternalLinkIcon className="inline h-3 w-3 ml-1" aria-hidden />\n <span className="sr-only">(nouvelle fenêtre)</span>\n </a>\n )\n}\n```',
391
+ },
392
+ },
393
+ };
394
+ server.tool('rgaa_fix', 'Retourne le code correctif pour une violation RGAA. Fournit des exemples avant/après en HTML, Vue et React.', {
395
+ ruleId: z.string().describe('ID de la règle axe-core (ex: image-alt, color-contrast, link-name, label, button-name, bypass, target-blank)'),
396
+ framework: z.enum(['html', 'vue', 'react']).default('html').describe('Framework cible pour les exemples de code'),
397
+ }, async ({ ruleId, framework }) => {
398
+ const fix = RGAA_FIXES[ruleId];
399
+ if (!fix) {
400
+ const available = Object.keys(RGAA_FIXES).join(', ');
401
+ return { content: [{ type: 'text', text: `Règle "${ruleId}" non trouvée dans la base de corrections.\n\nRègles disponibles : ${available}\n\nUtilisez \`rgaa_explain\` pour comprendre le critère RGAA associé.` }] };
402
+ }
403
+ const code = fix.fixes[framework] || fix.fixes['html'];
404
+ const text = `# Correction — ${fix.rule} (RGAA ${fix.criteria})\n\n` +
405
+ `**Problème** : ${fix.description}\n\n` +
406
+ `## Code correctif (${framework})\n\n${code}\n\n` +
407
+ (framework !== 'html' && fix.fixes['html'] ? `## Version HTML de référence\n\n${fix.fixes['html']}\n` : '');
408
+ return { content: [{ type: 'text', text }] };
409
+ });
410
+ // ── Outil 6 : rgaa_remediate ──
411
+ /**
412
+ * Règles pour lesquelles on sait générer un fix automatique.
413
+ * Mappées vers les clés de RGAA_FIXES.
414
+ */
415
+ const FIXABLE_RULES = new Set(Object.keys(RGAA_FIXES));
416
+ /**
417
+ * Ordre de priorité des impacts pour le tri
418
+ */
419
+ const IMPACT_PRIORITY = {
420
+ critical: 0, critique: 0,
421
+ serious: 1, serieux: 1,
422
+ moderate: 2, modere: 2,
423
+ minor: 3, mineur: 3,
424
+ };
425
+ server.tool('rgaa_remediate', 'Plan de remédiation autonome : audite, priorise les violations corrigeables, et retourne un plan d\'action structuré avec le code correctif pour chaque violation. L\'agent AI applique ensuite les fixes via ses outils (file system, browser). Sur localhost = re-audits gratuits. Sur site distant = chaque re-audit consomme le quota.', {
426
+ url: z.string().url().describe('URL du site à corriger'),
427
+ framework: z.enum(['html', 'vue', 'react']).default('html').describe('Framework cible pour le code correctif'),
428
+ targetScore: z.number().min(0).max(100).default(80).describe('Score cible à atteindre (défaut: 80%)'),
429
+ maxFixes: z.number().min(1).max(30).default(10).describe('Nombre max de violations à traiter (défaut: 10)'),
430
+ }, async ({ url, framework, targetScore, maxFixes }) => {
431
+ try {
432
+ const result = await callScan(url, { details: true });
433
+ const violations = result.violationDetails || [];
434
+ const isLocal = isLocalUrl(url);
435
+ if (violations.length === 0) {
436
+ return {
437
+ content: [{
438
+ type: 'text',
439
+ text: `# Remédiation RGAA — ${url}\n\n` +
440
+ `**Score actuel** : ${result.score}%\n` +
441
+ `**Aucune violation détectée.** Le site est conforme aux critères testés.\n\n` +
442
+ (result.coverage ? `Note : ${result.coverage.percentage}% des critères RGAA sont testés automatiquement. Les ${100 - result.coverage.percentage}% restants nécessitent un audit humain.` : ''),
443
+ }],
444
+ };
445
+ }
446
+ if (result.score >= targetScore) {
447
+ return {
448
+ content: [{
449
+ type: 'text',
450
+ text: `# Remédiation RGAA — ${url}\n\n` +
451
+ `**Score actuel** : ${result.score}% (cible : ${targetScore}%)\n` +
452
+ `**Score cible déjà atteint.** ${violations.length} violation(s) restante(s) de priorité basse.\n\n` +
453
+ `Pour aller plus loin, relancez avec un targetScore plus élevé.`,
454
+ }],
455
+ };
456
+ }
457
+ // Séparer violations corrigeables (on a un fix) vs manuelles
458
+ const fixable = [];
459
+ const manual = [];
460
+ for (const v of violations) {
461
+ if (FIXABLE_RULES.has(v.ruleId)) {
462
+ fixable.push(v);
463
+ }
464
+ else {
465
+ manual.push(v);
466
+ }
467
+ }
468
+ // Trier par impact (critique d'abord) puis par nombre de nœuds (plus d'impact)
469
+ fixable.sort((a, b) => {
470
+ const impactDiff = (IMPACT_PRIORITY[a.impact] ?? 9) - (IMPACT_PRIORITY[b.impact] ?? 9);
471
+ if (impactDiff !== 0)
472
+ return impactDiff;
473
+ return b.nodesCount - a.nodesCount;
474
+ });
475
+ // Limiter au max demandé
476
+ const toFix = fixable.slice(0, maxFixes);
477
+ // Construire le plan de remédiation
478
+ let text = `# Plan de remédiation RGAA — ${url}\n\n`;
479
+ text += `| Métrique | Valeur |\n|----------|--------|\n`;
480
+ text += `| Score actuel | ${result.score}% |\n`;
481
+ text += `| Score cible | ${targetScore}% |\n`;
482
+ text += `| Violations totales | ${violations.length} |\n`;
483
+ text += `| Corrigeables auto | ${fixable.length} |\n`;
484
+ text += `| Manuelles | ${manual.length} |\n`;
485
+ text += `| Fixes dans ce plan | ${toFix.length} |\n`;
486
+ if (isLocal) {
487
+ text += `| Mode | **Localhost** (re-audits gratuits) |\n`;
488
+ }
489
+ else {
490
+ text += `| Mode | **Distant** (re-audit = 1 quota) |\n`;
491
+ }
492
+ text += '\n';
493
+ // Score estimé après corrections
494
+ const estimatedGain = Math.min(30, toFix.length * 3);
495
+ const estimatedScore = Math.min(100, result.score + estimatedGain);
496
+ text += `**Score estimé après corrections** : ~${estimatedScore}% (+${estimatedGain} points)\n\n`;
497
+ text += `---\n\n## Corrections à appliquer (${toFix.length})\n\n`;
498
+ text += `Appliquez chaque fix dans l'ordre. Après le dernier fix, lancez \`rgaa_score\` pour mesurer l'amélioration.\n\n`;
499
+ for (const [i, v] of toFix.entries()) {
500
+ const fix = RGAA_FIXES[v.ruleId];
501
+ const code = fix.fixes[framework] || fix.fixes['html'];
502
+ text += `### Fix ${i + 1}/${toFix.length} — ${v.ruleId} (RGAA ${v.criteria || '?'})\n\n`;
503
+ text += `- **Problème** : ${fix.description}\n`;
504
+ text += `- **Impact** : ${v.impact}\n`;
505
+ text += `- **Éléments** : ${v.nodesCount} nœuds affectés\n`;
506
+ if (v.zone)
507
+ text += `- **Zone** : ${v.zone}\n`;
508
+ // Exemples de nœuds à corriger
509
+ if (v.nodes.length > 0) {
510
+ text += `- **Éléments à corriger** :\n`;
511
+ for (const n of v.nodes.slice(0, 3)) {
512
+ text += ` - \`${n.target}\`\n`;
513
+ }
514
+ if (v.nodes.length > 3)
515
+ text += ` - ... et ${v.nodes.length - 3} autres\n`;
516
+ }
517
+ text += `\n**Code correctif (${framework})** :\n\n${code}\n\n`;
518
+ if (v.suggestion && v.suggestion !== fix.description) {
519
+ text += `**Suggestion** : ${v.suggestion}\n\n`;
520
+ }
521
+ text += `---\n\n`;
522
+ }
523
+ // Violations manuelles (pas de fix automatique)
524
+ if (manual.length > 0) {
525
+ text += `## Violations manuelles (${manual.length})\n\n`;
526
+ text += `Ces violations n'ont pas de fix automatique. Elles nécessitent une analyse humaine :\n\n`;
527
+ const manualSorted = [...manual].sort((a, b) => (IMPACT_PRIORITY[a.impact] ?? 9) - (IMPACT_PRIORITY[b.impact] ?? 9));
528
+ for (const v of manualSorted.slice(0, 10)) {
529
+ text += `- **[${v.impact}]** ${v.criteria || ''} ${v.criteriaTitle || v.description} (${v.nodesCount} nœuds)\n`;
530
+ }
531
+ if (manual.length > 10)
532
+ text += `- ... et ${manual.length - 10} autres\n`;
533
+ text += '\n';
534
+ }
535
+ // Instructions pour l'agent
536
+ text += `## Instructions pour l'agent\n\n`;
537
+ text += `1. Pour chaque fix ci-dessus, identifiez le fichier source concerné\n`;
538
+ text += `2. Appliquez le code correctif adapté au composant\n`;
539
+ text += `3. Après toutes les corrections, exécutez :\n`;
540
+ text += ` - \`rgaa_score("${url}")\` pour mesurer l'amélioration\n`;
541
+ if (isLocal) {
542
+ text += ` - Re-audits **gratuits** (scan local, pas de quota)\n`;
543
+ }
544
+ else {
545
+ text += ` - Chaque re-audit **consomme 1 crédit** du quota utilisateur\n`;
546
+ }
547
+ text += `4. Si le score < ${targetScore}%, relancez \`rgaa_remediate\` pour le cycle suivant\n`;
548
+ text += `5. Vérifiez visuellement avec \`browser_screenshot\` que le rendu n'est pas cassé\n`;
549
+ return { content: [{ type: 'text', text }] };
550
+ }
551
+ catch (error) {
552
+ return { content: [{ type: 'text', text: `Erreur : ${error instanceof Error ? error.message : String(error)}` }], isError: true };
553
+ }
554
+ });
555
+ // ── Outil 7 : rgaa_report ──
175
556
  server.tool('rgaa_report', 'Génère un rapport d\'audit RGAA structuré en JSON pour intégration CI/CD ou export.', {
176
557
  url: z.string().url().describe('URL du site web'),
177
558
  threshold: z.number().min(0).max(100).default(0).describe('Score minimum pour pass/fail (0 = pas de seuil)'),
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Scanner local axe-core — Mode localhost
3
+ *
4
+ * Utilise puppeteer-core (Chrome existant) + @axe-core/puppeteer
5
+ * pour auditer des URLs localhost sans passer par l'API distante.
6
+ *
7
+ * Le mapping RGAA est ensuite récupéré via POST /api/ci/map.
8
+ */
9
+ /** Résultat brut d'un scan local axe-core */
10
+ export interface LocalScanResult {
11
+ url: string;
12
+ violations: LocalViolation[];
13
+ passes: number;
14
+ incomplete: number;
15
+ timestamp: string;
16
+ }
17
+ export interface LocalViolation {
18
+ ruleId: string;
19
+ impact: string;
20
+ description: string;
21
+ help: string;
22
+ helpUrl: string;
23
+ nodesCount: number;
24
+ nodes: Array<{
25
+ html: string;
26
+ target: string;
27
+ }>;
28
+ }
29
+ /** Mapping RGAA retourné par /api/ci/map */
30
+ export interface RgaaMapping {
31
+ criteria: string;
32
+ title: string;
33
+ theme: string | null;
34
+ impact: string | null;
35
+ help: string | null;
36
+ level: string | null;
37
+ example: string | null;
38
+ }
39
+ export interface MapResponse {
40
+ mappings: Record<string, RgaaMapping>;
41
+ coverage: {
42
+ mappedCriteria: number;
43
+ totalCriteria: number;
44
+ percentage: number;
45
+ };
46
+ }
47
+ /**
48
+ * Détecte si une URL est locale (localhost, 127.0.0.1, 0.0.0.0)
49
+ */
50
+ export declare function isLocalUrl(url: string): boolean;
51
+ /**
52
+ * Lance un scan axe-core local sur une URL
53
+ *
54
+ * @param url - URL locale à scanner (localhost, 127.0.0.1)
55
+ * @returns Violations axe-core brutes
56
+ */
57
+ export declare function scanLocal(url: string): Promise<LocalScanResult>;
58
+ /**
59
+ * Appelle POST /api/ci/map pour enrichir les violations avec le mapping RGAA
60
+ *
61
+ * @param ruleIds - Liste de ruleIds axe-core
62
+ * @param apiUrl - URL de l'API RGAAudit
63
+ * @param apiKey - Clé API
64
+ * @returns Mappings RGAA ou null si l'API est inaccessible
65
+ */
66
+ export declare function fetchRgaaMappings(ruleIds: string[], apiUrl: string, apiKey: string): Promise<MapResponse | null>;
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Scanner local axe-core — Mode localhost
3
+ *
4
+ * Utilise puppeteer-core (Chrome existant) + @axe-core/puppeteer
5
+ * pour auditer des URLs localhost sans passer par l'API distante.
6
+ *
7
+ * Le mapping RGAA est ensuite récupéré via POST /api/ci/map.
8
+ */
9
+ /**
10
+ * Détecte si une URL est locale (localhost, 127.0.0.1, 0.0.0.0)
11
+ */
12
+ export function isLocalUrl(url) {
13
+ try {
14
+ const parsed = new URL(url);
15
+ return (parsed.hostname === 'localhost' ||
16
+ parsed.hostname === '127.0.0.1' ||
17
+ parsed.hostname === '0.0.0.0' ||
18
+ parsed.hostname === '::1');
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }
24
+ /**
25
+ * Trouve le chemin de Chrome/Chromium installé sur le système
26
+ */
27
+ async function findChromePath() {
28
+ const { existsSync } = await import('fs');
29
+ const { join } = await import('path');
30
+ // Variable d'env explicite (priorité absolue, pas d'exécution shell)
31
+ if (process.env.CHROME_PATH && existsSync(process.env.CHROME_PATH)) {
32
+ return process.env.CHROME_PATH;
33
+ }
34
+ // Chemins connus par OS (pas d'exécution shell, pas d'injection possible)
35
+ const candidates = [];
36
+ // Windows — chemins standards via variables d'env système
37
+ const winDirs = [
38
+ process.env['PROGRAMFILES'],
39
+ process.env['PROGRAMFILES(X86)'],
40
+ process.env['LOCALAPPDATA'],
41
+ ].filter(Boolean);
42
+ for (const dir of winDirs) {
43
+ candidates.push(join(dir, 'Google', 'Chrome', 'Application', 'chrome.exe'));
44
+ candidates.push(join(dir, 'Microsoft', 'Edge', 'Application', 'msedge.exe'));
45
+ }
46
+ // macOS
47
+ candidates.push('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge');
48
+ // Linux
49
+ candidates.push('/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/snap/bin/chromium');
50
+ for (const p of candidates) {
51
+ if (existsSync(p))
52
+ return p;
53
+ }
54
+ return null;
55
+ }
56
+ /**
57
+ * Lance un scan axe-core local sur une URL
58
+ *
59
+ * @param url - URL locale à scanner (localhost, 127.0.0.1)
60
+ * @returns Violations axe-core brutes
61
+ */
62
+ export async function scanLocal(url) {
63
+ // Validation URL : seuls http/https autorisés, longueur limitée
64
+ const parsed = new URL(url);
65
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
66
+ throw new Error('Seuls les protocoles http et https sont autorisés');
67
+ }
68
+ if (url.length > 2048) {
69
+ throw new Error('URL trop longue (max 2048 caractères)');
70
+ }
71
+ const chromePath = await findChromePath();
72
+ if (!chromePath) {
73
+ throw new Error('Chrome/Chromium non trouvé sur ce système.\n\n' +
74
+ 'Le scan localhost nécessite un navigateur Chromium installé.\n' +
75
+ 'Installez Google Chrome ou Chromium, puis réessayez.\n\n' +
76
+ 'Chemins recherchés :\n' +
77
+ '- Windows : Program Files\\Google\\Chrome\\Application\\chrome.exe\n' +
78
+ '- macOS : /Applications/Google Chrome.app\n' +
79
+ '- Linux : /usr/bin/google-chrome ou /usr/bin/chromium');
80
+ }
81
+ // Import dynamique pour ne pas crasher si les deps ne sont pas installées
82
+ const puppeteer = await import('puppeteer-core');
83
+ const { AxePuppeteer } = await import('@axe-core/puppeteer');
84
+ let browser = null;
85
+ try {
86
+ browser = await puppeteer.default.launch({
87
+ executablePath: chromePath,
88
+ headless: true,
89
+ args: [
90
+ '--no-sandbox',
91
+ '--disable-setuid-sandbox',
92
+ '--disable-dev-shm-usage',
93
+ '--disable-gpu',
94
+ '--disable-extensions',
95
+ ],
96
+ });
97
+ const page = await browser.newPage();
98
+ await page.setViewport({ width: 1280, height: 720 });
99
+ // Naviguer avec timeout
100
+ await page.goto(url, { waitUntil: 'networkidle2', timeout: 30_000 });
101
+ // Lancer axe-core
102
+ const results = await new AxePuppeteer(page)
103
+ .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice'])
104
+ .analyze();
105
+ const violations = results.violations.map(v => ({
106
+ ruleId: v.id,
107
+ impact: v.impact || 'moderate',
108
+ description: v.description,
109
+ help: v.help,
110
+ helpUrl: v.helpUrl,
111
+ nodesCount: v.nodes.length,
112
+ nodes: v.nodes.slice(0, 5).map(n => ({
113
+ html: (n.html || '').slice(0, 200),
114
+ target: Array.isArray(n.target) ? n.target.join(' > ') : String(n.target),
115
+ })),
116
+ }));
117
+ return {
118
+ url,
119
+ violations,
120
+ passes: results.passes.length,
121
+ incomplete: results.incomplete.length,
122
+ timestamp: new Date().toISOString(),
123
+ };
124
+ }
125
+ finally {
126
+ if (browser)
127
+ await browser.close().catch(() => { });
128
+ }
129
+ }
130
+ /**
131
+ * Appelle POST /api/ci/map pour enrichir les violations avec le mapping RGAA
132
+ *
133
+ * @param ruleIds - Liste de ruleIds axe-core
134
+ * @param apiUrl - URL de l'API RGAAudit
135
+ * @param apiKey - Clé API
136
+ * @returns Mappings RGAA ou null si l'API est inaccessible
137
+ */
138
+ export async function fetchRgaaMappings(ruleIds, apiUrl, apiKey) {
139
+ try {
140
+ const res = await fetch(`${apiUrl}/api/ci/map`, {
141
+ method: 'POST',
142
+ headers: {
143
+ 'Content-Type': 'application/json',
144
+ 'X-API-Key': apiKey,
145
+ },
146
+ body: JSON.stringify({ ruleIds }),
147
+ });
148
+ if (!res.ok)
149
+ return null;
150
+ return res.json();
151
+ }
152
+ catch {
153
+ // API inaccessible (offline, pas de connexion) → retour null
154
+ return null;
155
+ }
156
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rgaaudit/mcp-server",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "MCP Server RGAA 4.1 — Audit d'accessibilité pour Claude Code, Cursor, Copilot",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -18,7 +18,9 @@
18
18
  "prepublishOnly": "npm run build"
19
19
  },
20
20
  "dependencies": {
21
+ "@axe-core/puppeteer": "^4.11.1",
21
22
  "@modelcontextprotocol/sdk": "^1.12.1",
23
+ "puppeteer-core": "^24.40.0",
22
24
  "zod": "^3.25.3"
23
25
  },
24
26
  "devDependencies": {
@@ -28,7 +30,16 @@
28
30
  "engines": {
29
31
  "node": ">=18.0.0"
30
32
  },
31
- "keywords": ["mcp", "rgaa", "accessibility", "a11y", "wcag", "audit", "france", "rgaa4"],
33
+ "keywords": [
34
+ "mcp",
35
+ "rgaa",
36
+ "accessibility",
37
+ "a11y",
38
+ "wcag",
39
+ "audit",
40
+ "france",
41
+ "rgaa4"
42
+ ],
32
43
  "license": "MIT",
33
44
  "repository": {
34
45
  "type": "git",