MCP endpoint — LLM authoring
KilnCMS serves a Model Context Protocol
(MCP) endpoint at /mcp so LLM clients (Claude Code, Claude Desktop,
custom agents) can read and author content with typed tools instead of
hand-rolled HTTP. It is powered by ash_ai: each
tool is an Ash action on the KilnCMS.CMS domain, executed as the caller
through the exact same policies as every other surface.
/mcp was historically the only key-authenticated surface that could write.
As of #330 the JSON:API and GraphQL surfaces are also write-capable (the
deliberate reversal of decision D7), using the same :read_write API-key
scope and resource policies described here — see
json-api.md → "Writing" and
headless-graphql-api.md → "Mutations". /mcp remains
the LLM-tailored surface (typed tools, tool-list scoping) and, unlike those two,
exposes no publish/unpublish/delete tools at all — LLM authoring stays
draft-only by design.
Authentication: write-scoped API keys
/mcp accepts API keys only (Authorization: Bearer kiln_…) — no JWT or
anonymous access; anything else is a 401. Mint keys at /editor/api-keys
(admin-only). A key acts as its owning user, bounded by its access scope:
Key access | Owner role | What the LLM can do over /mcp |
|---|---|---|
| Read-only | any | Read tools only (scoped to the owner's visibility) |
| Read + write | :viewer | Reads only — the role has no authoring rights |
| Read + write | :editor | Author: create/update drafts, tag, submit for review |
| Read + write | :admin | As editor, plus edit any record its role could |
The recommended setup for an LLM author is a read + write key on a dedicated
:editor account. Its work lands as drafts and goes through in_review;
publishing stays a human (admin) approval step. Publishing and hard deletes are
never exposed as tools, whatever the key or role, and the content policies
additionally forbid destroys for any API-key actor.
Enforcement is layered, all before the admin policy bypass:
- Transport —
/mcprequires a valid, unexpired, unrevoked key. - Tool listing —
tools/listonly offers tools the authenticated key may actually run, so a read key never even seescreate_page. - Policies — every content/taxonomy/media resource forbids create/update
for read-scoped keys (
KilnCMS.Accounts.Checks.ApiKeyWithoutWriteAccess, which fails closed) and destroy for all keys, then the owner's role applies as usual.
Tools
Reads (all policy-scoped — drafts are visible only if the owner is an editor):
read_pages, read_posts, read_entries, read_type_definitions,
read_field_definitions, read_tags, read_tag_groups, read_categories.
Authoring (require a write key + editor role): create_page / update_page /
submit_page_for_review, the same trio for posts and dynamic-type entries
(create_entry needs a type_definition_id — discover them with
read_type_definitions), plus create_tag and create_category.
create_tag takes an optional tag_group_id — discover the groups with
read_tag_groups.
Body:
body_markdown. The create/update tools take the body either asblock_tree(typed block maps) or asbody_markdown— plain Markdown, the shape a model usually has already, converted server-side into the same blocks (see Markdown). One or the other, not both; raw HTML in the Markdown is sanitized.
Tags: prefer
add_tag_ids/remove_tag_ids.update_page/update_post/update_entryaccept three tag arguments (#521):
Argument Semantics add_tag_idsAttaches the listed tags, leaves the rest attached. Re-adding an already-attached tag is a no-op; an unknown id is an error. remove_tag_idsDetaches the listed tags, leaves the rest attached. Removing a tag that isn't attached is a no-op. tag_idsReplaces the whole set — sending one id detaches every other tag. Only use it to set the complete list, and read the current tags first.
tag_idscannot be combined with either merge verb in one call, and the same id cannot appear in bothadd_tag_idsandremove_tag_ids; either is rejected rather than silently resolved. A repeated id inside one list is de-duplicated rather than rejected.The merge verbs are update-only.
create_page/create_post/create_entryhave no existing tags to merge against and taketag_idsalone; sendingadd_tag_idsto a create is an error.One asymmetry to plan around:
add_tag_idsfails loudly on an id that matches no tag, butremove_tag_idsswallows it, so removal stays idempotent on a retry. A successfulupdate_*is therefore not evidence a removal matched anything.Check the result rather than the status.
update_page/update_post/update_entryload the record'stagsandcategoryinto their response (#640), so a removal that matched nothing leaves the tag listed in the payload you already have. No follow-upread_tagsneeded.This covers tags and category only.
create_*does not load them and does not need to:tag_idson a create hard-errors on an unknown id, andcategory_idis echoed back as an attribute.The related-content links carry the same verbs (#637). Alongside
related_post_ids(which replaces),update_posttakesadd_related_post_ids/remove_related_post_ids, and the siblingadd_related_page_ids/remove_related_page_ids(and…_related_entry_idsonupdate_entry). The rules are identical — prefer the merge verbs for a partial update, don't combine a complete-set argument with its verbs, and don't list one id in both.They come back too, as
related_links— but only when the call named one of those arguments (#996). A write that never mentions related links does not carry them, because a document may have many and most writes do not touch them.
related_linksis a projection:[{"id": …, "title": …, "slug": …}], not the related records. That is deliberate — loading the relationship would put whole related posts, bodies included, into every write response, sinceloadcannot project.
No media upload tool, on purpose. Every other authoring tool produces a draft that a human approves before anyone sees it. Media has no draft state: an uploaded file is live at its public URL the moment it lands, and the upload pipeline has no review step to route it through. An
upload_mediatool would therefore be the one tool whose output an LLM — or a prompt injected into what it is reading — could publish on your domain by itself. Agents that need an image can reference one by URL in a draft for the reviewer to import, or an integration can call the REST upload API (POST /api/media, api.md) with its own key, where the file is the integration's deliberate act rather than a model's.
The tool set lives in the tools block on KilnCMS.CMS and the
config :kiln_cms, :mcp_tools list in config/config.exs (read at compile
time by the /mcp forward in KilnCMSWeb.Router) — add a tool in both places.
A downstream project can expose MCP tools for its own content types without
touching the core: add a tools block (via the AshAi extension) to your
content domain, then override :mcp_tools in your project config. As with
ash_domains, the override replaces the list — restate the core tools and
append your own.
Connecting a client
Claude Code:
claude mcp add --transport http kiln https://your-kiln-host/mcp \
--header "Authorization: Bearer $KILN_API_KEY"Or in a .mcp.json / Claude Desktop config:
{
"mcpServers": {
"kiln": {
"type": "http",
"url": "https://your-kiln-host/mcp",
"headers": { "Authorization": "Bearer kiln_…" }
}
}
}Smoke-test with curl (JSON-RPC over HTTP):
curl -s https://your-kiln-host/mcp \
-H "authorization: Bearer $KILN_API_KEY" \
-H "content-type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'Operational notes
- Requests ride the
:apirate-limit bucket. - Keys expire and can be revoked instantly at
/editor/api-keys; a revoked key 401s on the next request. - Every write is attributed to the key's owning user in versions/audit trails — another reason to give each LLM integration its own account.