Contenu

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 :

  1. Modularité : chaque service externe = un module isolé
  2. Configurabilité : activer/désactiver via config, pas via code
  3. 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

Diagram Diagram

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é

RisqueMitigation
Plugin tiers non auditéPas de marketplace, revue de code avant merge
Secret dans les logsstructlog configuré pour ne pas redumper les payloads
Outbound non contrôlésystemd IPAddressDeny + IPAddressAllow whitelist
Timeout / blocageasyncio.wait_for 10s + return_exceptions=True
Crash pluginIsolation via gather(return_exceptions=True)
Lecture/écriture FSsystemd 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/discord
class 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.py ne touche plus aux services externes.
  • Contributions externes : une PR = 1 dossier, review en isolation.
  • Désactivation triviale : enabled: false dans plugins.yaml, restart, fini.
  • Tests ciblés : mocker httpx + appeler on_page_event directement.

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.sh avec les flags --json, --no-cf-purge, --dry-run selon 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 optionnelle on_audit(audit_type, context) + flag handles_audit = False par défaut.
  • plugin_loader.py : fire_audit_event() avec timeout 600s.
  • plugins/cloudflare/plugin.py : honore context.force_full_purge: True pour 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.