@rgaaudit/mcp-server 0.2.1 → 0.3.1
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 +558 -23
- package/package.json +11 -2
package/dist/index.js
CHANGED
|
@@ -23,10 +23,27 @@ const API_URL = process.env.RGAA_API_URL || 'https://app.rgaaudit.fr';
|
|
|
23
23
|
const API_KEY = process.env.RGAA_API_KEY || '';
|
|
24
24
|
const server = new McpServer({
|
|
25
25
|
name: 'rgaaudit',
|
|
26
|
-
version: '0.
|
|
26
|
+
version: '0.3.1',
|
|
27
27
|
});
|
|
28
|
+
// ── Cache d'audit (TTL 10 min, évite double consommation de quota) ──
|
|
29
|
+
const CACHE_TTL_MS = 10 * 60 * 1000;
|
|
30
|
+
const auditCache = new Map();
|
|
28
31
|
/**
|
|
29
|
-
*
|
|
32
|
+
* Nettoie les entrées expirées du cache
|
|
33
|
+
*/
|
|
34
|
+
function cleanCache() {
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
for (const [key, entry] of auditCache) {
|
|
37
|
+
if (now - entry.timestamp > CACHE_TTL_MS)
|
|
38
|
+
auditCache.delete(key);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Appelle POST /api/ci/scan — toujours via l'API (règles custom + patterns + mapping RGAA).
|
|
43
|
+
* Cache intelligent : réutilise l'auditId si un audit récent existe (0 quota).
|
|
44
|
+
*
|
|
45
|
+
* @param url - URL à auditer
|
|
46
|
+
* @param options - details, threshold, auditId (pour réutiliser un audit existant)
|
|
30
47
|
*/
|
|
31
48
|
async function callScan(url, options) {
|
|
32
49
|
if (!API_KEY) {
|
|
@@ -36,6 +53,16 @@ async function callScan(url, options) {
|
|
|
36
53
|
'3. Générez une clé API\n' +
|
|
37
54
|
'4. Configurez : RGAA_API_KEY=rga_...');
|
|
38
55
|
}
|
|
56
|
+
cleanCache();
|
|
57
|
+
// Vérifier le cache : si un audit récent existe, réutiliser l'auditId (0 quota)
|
|
58
|
+
const cacheKey = url.toLowerCase().replace(/\/+$/, '');
|
|
59
|
+
const cached = auditCache.get(cacheKey);
|
|
60
|
+
if (cached && !options?.auditId) {
|
|
61
|
+
if (!options?.details || cached.result.violationDetails) {
|
|
62
|
+
return cached.result;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const auditId = options?.auditId || cached?.result.auditId;
|
|
39
66
|
const res = await fetch(`${API_URL}/api/ci/scan`, {
|
|
40
67
|
method: 'POST',
|
|
41
68
|
headers: {
|
|
@@ -46,12 +73,41 @@ async function callScan(url, options) {
|
|
|
46
73
|
url,
|
|
47
74
|
details: options?.details ?? false,
|
|
48
75
|
threshold: options?.threshold ?? 0,
|
|
76
|
+
...(auditId ? { auditId } : {}),
|
|
49
77
|
}),
|
|
50
78
|
});
|
|
51
79
|
if (!res.ok) {
|
|
52
80
|
const body = await res.json().catch(() => ({}));
|
|
53
81
|
throw new Error(`API erreur ${res.status}: ${body.message || res.statusText}`);
|
|
54
82
|
}
|
|
83
|
+
const result = await res.json();
|
|
84
|
+
// Mettre en cache
|
|
85
|
+
auditCache.set(cacheKey, { result, timestamp: Date.now() });
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Appelle POST /api/ci/recheck — re-scanne les mêmes pages d'un audit existant.
|
|
90
|
+
* Smart re-audit : pas de discovery, pas de crawl. Consomme 1 token.
|
|
91
|
+
* Retourne le delta (score, violations résolues/nouvelles).
|
|
92
|
+
*
|
|
93
|
+
* @param auditId - ID de l'audit à re-checker
|
|
94
|
+
*/
|
|
95
|
+
async function callRecheck(auditId) {
|
|
96
|
+
if (!API_KEY) {
|
|
97
|
+
throw new Error('RGAA_API_KEY non configurée.');
|
|
98
|
+
}
|
|
99
|
+
const res = await fetch(`${API_URL}/api/ci/recheck`, {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
headers: {
|
|
102
|
+
'Content-Type': 'application/json',
|
|
103
|
+
'X-API-Key': API_KEY,
|
|
104
|
+
},
|
|
105
|
+
body: JSON.stringify({ auditId }),
|
|
106
|
+
});
|
|
107
|
+
if (!res.ok) {
|
|
108
|
+
const body = await res.json().catch(() => ({}));
|
|
109
|
+
throw new Error(`API erreur ${res.status}: ${body.message || res.statusText}`);
|
|
110
|
+
}
|
|
55
111
|
return res.json();
|
|
56
112
|
}
|
|
57
113
|
// ── Outil 1 : rgaa_audit ──
|
|
@@ -64,10 +120,20 @@ server.tool('rgaa_audit', 'Lancer un audit d\'accessibilité RGAA 4.1 complet su
|
|
|
64
120
|
const critique = violations.filter(v => v.impact === 'critical').length;
|
|
65
121
|
const serieux = violations.filter(v => v.impact === 'serious').length;
|
|
66
122
|
const modere = violations.filter(v => v.impact === 'moderate').length;
|
|
123
|
+
const uniqueViolations = result.violationsInSharedZones
|
|
124
|
+
? result.violations - result.violationsInSharedZones
|
|
125
|
+
: result.violations;
|
|
126
|
+
const sharedInfo = result.violationsInSharedZones
|
|
127
|
+
? ` (dont ${result.violationsInSharedZones} dans des zones partagées nav/header/footer)`
|
|
128
|
+
: '';
|
|
67
129
|
let text = `# Audit RGAA 4.1 — ${url}\n\n` +
|
|
68
130
|
`**Score** : ${result.score}% — ${result.conformity}\n` +
|
|
69
|
-
`**${result.violations} non-conformités
|
|
70
|
-
`- ${critique} critique(s), ${serieux} sérieuse(s), ${modere} modérée(s)\n
|
|
131
|
+
`**${result.violations} non-conformités**${sharedInfo} sur ${result.pages} pages\n` +
|
|
132
|
+
`- ${critique} critique(s), ${serieux} sérieuse(s), ${modere} modérée(s)\n`;
|
|
133
|
+
if (result.coverage) {
|
|
134
|
+
text += `- **Couverture RGAA** : ${result.coverage.percentage}% (${result.coverage.mappedCriteria}/${result.coverage.totalCriteria} critères testés)\n`;
|
|
135
|
+
}
|
|
136
|
+
text += '\n';
|
|
71
137
|
if (result.patterns && result.patterns.length > 0) {
|
|
72
138
|
text += `## Patterns de correction (${result.patterns.length})\n\n`;
|
|
73
139
|
for (const p of result.patterns) {
|
|
@@ -76,20 +142,52 @@ server.tool('rgaa_audit', 'Lancer un audit d\'accessibilité RGAA 4.1 complet su
|
|
|
76
142
|
text += '\n';
|
|
77
143
|
}
|
|
78
144
|
if (violations.length > 0) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
text +=
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
145
|
+
// Regrouper les violations par zone partagée
|
|
146
|
+
const sharedZones = ['header', 'nav', 'footer'];
|
|
147
|
+
const inSharedZone = violations.filter(v => v.zone && sharedZones.includes(v.zone));
|
|
148
|
+
const inContent = violations.filter(v => !v.zone || !sharedZones.includes(v.zone));
|
|
149
|
+
if (inSharedZone.length > 0) {
|
|
150
|
+
text += `## Violations en zones partagées (${inSharedZone.length})\n\n`;
|
|
151
|
+
text += `> Ces violations sont dans des zones répétées sur toutes les pages (nav, header, footer).\n`;
|
|
152
|
+
text += `> **Corrigez le composant layout une seule fois** pour résoudre toutes les pages.\n\n`;
|
|
153
|
+
// Grouper par zone
|
|
154
|
+
const byZone = new Map();
|
|
155
|
+
for (const v of inSharedZone) {
|
|
156
|
+
const zone = v.zone;
|
|
157
|
+
if (!byZone.has(zone))
|
|
158
|
+
byZone.set(zone, []);
|
|
159
|
+
byZone.get(zone).push(v);
|
|
160
|
+
}
|
|
161
|
+
for (const [zone, zoneViolations] of byZone) {
|
|
162
|
+
text += `### Zone : ${zone}\n`;
|
|
163
|
+
text += `Fichier probable : \`layouts/*.vue\` ou \`components/${zone === 'nav' ? 'AppNav' : zone === 'footer' ? 'AppFooter' : 'AppHeader'}.vue\`\n\n`;
|
|
164
|
+
for (const v of zoneViolations) {
|
|
165
|
+
text += `- **${v.criteriaTitle || v.description}** (${v.impact}, ${v.nodesCount} nœuds)`;
|
|
166
|
+
if (v.nodes.length > 0)
|
|
167
|
+
text += ` — \`${v.nodes[0].target}\``;
|
|
168
|
+
text += '\n';
|
|
169
|
+
if (v.suggestion)
|
|
170
|
+
text += ` ${v.suggestion}\n`;
|
|
171
|
+
}
|
|
172
|
+
text += '\n';
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (inContent.length > 0) {
|
|
176
|
+
text += `## Violations de contenu (${inContent.length})\n\n`;
|
|
177
|
+
for (const [i, v] of inContent.entries()) {
|
|
178
|
+
text += `### ${i + 1}. ${v.criteriaTitle || v.description}\n`;
|
|
179
|
+
text += `- **Critère RGAA** : ${v.criteria || 'N/A'}${v.theme ? ` (${v.theme})` : ''}\n`;
|
|
180
|
+
text += `- **Impact** : ${v.impact}\n`;
|
|
181
|
+
text += `- **Éléments** : ${v.nodesCount} nœuds\n`;
|
|
182
|
+
if (v.zone)
|
|
183
|
+
text += `- **Zone** : ${v.zone}\n`;
|
|
184
|
+
if (v.suggestion)
|
|
185
|
+
text += `- **Suggestion** : ${v.suggestion}\n`;
|
|
186
|
+
if (v.nodes.length > 0) {
|
|
187
|
+
text += `- **Exemple** : \`${v.nodes[0].target}\`\n`;
|
|
188
|
+
}
|
|
189
|
+
text += '\n';
|
|
91
190
|
}
|
|
92
|
-
text += '\n';
|
|
93
191
|
}
|
|
94
192
|
}
|
|
95
193
|
text += `\n🔗 Rapport complet : ${result.auditUrl}`;
|
|
@@ -104,13 +202,29 @@ server.tool('rgaa_score', 'Score de conformité RGAA rapide pour une URL. Retour
|
|
|
104
202
|
url: z.string().url().describe('URL du site web'),
|
|
105
203
|
}, async ({ url }) => {
|
|
106
204
|
try {
|
|
107
|
-
|
|
108
|
-
const
|
|
205
|
+
// Appeler avec details pour inclure un résumé des top violations
|
|
206
|
+
const result = await callScan(url, { details: true });
|
|
207
|
+
const violations = result.violationDetails || [];
|
|
208
|
+
// Top 5 violations triées par impact (critique > sérieux > modéré)
|
|
209
|
+
const impactOrder = { critical: 0, critique: 0, serious: 1, serieux: 1, moderate: 2, modere: 2, minor: 3, mineur: 3 };
|
|
210
|
+
const top5 = [...violations]
|
|
211
|
+
.sort((a, b) => (impactOrder[a.impact] ?? 9) - (impactOrder[b.impact] ?? 9))
|
|
212
|
+
.slice(0, 5);
|
|
213
|
+
let text = `**Score RGAA** : ${result.score}%\n` +
|
|
109
214
|
`**Conformité** : ${result.conformity}\n` +
|
|
110
215
|
`**Violations** : ${result.violations}\n` +
|
|
111
|
-
`**Pages analysées** : ${result.pages}\n
|
|
112
|
-
|
|
113
|
-
|
|
216
|
+
`**Pages analysées** : ${result.pages}\n`;
|
|
217
|
+
if (result.coverage) {
|
|
218
|
+
text += `**Couverture** : ${result.coverage.percentage}% des critères RGAA testés\n`;
|
|
219
|
+
}
|
|
220
|
+
text += `**URL** : ${url}\n`;
|
|
221
|
+
if (top5.length > 0) {
|
|
222
|
+
text += `\n**Top violations** :\n`;
|
|
223
|
+
for (const v of top5) {
|
|
224
|
+
text += `- [${v.impact}] ${v.criteria || ''} ${v.criteriaTitle || v.description} (${v.nodesCount} nœuds)\n`;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
text += `\n🔗 Détails : ${result.auditUrl}`;
|
|
114
228
|
return { content: [{ type: 'text', text }] };
|
|
115
229
|
}
|
|
116
230
|
catch (error) {
|
|
@@ -171,7 +285,428 @@ server.tool('rgaa_explain', 'Explique un critère RGAA 4.1 avec sa description,
|
|
|
171
285
|
(info.exemple ? `## Exemple\n${info.exemple}\n` : '');
|
|
172
286
|
return { content: [{ type: 'text', text }] };
|
|
173
287
|
});
|
|
174
|
-
// ── Outil 5 :
|
|
288
|
+
// ── Outil 5 : rgaa_fix (local, pas d'API) ──
|
|
289
|
+
/**
|
|
290
|
+
* Base de corrections pour les règles axe-core / RGAA les plus courantes.
|
|
291
|
+
* Chaque entrée contient des exemples de code avant/après pour HTML, Vue et React.
|
|
292
|
+
*/
|
|
293
|
+
const RGAA_FIXES = {
|
|
294
|
+
'image-alt': {
|
|
295
|
+
rule: 'image-alt',
|
|
296
|
+
criteria: '1.1',
|
|
297
|
+
description: 'Image sans alternative textuelle',
|
|
298
|
+
fixes: {
|
|
299
|
+
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```',
|
|
300
|
+
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```',
|
|
301
|
+
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```',
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
'color-contrast': {
|
|
305
|
+
rule: 'color-contrast',
|
|
306
|
+
criteria: '3.3',
|
|
307
|
+
description: 'Contraste insuffisant entre texte et fond',
|
|
308
|
+
fixes: {
|
|
309
|
+
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```',
|
|
310
|
+
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```',
|
|
311
|
+
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```',
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
'link-name': {
|
|
315
|
+
rule: 'link-name',
|
|
316
|
+
criteria: '6.1',
|
|
317
|
+
description: 'Lien sans intitulé accessible',
|
|
318
|
+
fixes: {
|
|
319
|
+
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```',
|
|
320
|
+
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```',
|
|
321
|
+
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```',
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
'label': {
|
|
325
|
+
rule: 'label',
|
|
326
|
+
criteria: '11.1',
|
|
327
|
+
description: 'Champ de formulaire sans étiquette',
|
|
328
|
+
fixes: {
|
|
329
|
+
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```',
|
|
330
|
+
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```',
|
|
331
|
+
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```',
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
'button-name': {
|
|
335
|
+
rule: 'button-name',
|
|
336
|
+
criteria: '11.9',
|
|
337
|
+
description: 'Bouton sans intitulé accessible',
|
|
338
|
+
fixes: {
|
|
339
|
+
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```',
|
|
340
|
+
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```',
|
|
341
|
+
react: '```tsx\n// Avant\n<button><XIcon /></button>\n\n// Après\n<button aria-label="Fermer"><XIcon aria-hidden /></button>\n```',
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
'html-has-lang': {
|
|
345
|
+
rule: 'html-has-lang',
|
|
346
|
+
criteria: '8.3',
|
|
347
|
+
description: 'Langue de la page non déclarée',
|
|
348
|
+
fixes: {
|
|
349
|
+
html: '```html\n<!-- Avant -->\n<html>\n\n<!-- Après -->\n<html lang="fr">\n```',
|
|
350
|
+
vue: '```typescript\n// nuxt.config.ts\nexport default defineNuxtConfig({\n app: {\n head: {\n htmlAttrs: { lang: \'fr\' },\n },\n },\n})\n```',
|
|
351
|
+
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```',
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
'bypass': {
|
|
355
|
+
rule: 'bypass',
|
|
356
|
+
criteria: '12.7',
|
|
357
|
+
description: 'Pas de lien d\'évitement vers le contenu principal',
|
|
358
|
+
fixes: {
|
|
359
|
+
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```',
|
|
360
|
+
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```',
|
|
361
|
+
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```',
|
|
362
|
+
},
|
|
363
|
+
},
|
|
364
|
+
'landmark-one-main': {
|
|
365
|
+
rule: 'landmark-one-main',
|
|
366
|
+
criteria: '12.9',
|
|
367
|
+
description: 'Page sans élément <main>',
|
|
368
|
+
fixes: {
|
|
369
|
+
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```',
|
|
370
|
+
vue: '```vue\n<!-- layouts/default.vue -->\n<template>\n <header><AppNav /></header>\n <main><slot /></main>\n <footer><AppFooter /></footer>\n</template>\n```',
|
|
371
|
+
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```',
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
'link-in-text-block': {
|
|
375
|
+
rule: 'link-in-text-block',
|
|
376
|
+
criteria: '10.6',
|
|
377
|
+
description: 'Liens non distinguables du texte environnant',
|
|
378
|
+
fixes: {
|
|
379
|
+
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```',
|
|
380
|
+
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```',
|
|
381
|
+
react: '```tsx\n<a href="/page" className="underline hover:no-underline hover:border-b-2">\n Lien dans un paragraphe\n</a>\n```',
|
|
382
|
+
},
|
|
383
|
+
},
|
|
384
|
+
'target-blank': {
|
|
385
|
+
rule: 'custom-new-window-warning',
|
|
386
|
+
criteria: '13.2',
|
|
387
|
+
description: 'Lien target="_blank" sans avertissement',
|
|
388
|
+
fixes: {
|
|
389
|
+
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```',
|
|
390
|
+
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```',
|
|
391
|
+
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```',
|
|
392
|
+
},
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
server.tool('rgaa_fix', 'Retourne le code correctif pour une violation RGAA. Fournit des exemples avant/après en HTML, Vue et React.', {
|
|
396
|
+
ruleId: z.string().describe('ID de la règle axe-core (ex: image-alt, color-contrast, link-name, label, button-name, bypass, target-blank)'),
|
|
397
|
+
framework: z.enum(['html', 'vue', 'react']).default('html').describe('Framework cible pour les exemples de code'),
|
|
398
|
+
}, async ({ ruleId, framework }) => {
|
|
399
|
+
const fix = RGAA_FIXES[ruleId];
|
|
400
|
+
if (!fix) {
|
|
401
|
+
const available = Object.keys(RGAA_FIXES).join(', ');
|
|
402
|
+
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é.` }] };
|
|
403
|
+
}
|
|
404
|
+
const code = fix.fixes[framework] || fix.fixes['html'];
|
|
405
|
+
const text = `# Correction — ${fix.rule} (RGAA ${fix.criteria})\n\n` +
|
|
406
|
+
`**Problème** : ${fix.description}\n\n` +
|
|
407
|
+
`## Code correctif (${framework})\n\n${code}\n\n` +
|
|
408
|
+
(framework !== 'html' && fix.fixes['html'] ? `## Version HTML de référence\n\n${fix.fixes['html']}\n` : '');
|
|
409
|
+
return { content: [{ type: 'text', text }] };
|
|
410
|
+
});
|
|
411
|
+
// ── Outil 6 : rgaa_remediate ──
|
|
412
|
+
/**
|
|
413
|
+
* Règles pour lesquelles on sait générer un fix automatique.
|
|
414
|
+
* Mappées vers les clés de RGAA_FIXES.
|
|
415
|
+
*/
|
|
416
|
+
const FIXABLE_RULES = new Set(Object.keys(RGAA_FIXES));
|
|
417
|
+
/**
|
|
418
|
+
* Ordre de priorité des impacts pour le tri
|
|
419
|
+
*/
|
|
420
|
+
const IMPACT_PRIORITY = {
|
|
421
|
+
critical: 0, critique: 0,
|
|
422
|
+
serious: 1, serieux: 1,
|
|
423
|
+
moderate: 2, modere: 2,
|
|
424
|
+
minor: 3, mineur: 3,
|
|
425
|
+
};
|
|
426
|
+
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.', {
|
|
427
|
+
url: z.string().url().describe('URL du site à corriger'),
|
|
428
|
+
framework: z.enum(['html', 'vue', 'react']).default('html').describe('Framework cible pour le code correctif'),
|
|
429
|
+
targetScore: z.number().min(0).max(100).default(80).describe('Score cible à atteindre (défaut: 80%)'),
|
|
430
|
+
maxFixes: z.number().min(1).max(30).default(10).describe('Nombre max de violations à traiter (défaut: 10)'),
|
|
431
|
+
}, async ({ url, framework, targetScore, maxFixes }) => {
|
|
432
|
+
try {
|
|
433
|
+
const result = await callScan(url, { details: true });
|
|
434
|
+
const violations = result.violationDetails || [];
|
|
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
|
+
text += `| Re-check | 1 token (smart re-audit, pas de re-discovery) |\n`;
|
|
487
|
+
text += '\n';
|
|
488
|
+
// Score estimé après corrections
|
|
489
|
+
const estimatedGain = Math.min(30, toFix.length * 3);
|
|
490
|
+
const estimatedScore = Math.min(100, result.score + estimatedGain);
|
|
491
|
+
text += `**Score estimé après corrections** : ~${estimatedScore}% (+${estimatedGain} points)\n\n`;
|
|
492
|
+
text += `---\n\n## Corrections à appliquer (${toFix.length})\n\n`;
|
|
493
|
+
text += `Appliquez chaque fix dans l'ordre. Après le dernier fix, lancez \`rgaa_recheck("${result.auditId}")\` pour mesurer le delta.\n\n`;
|
|
494
|
+
for (const [i, v] of toFix.entries()) {
|
|
495
|
+
const fix = RGAA_FIXES[v.ruleId];
|
|
496
|
+
const code = fix.fixes[framework] || fix.fixes['html'];
|
|
497
|
+
text += `### Fix ${i + 1}/${toFix.length} — ${v.ruleId} (RGAA ${v.criteria || '?'})\n\n`;
|
|
498
|
+
text += `- **Problème** : ${fix.description}\n`;
|
|
499
|
+
text += `- **Impact** : ${v.impact}\n`;
|
|
500
|
+
text += `- **Éléments** : ${v.nodesCount} nœuds affectés\n`;
|
|
501
|
+
if (v.zone && ['header', 'nav', 'footer'].includes(v.zone)) {
|
|
502
|
+
text += `- **Zone partagée** : ${v.zone} → corrigez le composant layout (1 fix = toutes les pages)\n`;
|
|
503
|
+
}
|
|
504
|
+
else if (v.zone) {
|
|
505
|
+
text += `- **Zone** : ${v.zone}\n`;
|
|
506
|
+
}
|
|
507
|
+
// Exemples de nœuds à corriger
|
|
508
|
+
if (v.nodes.length > 0) {
|
|
509
|
+
text += `- **Éléments à corriger** :\n`;
|
|
510
|
+
for (const n of v.nodes.slice(0, 3)) {
|
|
511
|
+
text += ` - \`${n.target}\`\n`;
|
|
512
|
+
}
|
|
513
|
+
if (v.nodes.length > 3)
|
|
514
|
+
text += ` - ... et ${v.nodes.length - 3} autres\n`;
|
|
515
|
+
}
|
|
516
|
+
text += `\n**Code correctif (${framework})** :\n\n${code}\n\n`;
|
|
517
|
+
if (v.suggestion && v.suggestion !== fix.description) {
|
|
518
|
+
text += `**Suggestion** : ${v.suggestion}\n\n`;
|
|
519
|
+
}
|
|
520
|
+
text += `---\n\n`;
|
|
521
|
+
}
|
|
522
|
+
// Violations manuelles (pas de fix automatique)
|
|
523
|
+
if (manual.length > 0) {
|
|
524
|
+
text += `## Violations manuelles (${manual.length})\n\n`;
|
|
525
|
+
text += `Ces violations n'ont pas de fix automatique. Elles nécessitent une analyse humaine :\n\n`;
|
|
526
|
+
const manualSorted = [...manual].sort((a, b) => (IMPACT_PRIORITY[a.impact] ?? 9) - (IMPACT_PRIORITY[b.impact] ?? 9));
|
|
527
|
+
for (const v of manualSorted.slice(0, 10)) {
|
|
528
|
+
text += `- **[${v.impact}]** ${v.criteria || ''} ${v.criteriaTitle || v.description} (${v.nodesCount} nœuds)\n`;
|
|
529
|
+
}
|
|
530
|
+
if (manual.length > 10)
|
|
531
|
+
text += `- ... et ${manual.length - 10} autres\n`;
|
|
532
|
+
text += '\n';
|
|
533
|
+
}
|
|
534
|
+
// Instructions pour l'agent
|
|
535
|
+
text += `## Instructions pour l'agent\n\n`;
|
|
536
|
+
text += `1. Pour chaque fix ci-dessus, identifiez le fichier source concerné\n`;
|
|
537
|
+
text += `2. Appliquez le code correctif adapté au composant\n`;
|
|
538
|
+
text += `3. Après toutes les corrections, exécutez :\n`;
|
|
539
|
+
text += ` - \`rgaa_recheck("${result.auditId}")\` pour mesurer le delta (1 token)\n`;
|
|
540
|
+
text += `4. Si le score < ${targetScore}%, relancez \`rgaa_remediate\` pour le cycle suivant\n`;
|
|
541
|
+
text += `5. Vérifiez visuellement avec \`browser_screenshot\` que le rendu n'est pas cassé\n`;
|
|
542
|
+
return { content: [{ type: 'text', text }] };
|
|
543
|
+
}
|
|
544
|
+
catch (error) {
|
|
545
|
+
return { content: [{ type: 'text', text: `Erreur : ${error instanceof Error ? error.message : String(error)}` }], isError: true };
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
// ── Outil 7 : rgaa_recheck ──
|
|
549
|
+
server.tool('rgaa_recheck', 'Re-check rapide : re-scanne les mêmes pages d\'un audit existant sans redécouvrir le site. Retourne le delta (score avant/après, violations résolues/nouvelles). Consomme 1 token du plan. Utilisez après avoir corrigé des violations pour mesurer l\'amélioration.', {
|
|
550
|
+
auditId: z.string().describe('ID de l\'audit à re-checker (retourné par rgaa_audit ou rgaa_report)'),
|
|
551
|
+
}, async ({ auditId }) => {
|
|
552
|
+
try {
|
|
553
|
+
const result = await callRecheck(auditId);
|
|
554
|
+
const deltaSign = result.score > result.previousScore ? '+' : '';
|
|
555
|
+
const violFixedText = result.violationsDelta > 0
|
|
556
|
+
? `${result.violationsDelta} violation(s) corrigée(s)`
|
|
557
|
+
: result.violationsDelta < 0
|
|
558
|
+
? `${Math.abs(result.violationsDelta)} nouvelle(s) violation(s)`
|
|
559
|
+
: 'Même nombre de violations';
|
|
560
|
+
const text = `# Re-check RGAA — ${result.url}\n\n` +
|
|
561
|
+
`| Métrique | Avant | Après | Delta |\n` +
|
|
562
|
+
`|----------|-------|-------|-------|\n` +
|
|
563
|
+
`| Score | ${result.previousScore}% | ${result.score}% | ${deltaSign}${result.score - result.previousScore} |\n` +
|
|
564
|
+
`| Violations | ${result.previousViolations} | ${result.violations} | ${result.violationsDelta > 0 ? '-' : '+'}${Math.abs(result.violationsDelta)} |\n\n` +
|
|
565
|
+
`**${result.message}**\n\n` +
|
|
566
|
+
`${violFixedText}\n\n` +
|
|
567
|
+
`- Audit précédent : ${result.parentAuditId}\n` +
|
|
568
|
+
`- Nouvel audit : ${result.newAuditId}\n\n` +
|
|
569
|
+
`Pour voir le détail des violations restantes, lancez \`rgaa_audit\` avec l'URL ou consultez l'audit dans l'interface.`;
|
|
570
|
+
return { content: [{ type: 'text', text }] };
|
|
571
|
+
}
|
|
572
|
+
catch (error) {
|
|
573
|
+
return { content: [{ type: 'text', text: `Erreur : ${error instanceof Error ? error.message : String(error)}` }], isError: true };
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
// ── Outil 8 : rgaa_visual_check ──
|
|
577
|
+
/**
|
|
578
|
+
* Checklist de vérification visuelle structurée.
|
|
579
|
+
* Chaque étape dit à l'agent quoi faire avec Browser MCP et quoi vérifier.
|
|
580
|
+
*/
|
|
581
|
+
const VISUAL_CHECKS = [
|
|
582
|
+
{
|
|
583
|
+
id: 'skip-link',
|
|
584
|
+
criteria: '12.7',
|
|
585
|
+
title: 'Lien d\'évitement visible au focus',
|
|
586
|
+
steps: [
|
|
587
|
+
'browser_navigate vers l\'URL',
|
|
588
|
+
'browser_press_key("Tab") — 1 fois',
|
|
589
|
+
'browser_screenshot — capturer l\'état du focus',
|
|
590
|
+
],
|
|
591
|
+
lookFor: 'Un lien "Aller au contenu" doit apparaître en haut de page au premier Tab. S\'il est invisible ou absent, c\'est une violation.',
|
|
592
|
+
},
|
|
593
|
+
{
|
|
594
|
+
id: 'focus-visible',
|
|
595
|
+
criteria: '10.7',
|
|
596
|
+
title: 'Indicateur de focus visible sur les éléments interactifs',
|
|
597
|
+
steps: [
|
|
598
|
+
'browser_press_key("Tab") — 5 fois successives',
|
|
599
|
+
'browser_screenshot à chaque Tab',
|
|
600
|
+
],
|
|
601
|
+
lookFor: 'Chaque élément focusé (lien, bouton, champ) doit avoir un contour/outline visible. Si le focus est invisible (pas de ring, pas de bordure), c\'est une violation.',
|
|
602
|
+
},
|
|
603
|
+
{
|
|
604
|
+
id: 'contrast-visual',
|
|
605
|
+
criteria: '3.3',
|
|
606
|
+
title: 'Contraste visuel — zones potentiellement problématiques',
|
|
607
|
+
steps: [
|
|
608
|
+
'browser_screenshot de la page complète',
|
|
609
|
+
'Examiner le footer, les badges, les textes muted/secondaires',
|
|
610
|
+
],
|
|
611
|
+
lookFor: 'Texte gris clair sur fond blanc, texte clair sur fond coloré, badges/tags peu contrastés. Les zones footer et navigation sont souvent problématiques.',
|
|
612
|
+
},
|
|
613
|
+
{
|
|
614
|
+
id: 'responsive-320',
|
|
615
|
+
criteria: '10.11',
|
|
616
|
+
title: 'Responsive — pas de scroll horizontal à 320px',
|
|
617
|
+
steps: [
|
|
618
|
+
'browser_press_key avec Ctrl+Shift+M (toggle device mode) ou resize la fenêtre à 320px de large',
|
|
619
|
+
'browser_screenshot',
|
|
620
|
+
],
|
|
621
|
+
lookFor: 'Aucun contenu ne doit déborder horizontalement. Pas de barre de scroll horizontal. Le texte doit rester lisible.',
|
|
622
|
+
},
|
|
623
|
+
{
|
|
624
|
+
id: 'heading-hierarchy',
|
|
625
|
+
criteria: '9.1',
|
|
626
|
+
title: 'Hiérarchie de titres logique',
|
|
627
|
+
steps: [
|
|
628
|
+
'browser_snapshot — lire l\'arbre d\'accessibilité',
|
|
629
|
+
'Lister tous les headings (h1-h6) dans l\'ordre',
|
|
630
|
+
],
|
|
631
|
+
lookFor: 'Un seul h1, puis h2, h3 dans l\'ordre. Pas de saut (h1→h4). Pas de h1 manquant. Vérifier que le h1 décrit le contenu principal.',
|
|
632
|
+
},
|
|
633
|
+
{
|
|
634
|
+
id: 'images-alt',
|
|
635
|
+
criteria: '1.1',
|
|
636
|
+
title: 'Images informatives avec alternative textuelle',
|
|
637
|
+
steps: [
|
|
638
|
+
'browser_snapshot — chercher toutes les images dans l\'arbre d\'accessibilité',
|
|
639
|
+
],
|
|
640
|
+
lookFor: 'Chaque image informative doit avoir un alt descriptif. Les images décoratives doivent avoir alt="". Les icônes dans les boutons/liens doivent être aria-hidden avec un texte accessible sur le parent.',
|
|
641
|
+
},
|
|
642
|
+
{
|
|
643
|
+
id: 'form-labels',
|
|
644
|
+
criteria: '11.1',
|
|
645
|
+
title: 'Champs de formulaire avec étiquettes',
|
|
646
|
+
steps: [
|
|
647
|
+
'browser_snapshot — chercher les champs input/select/textarea',
|
|
648
|
+
],
|
|
649
|
+
lookFor: 'Chaque champ doit avoir un label associé (via for/id ou aria-label). Les placeholder seuls ne sont PAS des labels valides.',
|
|
650
|
+
},
|
|
651
|
+
{
|
|
652
|
+
id: 'new-window',
|
|
653
|
+
criteria: '13.2',
|
|
654
|
+
title: 'Liens ouvrant une nouvelle fenêtre',
|
|
655
|
+
steps: [
|
|
656
|
+
'browser_snapshot — chercher les liens avec target="_blank"',
|
|
657
|
+
],
|
|
658
|
+
lookFor: 'Chaque lien target="_blank" doit indiquer "(nouvelle fenêtre)" en texte, sr-only, ou via une icône avec aria-label.',
|
|
659
|
+
},
|
|
660
|
+
{
|
|
661
|
+
id: 'keyboard-nav',
|
|
662
|
+
criteria: '12.8',
|
|
663
|
+
title: 'Navigation clavier complète',
|
|
664
|
+
steps: [
|
|
665
|
+
'browser_press_key("Tab") — naviguer dans toute la page',
|
|
666
|
+
'browser_press_key("Enter") sur les boutons/liens',
|
|
667
|
+
'browser_press_key("Escape") pour fermer les modales',
|
|
668
|
+
],
|
|
669
|
+
lookFor: 'Tous les éléments interactifs doivent être atteignables au clavier. Pas de piège (impossible de sortir d\'un élément). L\'ordre de tabulation doit suivre l\'ordre visuel.',
|
|
670
|
+
},
|
|
671
|
+
{
|
|
672
|
+
id: 'landmarks',
|
|
673
|
+
criteria: '12.9',
|
|
674
|
+
title: 'Zones principales identifiées par des landmarks',
|
|
675
|
+
steps: [
|
|
676
|
+
'browser_snapshot — vérifier la structure de la page',
|
|
677
|
+
],
|
|
678
|
+
lookFor: 'La page doit avoir : header (banner), nav (navigation), main, footer (contentinfo). Chaque zone doit être un landmark sémantique.',
|
|
679
|
+
},
|
|
680
|
+
];
|
|
681
|
+
server.tool('rgaa_visual_check', 'Génère une checklist de vérification visuelle pour l\'agent AI. Chaque étape indique quoi faire avec Browser MCP (navigate, screenshot, snapshot, Tab) et quoi vérifier visuellement. Couvre les critères que axe-core ne peut pas détecter automatiquement.', {
|
|
682
|
+
url: z.string().url().describe('URL de la page à vérifier visuellement'),
|
|
683
|
+
checks: z.array(z.string()).optional().describe('Liste d\'IDs de vérifications à effectuer (défaut: toutes). IDs: skip-link, focus-visible, contrast-visual, responsive-320, heading-hierarchy, images-alt, form-labels, new-window, keyboard-nav, landmarks'),
|
|
684
|
+
}, async ({ url, checks }) => {
|
|
685
|
+
const selectedChecks = checks && checks.length > 0
|
|
686
|
+
? VISUAL_CHECKS.filter(c => checks.includes(c.id))
|
|
687
|
+
: VISUAL_CHECKS;
|
|
688
|
+
let text = `# Vérification visuelle RGAA — ${url}\n\n`;
|
|
689
|
+
text += `**${selectedChecks.length} vérifications** à effectuer avec Browser MCP.\n`;
|
|
690
|
+
text += `Chaque étape indique les actions Browser MCP à exécuter et ce qu'il faut vérifier.\n\n`;
|
|
691
|
+
text += `---\n\n`;
|
|
692
|
+
for (const [i, check] of selectedChecks.entries()) {
|
|
693
|
+
text += `## ${i + 1}. ${check.title} (RGAA ${check.criteria})\n\n`;
|
|
694
|
+
text += `**Actions** :\n`;
|
|
695
|
+
for (const step of check.steps) {
|
|
696
|
+
text += `1. \`${step}\`\n`;
|
|
697
|
+
}
|
|
698
|
+
text += `\n**Ce qu'il faut vérifier** : ${check.lookFor}\n\n`;
|
|
699
|
+
text += `---\n\n`;
|
|
700
|
+
}
|
|
701
|
+
text += `## Instructions pour l'agent\n\n`;
|
|
702
|
+
text += `1. Commencez par \`browser_navigate("${url}")\`\n`;
|
|
703
|
+
text += `2. Exécutez chaque vérification dans l'ordre\n`;
|
|
704
|
+
text += `3. Pour chaque problème trouvé, notez le critère RGAA et la description\n`;
|
|
705
|
+
text += `4. Après toutes les vérifications, résumez les problèmes trouvés\n`;
|
|
706
|
+
text += `5. Utilisez \`rgaa_fix\` pour obtenir le code correctif de chaque problème\n`;
|
|
707
|
+
return { content: [{ type: 'text', text }] };
|
|
708
|
+
});
|
|
709
|
+
// ── Outil 9 : rgaa_report ──
|
|
175
710
|
server.tool('rgaa_report', 'Génère un rapport d\'audit RGAA structuré en JSON pour intégration CI/CD ou export.', {
|
|
176
711
|
url: z.string().url().describe('URL du site web'),
|
|
177
712
|
threshold: z.number().min(0).max(100).default(0).describe('Score minimum pour pass/fail (0 = pas de seuil)'),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rgaaudit/mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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",
|
|
@@ -28,7 +28,16 @@
|
|
|
28
28
|
"engines": {
|
|
29
29
|
"node": ">=18.0.0"
|
|
30
30
|
},
|
|
31
|
-
"keywords": [
|
|
31
|
+
"keywords": [
|
|
32
|
+
"mcp",
|
|
33
|
+
"rgaa",
|
|
34
|
+
"accessibility",
|
|
35
|
+
"a11y",
|
|
36
|
+
"wcag",
|
|
37
|
+
"audit",
|
|
38
|
+
"france",
|
|
39
|
+
"rgaa4"
|
|
40
|
+
],
|
|
32
41
|
"license": "MIT",
|
|
33
42
|
"repository": {
|
|
34
43
|
"type": "git",
|