Files
FabledSteward/docs/plugins/writing-a-plugin.md
T
bvandeusenandClaude Opus 5.5 41c7509366
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 36s
CI / unit (push) Successful in 58s
CI / publish (push) Successful in 1m2s
feat(mcp): diagnostic tools — overview, hosts, monitors, alerts, metrics, audit
MCP-4 of milestone 461 "MCP server: Steward as an LLM-inspectable,
LLM-operable hub". Twelve read-only viewer tools:

- get_overview: what is wrong now, ranked critical-then-oldest. Failing
  monitors with their error and when the run of failures began, firing,
  pending and still-breaching acknowledged alerts, TLS certificates expiring
  within 14 days, agents that stopped reporting, hosts over the configured
  CPU/memory/disk thresholds (Settings > Thresholds), failed Ansible runs in
  the last 24h, and findings from enabled plugins through a new optional
  `mcp_overview(db)` plugin hook. Each finding names the call that shows
  its evidence.
- list_hosts, get_host, get_host_metrics
- list_monitors, get_monitor (HTTP header values and URL passwords are
  redacted)
- list_alerts, get_alert_history, list_alert_rules, list_maintenance_windows
- query_metrics: the whole metric store, narrowed one argument at a time
  (sources > resources > latest values > a series summary)
- list_audit_events

Shared shaping in steward/mcp/tools/_shape.py: UTC ISO times with age_s,
bounded lists that say when and how they were cut, series as
min/avg/max/p95/last plus a ≤60-point series, lookup by id or
case-insensitive name with a did-you-mean, and a range argument that
refuses an unknown key instead of reading it as 24h.

New core reads in steward/core/metric_series.py (sources and resources
via loose index scans, latest values via DISTINCT ON, series across the
raw/hourly-rollup seam). monitors/service.py gains monitor_states,
down_since and recent_failures; agent_overview_by_host now carries the
fullest disk and the last sample time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 07:39:41 -04:00

475 lines
15 KiB
Markdown

# Writing a Plugin
This guide walks through building a complete plugin from scratch. The Traefik plugin (`plugins/traefik/`) is the reference implementation — read it alongside this guide.
---
## Step 1: Create the Directory
```
plugins/
└── myplugin/
└── __init__.py ← start here
```
The directory name is the plugin's identity. It must match the `name` field in `plugin.yaml` and is used as the URL prefix (`/plugins/myplugin/`) and the Python import name.
---
## Step 2: Write plugin.yaml
```yaml
name: myplugin
version: "1.0.0"
description: "A short description of what this plugin monitors"
author: "Your Name or GitHub username"
license: "MIT" # any SPDX identifier, e.g. MIT, Apache-2.0, GPL-3.0
# Optional: prevents loading on older app versions
min_app_version: "0.1.0"
# Optional: shown in the catalog UI
repository_url: "https://github.com/yourname/yourrepo"
homepage: "https://github.com/yourname/yourrepo/tree/main/myplugin"
tags:
- monitoring
- http
# Default config values — users override these via the Settings UI
# or by writing to the app_settings DB table under "plugin.myplugin"
config:
target_url: "http://localhost:9090/metrics"
scrape_interval_seconds: 60
```
---
## Step 3: Define Models (if needed)
If your plugin stores data, define SQLAlchemy models using the shared `Base` from `steward.models.base`.
```python
# plugins/myplugin/models.py
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import String, Float, DateTime
from sqlalchemy.orm import Mapped, mapped_column
from steward.models.base import Base
class MyPluginMetric(Base):
__tablename__ = "myplugin_metrics"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
resource_name: Mapped[str] = mapped_column(String, nullable=False, index=True)
scraped_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
my_value: Mapped[float] = mapped_column(Float, nullable=False)
```
---
## Step 4: Write Migrations
Create `plugins/myplugin/migrations/` with the standard Alembic layout. Copy `env.py` and `script.py.mako` from `plugins/traefik/migrations/` as a starting point — the `env.py` is boilerplate and rarely needs changes.
Generate the initial migration:
```bash
# From the project root
alembic --config alembic.ini revision \
--autogenerate \
--head=steward@head \
--branch-label=myplugin \
-m "myplugin initial"
```
Edit the generated file to set `depends_on`:
```python
# In the generated revision file:
depends_on = ("0004_core_head_id",) # the core migration head ID
down_revision = None
branch_labels = ("myplugin",)
# Prefix the revision ID with the plugin name:
revision = "myplugin_001_initial"
```
---
## Step 5: Write Scheduled Task Logic
Keep task logic in a separate file so `__init__.py` stays clean.
```python
# plugins/myplugin/scheduler.py
from __future__ import annotations
import logging
from steward.core.scheduler import ScheduledTask
from steward.core.alerts import record_metric
logger = logging.getLogger(__name__)
def make_task(app) -> ScheduledTask:
interval = int(app.config["PLUGINS"]["myplugin"]["scrape_interval_seconds"])
async def scrape():
await _do_scrape(app)
return ScheduledTask(
name="myplugin_scrape",
coro_factory=scrape,
interval_seconds=interval,
run_on_startup=True,
)
async def _do_scrape(app) -> None:
from .models import MyPluginMetric
from datetime import datetime, timezone
url = app.config["PLUGINS"]["myplugin"]["target_url"]
try:
value = await _fetch_value(url)
except Exception:
logger.exception("myplugin scrape failed (url=%s)", url)
return
now = datetime.now(timezone.utc)
async with app.db_sessionmaker() as session:
async with session.begin():
# Write to plugin's own history table
session.add(MyPluginMetric(
resource_name="my-resource",
scraped_at=now,
my_value=value,
))
# Emit to plugin_metrics so alert rules can fire
await record_metric(
session=session,
source_module="myplugin",
resource_name="my-resource",
metric_name="my_value",
value=value,
)
async def _fetch_value(url: str) -> float:
import httpx
async with httpx.AsyncClient() as client:
resp = await client.get(url)
resp.raise_for_status()
return float(resp.text.strip())
```
---
## Step 6: Write Routes (if needed)
```python
# plugins/myplugin/routes.py
from quart import Blueprint, current_app, render_template
from steward.auth.middleware import require_role
from steward.models.users import UserRole
from .models import MyPluginMetric
myplugin_bp = Blueprint("myplugin", __name__, template_folder="templates")
@myplugin_bp.get("/")
@require_role(UserRole.viewer)
async def index():
async with current_app.db_sessionmaker() as db:
from sqlalchemy import select
result = await db.execute(
select(MyPluginMetric).order_by(MyPluginMetric.scraped_at.desc()).limit(50)
)
rows = result.scalars().all()
return await render_template("myplugin/index.html", rows=rows)
@myplugin_bp.get("/widget")
@require_role(UserRole.viewer)
async def widget():
"""HTMX fragment for the dashboard widget."""
async with current_app.db_sessionmaker() as db:
from sqlalchemy import select
result = await db.execute(
select(MyPluginMetric).order_by(MyPluginMetric.scraped_at.desc()).limit(1)
)
latest = result.scalar_one_or_none()
return await render_template("myplugin/widget.html", latest=latest)
```
---
## Step 7: Write Templates
Templates live in `plugins/myplugin/templates/myplugin/` (the extra nesting avoids naming collisions with core templates).
```html
{# plugins/myplugin/templates/myplugin/index.html #}
{% extends "base.html" %}
{% block title %}My Plugin — Steward{% endblock %}
{% block content %}
<div class="page-title">My Plugin</div>
{% for row in rows %}
<p>{{ row.resource_name }} — {{ row.my_value }}</p>
{% endfor %}
{% endblock %}
```
```html
{# plugins/myplugin/templates/myplugin/widget.html — HTMX fragment, no extends #}
{% if latest %}
<div class="ping-row">
<span>{{ latest.resource_name }}</span>
<span>{{ latest.my_value }}</span>
</div>
{% else %}
<p class="empty">No data yet.</p>
{% endif %}
```
---
## Step 8: Wire Up __init__.py
```python
# plugins/myplugin/__init__.py
from __future__ import annotations
_app = None
def setup(app) -> None:
global _app
_app = app
from .models import MyPluginMetric # noqa: registers model with Base.metadata
def get_scheduled_tasks() -> list:
from .scheduler import make_task
return [make_task(_app)]
def get_blueprint():
from .routes import myplugin_bp
return myplugin_bp
```
---
## Step 9: Enable the Plugin
Plugin config is stored in the `app_settings` DB table. The easiest way to enable a plugin is via the Settings UI, or by inserting directly:
```sql
INSERT INTO app_settings (key, value_json, updated_at)
VALUES ('plugin.myplugin', '{"enabled": true, "target_url": "http://localhost:9090/metrics"}', now());
```
On next startup, the plugin will be loaded, its migrations applied, and its blueprint and tasks registered.
---
## Using record_metric()
`record_metric()` is how plugins feed data into the alert pipeline. Any metric you write here can be the target of an alert rule created in the UI.
```python
from steward.core.alerts import record_metric
# Must be inside an active transaction
async with session.begin():
await record_metric(
session=session,
source_module="myplugin", # matches alert rule source_module
resource_name="my-server", # matches alert rule resource_name
metric_name="response_time_ms", # matches alert rule metric_name
value=42.3,
)
```
`record_metric()` writes to `plugin_metrics` and evaluates all matching alert rules inline. Notifications are deferred outside the transaction. It propagates `SQLAlchemyError` on DB failure — don't swallow it.
---
## Auth in Routes
Use the `@require_role` decorator from `steward.auth.middleware`:
```python
from steward.auth.middleware import require_role
from steward.models.users import UserRole
@myplugin_bp.get("/admin-only")
@require_role(UserRole.admin)
async def admin_page():
...
@myplugin_bp.get("/read-only")
@require_role(UserRole.viewer) # viewer, operator, and admin can all access
async def read_page():
...
```
Role hierarchy: `admin > operator > viewer`. Requiring `viewer` grants access to all three roles.
---
## Agent Tools (MCP)
Steward serves an MCP endpoint at `/mcp` that AI clients reach with a personal API token created on the AI access page. A plugin adds tools to it by exporting an optional `register_mcp_tools(register)` from `__init__.py`:
```python
# plugins/myplugin/__init__.py
def register_mcp_tools(register) -> None:
from .mcp_tools import list_targets, rescan_target
from steward.models.users import UserRole
register(list_targets, role=UserRole.viewer, read_only=True)
register(rescan_target, role=UserRole.operator, read_only=False)
```
```python
# plugins/myplugin/mcp_tools.py
from steward.mcp._context import current_caller, mcp_app
async def list_targets(limit: int = 50) -> dict:
"""What this tool is for, when to use it over its siblings, what each
argument's empty value means, and what comes back. This docstring is the
tool's description: it is all the agent knows about it."""
app = mcp_app() # Quart's current_app does not exist here
async with app.db_sessionmaker() as db:
...
return {"targets": [...], "total": n, "truncated": n > limit}
```
- `role` and `read_only` are required. A tool is listed and callable only for callers whose role reaches `role`, and only while the plugin is enabled.
- Tools run outside Quart's request pipeline: use `mcp_app()` and `current_caller()` from `steward.mcp._context`, never `current_app` or `session`.
- Refuse by raising `ValueError("what was wrong, and what to do instead")`. The agent reads that text. Any other exception reaches it only as a generic failure.
- Tool names are global across core and every plugin; a name already taken fails registration and the plugin contributes no tools (logged).
- Hot-reloading the plugin replaces its tools.
- Follow the response conventions the core tools use (`steward/mcp/tools/_shape.py`): times via `iso()` plus `age_s()`, lists through `bounded()` so a cut is never silent, series through `summarize()`, and lookups by name or id through `pick()`.
### Contributing to get_overview
`get_overview` is the agent's first call: what is wrong right now, ranked. A plugin adds its own problems to it by exporting an optional `mcp_overview(db)` that returns findings built with `steward.mcp.tools.overview.finding`:
```python
# plugins/myplugin/__init__.py
async def mcp_overview(db) -> list[dict]:
from steward.mcp.tools.overview import finding
return [
finding("warning", "target_unreachable", t.name, f"{t.name} has not answered for {t.misses} scans",
since=t.last_ok_at, ids={"target_id": t.id}, next=f'get_target(target="{t.id}")')
for t in await _unreachable_targets(db)
]
```
- `severity` is `"critical"` (something is down or about to be) or `"warning"` (degraded, or heading that way). Report problems only; a healthy plugin returns `[]`.
- It is called only while the plugin is enabled, with its own session. If it raises, the overview still returns, and names the plugin under `unavailable`.
---
## Publishing to the Catalog
The official plugin catalog is hosted at `https://git.fabledsword.com/bvandeusen/Steward-plugins`. Anyone can submit a plugin by opening a pull request — first-party and third-party plugins are treated identically by the catalog system.
### Repo layout
```
steward-plugins/
├── index.yaml ← catalog index — the only file Steward fetches
├── myplugin/
│ ├── plugin.yaml
│ ├── __init__.py
│ └── ...
└── .github/
└── workflows/
└── publish.yml ← packages each plugin dir into a zip on release
```
### index.yaml entry
Each plugin needs a corresponding entry in `index.yaml`. See `docs/plugins/index.yaml.example` for the full schema. The minimum required fields are:
```yaml
- name: myplugin
version: "1.0.0"
description: "What this plugin does"
author: "Your Name"
license: "MIT"
min_app_version: "0.1.0"
repository_url: "https://github.com/yourname/yourrepo"
homepage: "https://github.com/yourname/yourrepo/tree/main/myplugin"
download_url: "https://github.com/yourname/yourrepo/releases/download/myplugin-v1.0.0/myplugin.zip"
checksum_sha256: "" # fill in after zipping — see below
tags:
- monitoring
```
### Creating a release zip
The zip must contain the plugin files at the top level OR inside a single directory named after the plugin. Both layouts work:
```
# Layout A — flat (preferred)
myplugin.zip
├── plugin.yaml
├── __init__.py
└── ...
# Layout B — single top-level directory (also accepted, GitHub archive default)
myplugin.zip
└── myplugin/
├── plugin.yaml
└── ...
```
Generate the zip and its checksum:
```bash
cd steward-plugins
zip -r myplugin.zip myplugin/
sha256sum myplugin.zip # paste this into index.yaml checksum_sha256
```
Upload `myplugin.zip` as a GitHub release asset, then update `index.yaml` with the release download URL and checksum.
### How install works
When a user clicks **Install** in Settings → Plugins:
1. The app downloads the zip from `download_url`
2. Verifies the SHA-256 checksum (if provided)
3. Extracts the plugin into its `PLUGIN_DIR`
4. Runs any pending Alembic migrations for the plugin
5. Attempts a **hot-reload** — registers the blueprint and scheduled tasks without restarting
6. If the plugin was previously loaded (blueprint already mounted), a **restart** is required to pick up the new code
Hot-reload works reliably for brand-new plugin installs. Updates to already-active plugins require a restart, which can be triggered from the same settings page.
---
## Checklist
- [ ] `plugins/myplugin/` directory created
- [ ] `plugin.yaml` with correct `name` (matches directory), `author`, `license`, `tags`
- [ ] `__init__.py` exports `setup()` and `get_scheduled_tasks()`
- [ ] Models import inside `setup()` to register with metadata
- [ ] Migrations use `depends_on` pointing to core head, not `down_revision`
- [ ] Revision IDs prefixed with plugin name
- [ ] `record_metric()` called inside `session.begin()`
- [ ] Routes use `@require_role` decorator
- [ ] MCP tools (if any) registered via `register_mcp_tools(register)` with a role and `read_only`
- [ ] Templates namespaced under `templates/myplugin/`
- [ ] Plugin enabled in app settings
- [ ] `index.yaml` entry added with `download_url` and `checksum_sha256`