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:
- Modularity: each external service = isolated module
- Configurability: enable/disable via config, not via code
- 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
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
| 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
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/..."Benefits for future development
- Stable core:
main.pyno longer touches external services. - External contributions: one PR = one directory, reviewed in isolation.
- Trivial disabling:
enabled: falseinplugins.yaml, restart, done. - Focused tests: mock httpx + call
on_page_eventdirectly.
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.shwith flags--json,--no-cf-purge,--dry-rundepending 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 methodon_audit(audit_type, context)+ flaghandles_audit = Falseby default.plugin_loader.py:fire_audit_event()with 600s timeout.plugins/cloudflare/plugin.py: honourscontext.force_full_purge: Trueto 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.