---
title: "hugo-mcp v2.0: a Python plugin-system in 200 lines"
description: "Deep-dive into the hugo-mcp v2.0 plugin-system: 200 lines of Python for an extensible system with auto-discovery, parallel execution, error isolation, and 3 production plugins. Complete tutorial to write your own plugin."
url: "https://www.arleo.eu/en/posts/hugo-mcp-plugins-architecture/"
language: "en"
datePublished: "2026-05-09T23:52:28+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: a Python plugin-system in 200 lines
Deep-dive into the hugo-mcp v2.0 plugin-system: 200 lines of Python for an extensible system with auto-discovery, parallel execution, error isolation, and 3 production plugins. Complete tutorial to write your own plugin.



## TL;DR

[hugo-mcp v2.0.0](https://github.com/jmrGrav/hugo-mcp/releases/tag/v2.0.0) introduces a **Python plugin-system** that lets anyone add hooks after each `create_page` / `update_page` / `delete_page` operation. 200 lines of code for the core, 3 production plugins shipped (IndexNow, Google Indexing, Cloudflare). This post explains the design, the trade-offs, the security posture, and shows how to write your own plugin in 5 minutes.

<!--more-->

If you want the broader context of why this system exists (Grav→Hugo migration, need to notify search engines after each publication), read [Grav → Hugo migration](/en/posts/migration-grav-hugo/) first. This post is the **technical deep-dive** on the architecture, the design choices, and their impact on future development.

## The problem

With Grav, I had 3 PHP plugins each doing their job on the `onAdminAfterSave` and `onMcpAfterSave` hooks: IndexNow submitting to Bing/Yandex, Google Indexing submitting to Google API v3, and a bash script purging Cloudflare cache after every save. Three independent plugins, individually toggleable.

With Hugo, I first hard-coded all of this into `main.py`. It works, but it scales poorly: disabling Google Indexing requires commenting out code and redeploying, adding a 4th indexer touches critical code, sharing a plugin with someone requires cherry-picking, and a single plugin failure can block the whole pipeline.

Three patterns kept nagging me:
1. Modularity: each external service = isolated module
2. Configurability: enable/disable via config, not via code
3. Isolation: one plugin crashing doesn't break the others

This is exactly what Grav's plugin system did. So I ported the same idea to Python.

## The architecture in one diagram

<!-- mermaid-source:Z3JhcGggVEIKICAgIFRvb2xbImNyZWF0ZV9wYWdlIC8gdXBkYXRlX3BhZ2UgLyBkZWxldGVfcGFnZSJdCiAgICBUb29sIC0tPnxodWdvIGJ1aWxkIE9LfCBMb2FkZXJbcGx1Z2luX2xvYWRlci5maXJlX2V2ZW50XQogICAgTG9hZGVyIC0tPnxhc3luY2lvLmdhdGhlcnwgUDFbcGx1Z2luOiBpbmRleG5vd10KICAgIExvYWRlciAtLT58YXN5bmNpby5nYXRoZXJ8IFAyW3BsdWdpbjogZ29vZ2xlLWluZGV4aW5nXQogICAgTG9hZGVyIC0tPnxhc3luY2lvLmdhdGhlcnwgUDNbcGx1Z2luOiBjbG91ZGZsYXJlXQogICAgUDEgLS0+fEhUVFAgUE9TVHwgQmluZ1tCaW5nL1lhbmRleF0KICAgIFAyIC0tPnxKV1QgKyBIVFRQIFBPU1R8IEdvb2dsZVtHb29nbGUgQVBJIHYzXQogICAgUDMgLS0+fEhUVFAgUE9TVHwgQ0ZbQ2xvdWRmbGFyZSBBUEldCiAgICBQMSAtLi0+fHJlc3VsdCBvciBlcnJvcnwgTG9hZGVyCiAgICBQMiAtLi0+fHJlc3VsdCBvciBlcnJvcnwgTG9hZGVyCiAgICBQMyAtLi0+fHJlc3VsdCBvciBlcnJvcnwgTG9hZGVyCiAgICBMb2FkZXIgLS0+fHJlc3VsdHMgbGlzdHwgVG9vbAogICAgVG9vbCAtLT58SlNPTi1SUEMgcmVzcG9uc2V8IENsaWVudFtDbGF1ZGUuYWld -->
{{< mermaid-svg hash="64f0a01d" >}}

Three key principles: auto-discovery at startup, parallel execution via `asyncio.gather`, error isolation via `return_exceptions=True`.

## The HugoMcpPlugin contract

```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 mandatory methods, no framework, no magic. `is_enabled` separate from `validate_config`: intentionally disabled → INFO, invalid config → WARNING.

## Design decisions

### Async + parallel rather than sequential

3 plugins × ~500ms = 1.5s sequential vs ~600ms with `asyncio.gather`. For an interactive MCP, the difference is noticeable.

### Per-plugin timeout (10s) rather than global

A global timeout cancels healthy plugins when one fails. Per-plugin: the 2 healthy ones return results, the 3rd is marked `error: timeout`.

### Auto-discovery rather than explicit registration

Adding a plugin = drop a directory. No core changes. Same philosophy as Hugo (`themes/`) or systemd (`/etc/systemd/system/`).

### YAML config

Human-readable, strictly parseable (no eval), supports nesting. Consistent with the Grav/Hugo ecosystem.

## Security

| Risk | Mitigation |
|---|---|
| Unaudited 3rd-party plugin | No marketplace, code review before merge |
| Secret in logs | structlog configured not to redump input payloads |
| Uncontrolled outbound | systemd IPAddressDeny + IPAddressAllow whitelist |
| Timeout / blocking | asyncio.wait_for 10s + return_exceptions=True |
| Plugin crash | Isolation via gather(return_exceptions=True) |
| Arbitrary FS read/write | systemd ProtectSystem=strict + ReadWritePaths + NoNewPrivileges |

## 3 plugins, 3 patterns

**IndexNow**: 1 HTTP call for Bing + Yandex simultaneously.

**Google Indexing**: 1 call per URL, OAuth2 JWT, 200/day quota persisted in `/var/lib/hugo-mcp/google-indexing-quota.json`.

**Cloudflare**: 3 modes — `full` (purge_everything), `partial` (explicit URLs only), `smart` (partial on create/update, full on delete).

## Tutorial: your first plugin in 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/..."
```

## Benefits for future development

- **Stable core**: `main.py` no longer touches external services.
- **External contributions**: one PR = one directory, reviewed in isolation.
- **Trivial disabling**: `enabled: false` in `plugins.yaml`, restart, done.
- **Focused tests**: mock httpx + call `on_page_event` directly.

## Plugins added since v2.0.0

### sri-check plugin (PR #1) ✅

The 4th plugin introduces a new event type: `on_audit`. Unlike the 3 page-event plugins that react to every publication, `sri-check` is triggered on demand via the MCP tool `check_sri_versions` (or by an external cron).

What it does:

- Delegates to the bash script `check-sri-versions.sh` with flags `--json`, `--no-cf-purge`, `--dry-run` depending on arguments received.
- Parses the JSON report emitted after the `===JSON-REPORT===` marker (method `_extract_trailing_json`).
- On successful auto-fix, fires `registry.fire_event("updated", urls, {"force_full_purge": True, "trigger": "sri-check.autofix"})` — the CF purge goes through the existing Cloudflare plugin, not through bash.

Core extensions required (100% backwards-compatible):

- `plugin_base.py`: optional method `on_audit(audit_type, context)` + flag `handles_audit = False` by default.
- `plugin_loader.py`: `fire_audit_event()` with 600s timeout.
- `plugins/cloudflare/plugin.py`: honours `context.force_full_purge: True` to bypass smart/partial mode.

Open PR: [jmrGrav/hugo-mcp#1](https://github.com/jmrGrav/hugo-mcp/pull/1) — branch `feat/sri-check-plugin`, 8 files, +317 −2.

For the full context of the bash script and the SRI pipeline, see [SRI on Hugo: automated hashes, auto-update and BetterStack alerting](/en/posts/sri-cdn-hugo-automate/).

## Backlog

- Richer event lifecycle (pre_build, post_build, pre_deploy, post_deploy).
- Plugin hot-reload without `systemctl restart`.
- Per-plugin configurable timeout.
- GPG signing of plugins, verified at load.
- Separate workers via multiprocessing.

## Conclusion

200 lines of Python for 3 fundamental mechanics (auto-discovery, parallel execution, error isolation), 3 production plugins covering 3 different patterns, and a 4th `sri-check` plugin extending the system toward on-demand audits.

Code on [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). MIT license, contributions welcome.


## Tags

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

## Categories

- infrastructure
