---
title: "hugo-mcp v2.0 : un plugin-system Python en 200 lignes"
description: "Deep-dive sur le plugin-system de hugo-mcp v2.0 : 200 lignes de Python pour un système extensible avec auto-discovery, exécution parallèle, isolation des erreurs, et 3 plugins de production (IndexNow, Google Indexing, Cloudflare). Tutorial complet pour écrire son propre plugin."
url: "https://www.arleo.eu/posts/hugo-mcp-plugins-architecture/"
language: "fr"
datePublished: "2026-05-09T23:23:49+02:00"
dateModified: "2026-05-17T00:00:00Z"
tags: ["hugo","mcp","python","architecture","plugins","open-source"]
categories: ["infrastructure"]
contentSignal: "ai-train=no, search=yes, ai-input=yes"
---

# hugo-mcp v2.0 : un plugin-system Python en 200 lignes
Deep-dive sur le plugin-system de hugo-mcp v2.0 : 200 lignes de Python pour un système extensible avec auto-discovery, exécution parallèle, isolation des erreurs, et 3 plugins de production (IndexNow, Google Indexing, Cloudflare). Tutorial complet pour écrire son propre plugin.



## TL;DR

[hugo-mcp v2.0.0](https://github.com/jmrGrav/hugo-mcp/releases/tag/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.

<!--more-->

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](/posts/migration-grav-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

<!-- mermaid-source:Z3JhcGggVEIKICAgIFRvb2xbImNyZWF0ZV9wYWdlIC8gdXBkYXRlX3BhZ2UgLyBkZWxldGVfcGFnZSJdCiAgICBUb29sIC0tPnxodWdvIGJ1aWxkIE9LfCBMb2FkZXJbcGx1Z2luX2xvYWRlci5maXJlX2V2ZW50XQogICAgTG9hZGVyIC0tPnxhc3luY2lvLmdhdGhlcnwgUDFbcGx1Z2luOiBpbmRleG5vd10KICAgIExvYWRlciAtLT58YXN5bmNpby5nYXRoZXJ8IFAyW3BsdWdpbjogZ29vZ2xlLWluZGV4aW5nXQogICAgTG9hZGVyIC0tPnxhc3luY2lvLmdhdGhlcnwgUDNbcGx1Z2luOiBjbG91ZGZsYXJlXQogICAgUDEgLS0+fEhUVFAgUE9TVHwgQmluZ1tCaW5nL1lhbmRleF0KICAgIFAyIC0tPnxKV1QgKyBIVFRQIFBPU1R8IEdvb2dsZVtHb29nbGUgQVBJIHYzXQogICAgUDMgLS0+fEhUVFAgUE9TVHwgQ0ZbQ2xvdWRmbGFyZSBBUEldCiAgICBQMSAtLi0+fHLDqXN1bHRhdCBvdSBlcnJldXJ8IExvYWRlcgogICAgUDIgLS4tPnxyw6lzdWx0YXQgb3UgZXJyZXVyfCBMb2FkZXIKICAgIFAzIC0uLT58csOpc3VsdGF0IG91IGVycmV1cnwgTG9hZGVyCiAgICBMb2FkZXIgLS0+fGxpc3RlIGRlIHLDqXN1bHRhdHN8IFRvb2wKICAgIFRvb2wgLS0+fEpTT04tUlBDIHJlc3BvbnNlfCBDbGllbnRbQ2xhdWRlLmFpXQ== -->
{{< mermaid-svg hash="17ccee70" >}}

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

```python
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

```bash
cp -r plugins/_template plugins/discord
```

```python
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)}
```

```yaml
# 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](https://github.com/jmrGrav/hugo-mcp/pull/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](/posts/sri-cdn-hugo-automate/).

## 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](https://github.com/jmrGrav/hugo-mcp), tag [v2.0.0](https://github.com/jmrGrav/hugo-mcp/releases/tag/v2.0.0). Licence MIT.


## Tags

- hugo
- mcp
- python
- architecture
- plugins
- open-source

## Categories

- infrastructure
