Skip to main content

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 accessOwner roleWhat the LLM can do over /mcp
Read-onlyanyRead tools only (scoped to the owner's visibility)
Read + write:viewerReads only — the role has no authoring rights
Read + write:editorAuthor: create/update drafts, tag, submit for review
Read + write:adminAs 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:

  1. Transport — /mcp requires a valid, unexpired, unrevoked key.
  2. Tool listing — tools/list only offers tools the authenticated key may actually run, so a read key never even sees create_page.
  3. 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 as block_tree (typed block maps) or as body_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_entry accept three tag arguments (#521):

ArgumentSemantics
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_ids cannot be combined with either merge verb in one call, and the same id cannot appear in both add_tag_ids and remove_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_entry have no existing tags to merge against and take tag_ids alone; sending add_tag_ids to a create is an error.

One asymmetry to plan around: add_tag_ids fails loudly on an id that matches no tag, but remove_tag_ids swallows it, so removal stays idempotent on a retry. A successful update_* is therefore not evidence a removal matched anything.

Check the result rather than the status. update_page / update_post / update_entry load the record's tags and category into their response (#640), so a removal that matched nothing leaves the tag listed in the payload you already have. No follow-up read_tags needed.

This covers tags and category only. create_* does not load them and does not need to: tag_ids on a create hard-errors on an unknown id, and category_id is echoed back as an attribute.

The related-content links carry the same verbs (#637). Alongside related_post_ids (which replaces), update_post takes add_related_post_ids / remove_related_post_ids, and the sibling add_related_page_ids / remove_related_page_ids (and …_related_entry_ids on update_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_links is 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, since load cannot 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_media tool 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 :api rate-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.