Contents

hugo-mcp v2.0: a Python plugin-system in 200 lines

TL;DR

hugo-mcp 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.

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

Diagram Diagram

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

The HugoMcpPlugin contract

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

RiskMitigation
Unaudited 3rd-party pluginNo marketplace, code review before merge
Secret in logsstructlog configured not to redump input payloads
Uncontrolled outboundsystemd IPAddressDeny + IPAddressAllow whitelist
Timeout / blockingasyncio.wait_for 10s + return_exceptions=True
Plugin crashIsolation via gather(return_exceptions=True)
Arbitrary FS read/writesystemd 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

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/..."

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 — 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.

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, tag v2.0.0. MIT license, contributions welcome.