@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 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.0',
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
- * 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.
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
- // 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();
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
- text += `## Détail des violations\n\n`;
177
- for (const [i, v] of violations.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`;
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
- if (isLocal) {
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 \`rgaa_score\` pour mesurer l'amélioration.\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`;
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 += ` - \`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
- }
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 : rgaa_report ──
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.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",
@@ -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": {
@@ -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>;
@@ -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
- }