Status: shipped (flarum_diag / flarum_triage, gated on DIAG_URL + token). Target: paid managed (LR-hosted) tier only. Intentionally absent from the public CHANGELOG: self-hosters never see these tools, so there is nothing to announce to them.
Lets a managed client invoke the MCP to isolate and troubleshoot forum problems (boot errors, post-update breakage, mail/queue failures) and get back a findings report. Self-hosted clients get none of this and never see that it exists.
Decided against. Troubleshooting must work even when Flarum won't boot, so it cannot depend on anything that loads inside Flarum. It also must be invisible to self-hosters, which an installable extension can't guarantee. Instead the channel goes through infra we already own.
MCP client → MCP server (managed mode) → srvup control-plane diag API → tenant container
(whitelisted read-only cmds)
- The MCP server does not hold
docker exec/SSH. srvup owns container access and the tenant→container mapping; the MCP just calls srvup's diag endpoints. - The actual command-execution logic lives in srvup, not in this (open-source) package. This repo only ever contains a client for an API that self-hosters cannot authenticate to.
- Because the path is srvup→container, it does not depend on Flarum's HTTP API being up. This is what makes boot-error diagnosis possible (the JSON:API is dead during a boot fatal; the container is still reachable).
When Flarum 500s on boot the JSON:API returns HTML/500 and the existing read-only API tools are blind. Via srvup→container we can still:
php flarum info, versions, PHP, DB driver, debug flag, mail/queue/url configphp flarum migrate:status, pending migrations (top cause of post-update breakage)- tail
storage/logs/flarum.logand the PHP-FPM/web error log (a true boot fatal often fires before Flarum's logger initializes, so it only shows in the web log) composer diagnose/ dependency-conflict check (the "enabled/updated an extension and the site died" case)- host facts: disk full,
storage/+assets/writability/perms, DB reachable from container, queue/horizon worker alive (risendad runs redis + fof-horizon)
If the fatal is early enough that php flarum itself won't run, the raw error-log
tail + composer diagnose are usually enough to name the culprit.
Decided: self-hosted sees nothing at all, no tools, no stub, no "managed-tier feature" message, no mention in errors. Enforced structurally, in three layers:
- Capability gating by credential possession, not a flag. Diag tools register
only when the server boots in managed mode with valid srvup control-plane auth
- a tenant binding. No credential → tools are never added to the advertised
tool list. This is stronger than the
FLARUM_EXTENSIONSopt-in (a self-hoster can set an env flag; they cannot mint control-plane auth).
- a tenant binding. No credential → tools are never added to the advertised
tool list. This is stronger than the
- Logic lives in srvup, not here. The open-source package contains only a client for an unreachable API. Nothing privileged ships to self-hosters.
- Server-side authz is the real boundary. Even a forged managed-mode flag fails: srvup authenticates the caller and maps it to a tenant it owns; unknown caller → refused. Security depends on srvup's auth, which we control, not on the MCP behaving.
Mirror the existing gate shape in src/server.ts:
// only non-null when control-plane auth is present (self-hosters cannot obtain it)
export function diagClientFromEnv(): DiagClient | null {
const url = process.env.DIAG_URL;
const token = process.env.DIAG_TOKEN || process.env.MCP_AUTH_TOKEN;
if (!url || !token) return null;
return new DiagClient({ url, token });
}
// in createMcpServer():
const diag = diagClientFromEnv();
if (diag) registerDiagnosticTools(server, diag);- Whitelisted commands only, a fixed menu of read-only diagnostics, never free-form shell. The whitelist IS the security boundary (see TODO below).
- Read-only / fail-closed, diagnose and recommend (clear cache, run migration, disable extension X); never auto-apply.
- Tenant-scoped + audited, a client can only diagnose its own forum; every diag call is logged on the srvup side.
Single endpoint, one whitelisted check per call. srvup resolves {tenant} to a
container it owns, runs the fixed command for check, and returns captured output.
POST {DIAG_URL}/internal/mcp/diag/{tenant}
Authorization: Bearer {DIAG_TOKEN}
Content-Type: application/json
{ "check": "<whitelisted-id>", "args": { "lines": 200, "level": "error" } }
Response:
{
"check": "flarum_log",
"ok": true,
"raw": "...captured stdout/stderr, secret-scrubbed, size-capped...",
"exitCode": 0,
"durationMs": 412,
"truncated": false
}checkmust be one of the seven locked ids (below). Anything else → 400; the command is never derived from client input, only selected from srvup's table.argsis per-check and bounded:lines(log tails, clamped e.g. 1..1000),level(flarum_log filter). No path, no flags, no free-form anything.- authN: control-plane bearer token. authZ: token→tenant mapping enforced server-side; a token may only diag tenants it owns (cross-tenant → 403).
- Hardening: per-check timeout, output size cap (
truncated:truewhen hit), secret-scrub onrawbefore it leaves the box, rate-limit per tenant, full audit log of every diag call.okreflects "the check ran", not "the forum is healthy".
| check id | command (read-only) |
|---|---|
flarum_info |
php flarum info |
migrate_status |
php flarum migrate:status |
flarum_log |
tail storage/logs/flarum.log (last lines, level) |
web_log |
tail PHP-FPM/web error log (last lines) |
composer_diagnose |
composer diagnose (+ dependency-conflict parse) |
disk_perms |
df + writability/perms of storage/,assets/ |
queue_status |
queue/horizon worker liveness probe |
Lives in src/tools/diagnostics.ts, wired into createMcpServer behind the
gate. Shape:
DiagClient, thin control-plane HTTP client (mirrors the snapshot hook inflarum-client.ts: bearer token, AbortController timeout, identifiable UA).diagClientFromEnv()/managedDiagnosticsEnabled(), gate onDIAG_URLplus a token (DIAG_TOKEN, falling back toMCP_AUTH_TOKEN). Self-hosters can't mint these, so the tools never register for them (the structural gate from §managed-only).- Tools (register only behind that gate):
flarum_diag, run ONE whitelisted check; returns raw{check, ok, raw, ...}.flarum_triage, run the standard boot-error bundle (info, migrate_status, flarum_log, web_log, composer_diagnose) and return the combined raw outputs for the model to fuse into the findings report.
Wiring in createMcpServer (one line, behind the gate):
const diag = diagClientFromEnv();
if (diag) registerDiagnosticTools(server, diag);- Admin hits a problem (e.g. boot 500). Client calls
flarum_triage. - MCP → srvup → container runs the bundle; raw outputs come back scrubbed/capped.
- The model fuses them into the findings-report schema below. It recommends fixes (run migration, disable extension X, free disk); it never executes them.
Each finding:
severity · symptom (observed) · evidence (exact log/CLI excerpt) · likely cause · suggested fix · confidence · couldn't-check
The LLM fuses raw command outputs into this shape. It recommends; it does not act.
The whitelist is the security boundary. v1 is locked to exactly these seven read-only checks, nothing outside this list executes:
-
flarum info -
flarum migrate:status - flarum-log tail (
storage/logs/flarum.log, last N lines, level filter) - web/PHP-FPM error-log tail (last N lines)
-
composer diagnose/ dependency-conflict check - disk usage +
storage//assets/writability & perms check - queue/horizon worker liveness ping
Explicitly out of v1 (mutating, surface as recommendations only, never run):
cache:clear, migrate, extension enable/disable, composer install/update.
Still open (implementation details, do not block the locked list):
- exact log line caps / redaction (scrub secrets from log tails before return)
- per-check timeouts
- does
flarum infooutput need any field redaction before leaving the box