@rgaaudit/mcp-server 0.3.0 → 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 +269 -115
- package/package.json +1 -3
- package/dist/local-scanner.d.ts +0 -66
- package/dist/local-scanner.js +0 -156
package/dist/index.js
CHANGED
|
@@ -19,12 +19,11 @@
|
|
|
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';
|
|
23
22
|
const API_URL = process.env.RGAA_API_URL || 'https://app.rgaaudit.fr';
|
|
24
23
|
const API_KEY = process.env.RGAA_API_KEY || '';
|
|
25
24
|
const server = new McpServer({
|
|
26
25
|
name: 'rgaaudit',
|
|
27
|
-
version: '0.3.
|
|
26
|
+
version: '0.3.1',
|
|
28
27
|
});
|
|
29
28
|
// ── Cache d'audit (TTL 10 min, évite double consommation de quota) ──
|
|
30
29
|
const CACHE_TTL_MS = 10 * 60 * 1000;
|
|
@@ -40,107 +39,77 @@ function cleanCache() {
|
|
|
40
39
|
}
|
|
41
40
|
}
|
|
42
41
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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.
|
|
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).
|
|
92
44
|
*
|
|
93
45
|
* @param url - URL à auditer
|
|
94
46
|
* @param options - details, threshold, auditId (pour réutiliser un audit existant)
|
|
95
47
|
*/
|
|
96
48
|
async function callScan(url, options) {
|
|
49
|
+
if (!API_KEY) {
|
|
50
|
+
throw new Error('RGAA_API_KEY non configurée.\n\n' +
|
|
51
|
+
'1. Créez un compte sur https://app.rgaaudit.fr\n' +
|
|
52
|
+
'2. Allez dans Paramètres > API\n' +
|
|
53
|
+
'3. Générez une clé API\n' +
|
|
54
|
+
'4. Configurez : RGAA_API_KEY=rga_...');
|
|
55
|
+
}
|
|
97
56
|
cleanCache();
|
|
98
|
-
// Vérifier le cache
|
|
57
|
+
// Vérifier le cache : si un audit récent existe, réutiliser l'auditId (0 quota)
|
|
99
58
|
const cacheKey = url.toLowerCase().replace(/\/+$/, '');
|
|
100
59
|
const cached = auditCache.get(cacheKey);
|
|
101
60
|
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
61
|
if (!options?.details || cached.result.violationDetails) {
|
|
104
62
|
return cached.result;
|
|
105
63
|
}
|
|
106
64
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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();
|
|
65
|
+
const auditId = options?.auditId || cached?.result.auditId;
|
|
66
|
+
const res = await fetch(`${API_URL}/api/ci/scan`, {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: {
|
|
69
|
+
'Content-Type': 'application/json',
|
|
70
|
+
'X-API-Key': API_KEY,
|
|
71
|
+
},
|
|
72
|
+
body: JSON.stringify({
|
|
73
|
+
url,
|
|
74
|
+
details: options?.details ?? false,
|
|
75
|
+
threshold: options?.threshold ?? 0,
|
|
76
|
+
...(auditId ? { auditId } : {}),
|
|
77
|
+
}),
|
|
78
|
+
});
|
|
79
|
+
if (!res.ok) {
|
|
80
|
+
const body = await res.json().catch(() => ({}));
|
|
81
|
+
throw new Error(`API erreur ${res.status}: ${body.message || res.statusText}`);
|
|
139
82
|
}
|
|
83
|
+
const result = await res.json();
|
|
140
84
|
// Mettre en cache
|
|
141
85
|
auditCache.set(cacheKey, { result, timestamp: Date.now() });
|
|
142
86
|
return result;
|
|
143
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
|
+
}
|
|
111
|
+
return res.json();
|
|
112
|
+
}
|
|
144
113
|
// ── Outil 1 : rgaa_audit ──
|
|
145
114
|
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.', {
|
|
146
115
|
url: z.string().url().describe('URL du site web à auditer'),
|
|
@@ -173,20 +142,52 @@ server.tool('rgaa_audit', 'Lancer un audit d\'accessibilité RGAA 4.1 complet su
|
|
|
173
142
|
text += '\n';
|
|
174
143
|
}
|
|
175
144
|
if (violations.length > 0) {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
text +=
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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';
|
|
188
190
|
}
|
|
189
|
-
text += '\n';
|
|
190
191
|
}
|
|
191
192
|
}
|
|
192
193
|
text += `\n🔗 Rapport complet : ${result.auditUrl}`;
|
|
@@ -431,7 +432,6 @@ server.tool('rgaa_remediate', 'Plan de remédiation autonome : audite, priorise
|
|
|
431
432
|
try {
|
|
432
433
|
const result = await callScan(url, { details: true });
|
|
433
434
|
const violations = result.violationDetails || [];
|
|
434
|
-
const isLocal = isLocalUrl(url);
|
|
435
435
|
if (violations.length === 0) {
|
|
436
436
|
return {
|
|
437
437
|
content: [{
|
|
@@ -483,19 +483,14 @@ server.tool('rgaa_remediate', 'Plan de remédiation autonome : audite, priorise
|
|
|
483
483
|
text += `| Corrigeables auto | ${fixable.length} |\n`;
|
|
484
484
|
text += `| Manuelles | ${manual.length} |\n`;
|
|
485
485
|
text += `| Fixes dans ce plan | ${toFix.length} |\n`;
|
|
486
|
-
|
|
487
|
-
text += `| Mode | **Localhost** (re-audits gratuits) |\n`;
|
|
488
|
-
}
|
|
489
|
-
else {
|
|
490
|
-
text += `| Mode | **Distant** (re-audit = 1 quota) |\n`;
|
|
491
|
-
}
|
|
486
|
+
text += `| Re-check | 1 token (smart re-audit, pas de re-discovery) |\n`;
|
|
492
487
|
text += '\n';
|
|
493
488
|
// Score estimé après corrections
|
|
494
489
|
const estimatedGain = Math.min(30, toFix.length * 3);
|
|
495
490
|
const estimatedScore = Math.min(100, result.score + estimatedGain);
|
|
496
491
|
text += `**Score estimé après corrections** : ~${estimatedScore}% (+${estimatedGain} points)\n\n`;
|
|
497
492
|
text += `---\n\n## Corrections à appliquer (${toFix.length})\n\n`;
|
|
498
|
-
text += `Appliquez chaque fix dans l'ordre. Après le dernier fix, lancez \`
|
|
493
|
+
text += `Appliquez chaque fix dans l'ordre. Après le dernier fix, lancez \`rgaa_recheck("${result.auditId}")\` pour mesurer le delta.\n\n`;
|
|
499
494
|
for (const [i, v] of toFix.entries()) {
|
|
500
495
|
const fix = RGAA_FIXES[v.ruleId];
|
|
501
496
|
const code = fix.fixes[framework] || fix.fixes['html'];
|
|
@@ -503,8 +498,12 @@ server.tool('rgaa_remediate', 'Plan de remédiation autonome : audite, priorise
|
|
|
503
498
|
text += `- **Problème** : ${fix.description}\n`;
|
|
504
499
|
text += `- **Impact** : ${v.impact}\n`;
|
|
505
500
|
text += `- **Éléments** : ${v.nodesCount} nœuds affectés\n`;
|
|
506
|
-
if (v.zone)
|
|
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) {
|
|
507
505
|
text += `- **Zone** : ${v.zone}\n`;
|
|
506
|
+
}
|
|
508
507
|
// Exemples de nœuds à corriger
|
|
509
508
|
if (v.nodes.length > 0) {
|
|
510
509
|
text += `- **Éléments à corriger** :\n`;
|
|
@@ -537,13 +536,7 @@ server.tool('rgaa_remediate', 'Plan de remédiation autonome : audite, priorise
|
|
|
537
536
|
text += `1. Pour chaque fix ci-dessus, identifiez le fichier source concerné\n`;
|
|
538
537
|
text += `2. Appliquez le code correctif adapté au composant\n`;
|
|
539
538
|
text += `3. Après toutes les corrections, exécutez :\n`;
|
|
540
|
-
text += ` - \`
|
|
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
|
-
}
|
|
539
|
+
text += ` - \`rgaa_recheck("${result.auditId}")\` pour mesurer le delta (1 token)\n`;
|
|
547
540
|
text += `4. Si le score < ${targetScore}%, relancez \`rgaa_remediate\` pour le cycle suivant\n`;
|
|
548
541
|
text += `5. Vérifiez visuellement avec \`browser_screenshot\` que le rendu n'est pas cassé\n`;
|
|
549
542
|
return { content: [{ type: 'text', text }] };
|
|
@@ -552,7 +545,168 @@ server.tool('rgaa_remediate', 'Plan de remédiation autonome : audite, priorise
|
|
|
552
545
|
return { content: [{ type: 'text', text: `Erreur : ${error instanceof Error ? error.message : String(error)}` }], isError: true };
|
|
553
546
|
}
|
|
554
547
|
});
|
|
555
|
-
// ── Outil 7 :
|
|
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 ──
|
|
556
710
|
server.tool('rgaa_report', 'Génère un rapport d\'audit RGAA structuré en JSON pour intégration CI/CD ou export.', {
|
|
557
711
|
url: z.string().url().describe('URL du site web'),
|
|
558
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.
|
|
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",
|
|
@@ -18,9 +18,7 @@
|
|
|
18
18
|
"prepublishOnly": "npm run build"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@axe-core/puppeteer": "^4.11.1",
|
|
22
21
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
23
|
-
"puppeteer-core": "^24.40.0",
|
|
24
22
|
"zod": "^3.25.3"
|
|
25
23
|
},
|
|
26
24
|
"devDependencies": {
|
package/dist/local-scanner.d.ts
DELETED
|
@@ -1,66 +0,0 @@
|
|
|
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>;
|
package/dist/local-scanner.js
DELETED
|
@@ -1,156 +0,0 @@
|
|
|
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
|
-
}
|