MCP Server
AI-native control plane: transports, auth, scoping, audit log.
What MCP gives you
The Model Context Protocol server exposes the CoreAPI plus a handful of higher-level operations as a structured, typed tool surface. An AI agent can drive the entire CMS through ~94 tools across 21 domains — without filesystem access, without shell access, and without ever crossing into the admin SPA. Every operation is a typed call with a typed JSON response; every response carries enough structure for an agent to decide its next step.
Activate a theme. The CMS reorganises itself around it. No restart.— core.theme.activate
Transports
The kernel mounts MCP on a single Fiber endpoint at POST /mcp (and its descendants /mcp/*) using the standard JSON-RPC framing the Anthropic mcp CLI, Claude Desktop, and Cursor speak. Bearer auth runs first on every request; CORS for /mcp is permissive because the Authorization header is the security boundary. There is no stdio transport for remote use — a standalone mcp CLI command piped over stdio works against a local instance via the same HTTP endpoint.
Authentication
Bearer tokens are issued from /admin/security/mcp-tokens in the admin SPA (the legacy /admin/mcp-tokens URL still redirects, and the underlying API is at /admin/api/mcp-tokens). Tokens carry one of three scopes:
- read — read-only tools (every
get,list,query; allcore.render.*previews;core.guide;core.field_types.list). - content — read tools plus content writes (nodes, terms, menus, media uploads, file storage,
data.*reads/writes/deletes against extension-owned tables, event emit). - full — everything: theme/extension activation, deploy, deactivate, delete; node-type, taxonomy, layout, block-type writes; settings writes;
data.execraw SQL;email.send;http.fetch.
read cannot even see content-class tools in the catalogue; it cannot promote itself to content by passing extra arguments. Issue tokens with the smallest scope that lets the agent do its job.Rate limiting
The kernel applies a per-token leaky-bucket limiter (internal/mcp/ratelimit.go) with a default rate of 600 requests per minute and a burst of 60. Both knobs are configurable via SQUILLA_MCP_RPM and SQUILLA_MCP_BURST environment variables. Exceeded requests get HTTP 429 with a Retry-After hint; the limiter does not penalise legitimate bursts during normal AI-agent operation but does keep a runaway loop from overwhelming the database.
The core.guide meta-tool
Always start with core.guide. It returns a goal→tool decision tree, the canonical data shapes, naming conventions, common gotchas, an editing playbook, a tool index, and a snapshot of the current CMS state (active theme, node types, recent nodes, sections per node type, layouts, block types). One call replaces ~10 discovery calls and primes an agent with the operational context it needs before mutating anything.
By default the response is the token-compact menu form (~3 KB). Pass {topic: "<name>"} to drill into one domain (full recipes, gotchas, tool descriptions for that area). Pass {verbose: true} to get the entire reference dump (~30 KB) — useful once at session start, expensive to repeat. Topics: pages, editing, blocks, themes, taxonomies, media, extensions.
Resource URIs
The MCP server exposes structured resource URIs alongside tools. Resources are useful for fetching context that would be awkward to express as a tool call — they hand back full structured documents the agent can read into its working memory.
squilla://nodes/<id>— full node JSON, including blocks_data, fields_data, seo_settings, taxonomies, translations, revision count.squilla://themes/<slug>— theme manifest plus the registered DB row.squilla://extensions/<slug>— extension manifest plus the registered DB row, including capability set and is_active.squilla://guidelines/themes— onboarding guidelines for theme authors (machine-readable JSON envelope plus a Markdown body the agent can present to a user).squilla://guidelines/extensions— same shape for extension authors.squilla://guidelines/onboarding— generic agent onboarding markdown.
Audit log
Every MCP call is recorded in mcp_audit_log with token id, tool name, args hash (SHA-256, so the audit cannot leak secret values verbatim), status (ok | error | denied), error code (when applicable), client IP, and duration in milliseconds. Writes are buffered (channel size 256) and flushed asynchronously by a dedicated goroutine; channel overflow drops the oldest entries with a kernel warning rather than blocking the request. Retention is configurable via the mcp_audit_retention_days setting (default 90 days); a periodic sweeper deletes rows older than the retention window.
There is no admin UI for browsing the audit log directly. Query it via core.data.query({table: "mcp_audit_log", ...}) with a full-scope token, or against psql for ad-hoc analysis. The complementary mcp_token_audit table records token issuance, revocation, and scope changes — useful for incident response when a token is suspected compromised.
Hot-deploy tools
core.theme.deploy and core.extension.deploy accept a base64-encoded zip and unpack it into data/themes/<slug>/ or data/extensions/<slug>/ via an atomic directory swap. Inline base64 is capped at 50 MB. With activate=true the new package is registered AND activated in the same call — no kernel restart, no human in the loop. The activation pipeline runs SQL migrations recorded in extension_migrations, starts plugin subprocesses, loads scripts and blocks, and fires the extension.activated / theme.activated event.
For payloads above ~5–10 MB, use the presigned upload pair to bypass the JSON-RPC envelope (base64 inflates payloads ~33%, so the practical effective cap on inline tools is closer to 35 MB):
core.media.upload_init/core.media.upload_finalize— default 50 MB, override viaSQUILLA_MEDIA_MAX_MB.core.theme.deploy_init/core.theme.deploy_finalize— default 200 MB, override viaSQUILLA_THEME_MAX_MB.core.extension.deploy_init/core.extension.deploy_finalize— default 200 MB, override viaSQUILLA_EXTENSION_MAX_MB.
Init returns {upload_url, upload_token, expires_at, max_bytes}. PUT the raw bytes to upload_url with no Authorization header — the 64-char token in the URL is the auth, single-use, ~15 minute TTL, bound to the issuing user. Finalize routes through the same install pipeline as the inline tool, so behaviour is identical at the receiving end. core.extension.delete exists and deactivates the extension, kills its plugin subprocess cleanly, and removes the DB row; database tables created by extension migrations are not dropped (no down-migrations).
// Inline path (smaller archives):
core.theme.deploy({
body_base64: "...<base64 zip>...",
activate: true
})
// Presigned path (larger archives, faster):
const init = await mcp("core.theme.deploy_init", { filename: "my-theme-v2.zip" });
await fetch(init.upload_url, { method: "PUT", body: zipBytes }); // no Authorization!
await mcp("core.theme.deploy_finalize", {
upload_token: init.upload_token,
activate: true
});
--platform linux/arm64 if your production target is arm64 (the bundled Makefile defaults to this).