hugo-mcp v2.0 : un plugin-system Python en 200 lignes

TL;DR
hugo-mcp v2.0.0 introduit un plugin-system Python qui permet à n’importe qui d’ajouter des hooks après chaque create_page / update_page / delete_page. 200 lignes de code pour le coeur, 3 plugins de production fournis (IndexNow, Google Indexing, Cloudflare). Ce post explique le design, les arbitrages, la sécurité, et montre comment écrire son propre plugin en 5 minutes.
Si tu veux le contexte de pourquoi ce système existe (migration Grav vers Hugo, besoin de notifier les moteurs après chaque publication), va lire d’abord Migration Grav vers Hugo. Ce post-ci est le deep-dive technique sur l’architecture, les choix de design, et leur impact sur le développement futur.
Le problème
Avec Grav, j’avais 3 plugins PHP qui faisaient chacun leur job sur les hooks onAdminAfterSave et onMcpAfterSave : IndexNow soumettait à Bing/Yandex, Google Indexing soumettait à Google API v3, et un script bash de purge Cloudflare tournait après chaque save. Trois plugins indépendants, activables/désactivables séparément.
Avec Hugo, j’ai d’abord codé tout ça en dur dans main.py. Ça marche, mais ça scale mal : désactiver Google Indexing demande de commenter du code et redéployer, ajouter un 4ème indexer touche au code critique, partager un plugin avec quelqu’un demande du cherry-pick, et un plugin qui foire bloque tout le pipeline.
Trois patterns me titillaient :
- Modularité : chaque service externe = un module isolé
- Configurabilité : activer/désactiver via config, pas via code
- Isolation : un plugin qui crash ne casse pas les autres
C’est exactement ce que Grav faisait avec son système de plugins. Donc j’ai porté la même idée en Python.
L’architecture en 1 schéma
Trois principes clés : auto-discovery au démarrage, exécution parallèle via asyncio.gather, isolation des erreurs via return_exceptions=True.
Le contrat HugoMcpPlugin
from abc import ABC, abstractmethod
from typing import Literal, Any
class HugoMcpPlugin(ABC):
name: str
version: str
description: str
requires_secret: bool
@abstractmethod
def is_enabled(self, config: dict) -> bool: ...
@abstractmethod
def validate_config(self, config: dict) -> tuple[bool, str]: ...
@abstractmethod
async def on_page_event(
self,
event_type: Literal["created", "updated", "deleted"],
urls: list[str],
context: dict[str, Any],
) -> dict: ...3 méthodes obligatoires, pas de framework, pas de magie. is_enabled séparé de validate_config : désactivé volontairement → INFO, config invalide → WARNING.
Décisions de design
Async + parallèle plutôt que séquentiel
3 plugins × ~500ms = 1.5s séquentiel vs ~600ms en asyncio.gather. Pour un MCP interactif, la différence est sensible.
Timeout par plugin (10s) plutôt que global
Un timeout global annule les plugins sains quand un seul plante. Timeout par plugin : les 2 sains rendent leur résultat, le 3ème est marqué error: timeout.
Auto-discovery plutôt que registration explicite
Ajouter un plugin = déposer un dossier. Pas de touche au core. Même philosophie que Hugo (themes/) ou systemd (/etc/systemd/system/).
Config YAML
Lisible, parsable strict (pas d’eval), supporte le nested. Cohérent avec l’écosystème Grav/Hugo.
Sécurité
| Risque | Mitigation |
|---|---|
| Plugin tiers non audité | Pas de marketplace, revue de code avant merge |
| Secret dans les logs | structlog configuré pour ne pas redumper les payloads |
| Outbound non contrôlé | systemd IPAddressDeny + IPAddressAllow whitelist |
| Timeout / blocage | asyncio.wait_for 10s + return_exceptions=True |
| Crash plugin | Isolation via gather(return_exceptions=True) |
| Lecture/écriture FS | systemd ProtectSystem=strict + ReadWritePaths + NoNewPrivileges |
3 plugins, 3 patterns
IndexNow : 1 appel HTTP pour Bing + Yandex simultanément.
Google Indexing : 1 appel par URL, OAuth2 JWT, quota 200/jour persisté dans /var/lib/hugo-mcp/google-indexing-quota.json.
Cloudflare : 3 modes — full (purge_everything), partial (URLs explicites), smart (partial sur create/update, full sur delete).
Tutorial : ton premier plugin en 5 minutes
cp -r plugins/_template plugins/discordclass DiscordPlugin(HugoMcpPlugin):
name = "discord"
version = "1.0.0"
description = "Notify a Discord webhook on each page event"
requires_secret = True
def is_enabled(self, config): return config.get("enabled", False)
def validate_config(self, config):
if not self.is_enabled(config): return True, ""
return (False, "Missing webhook_url") if not config.get("webhook_url") else (True, "")
async def on_page_event(self, event_type, urls, context):
from core.plugin_loader import registry
emoji = {"created": "✨", "updated": "📝", "deleted": "🗑️"}[event_type]
async with httpx.AsyncClient(timeout=5.0) as client:
r = await client.post(
registry.config["discord"]["webhook_url"],
json={"content": f"{emoji} **{event_type}**: {urls[0] if urls else '?'}"}
)
return {"plugin": self.name, "success": r.status_code in (200, 204)}# config/plugins.yaml
discord:
enabled: true
webhook_url: "https://discord.com/api/webhooks/..."Avantages pour le développement futur
- Core stable :
main.pyne touche plus aux services externes. - Contributions externes : une PR = 1 dossier, review en isolation.
- Désactivation triviale :
enabled: falsedansplugins.yaml, restart, fini. - Tests ciblés : mocker httpx + appeler
on_page_eventdirectement.
Plugins ajoutés depuis v2.0.0
Plugin sri-check (PR #1) ✅
Le 4ème plugin introduit un nouveau type d’événement : on_audit. Contrairement aux 3 plugins page-event qui réagissent à chaque publication, sri-check est déclenché à la demande via le tool MCP check_sri_versions (ou par un cron externe).
Ce qu’il fait :
- Délègue au script bash
check-sri-versions.shavec les flags--json,--no-cf-purge,--dry-runselon les arguments reçus. - Parse le rapport JSON émis après le marqueur
===JSON-REPORT===(méthode_extract_trailing_json). - Sur auto-fix réussi, déclenche
registry.fire_event("updated", urls, {"force_full_purge": True, "trigger": "sri-check.autofix"})— la purge CF passe par le plugin Cloudflare existant, pas par le bash.
Extensions du core (rétrocompat 100%) :
plugin_base.py: méthode optionnelleon_audit(audit_type, context)+ flaghandles_audit = Falsepar défaut.plugin_loader.py:fire_audit_event()avec timeout 600s.plugins/cloudflare/plugin.py: honorecontext.force_full_purge: Truepour bypasser le mode smart/partial.
PR ouverte : jmrGrav/hugo-mcp#1 — branche feat/sri-check-plugin, 8 fichiers, +317 −2.
Pour le contexte complet du script bash et du pipeline SRI, voir SRI sur Hugo : hashes automatiques, auto-update et alerting BetterStack.
Backlog
- Cycle d’événements plus riche (pre_build, post_build, pre_deploy, post_deploy).
- Plugin hot-reload sans
systemctl restart. - Per-plugin timeout configurable.
- Signature GPG des plugins vérifiée au load.
- Workers séparés via multiprocessing.
Conclusion
200 lignes de Python pour 3 mécaniques fondamentales (auto-discovery, exécution parallèle, isolation des erreurs), 3 plugins de production couvrant 3 patterns différents, et un 4ème plugin sri-check qui étend le système vers les audits à la demande.
Code sur github.com/jmrGrav/hugo-mcp, tag v2.0.0. Licence MIT.