Skip to content

MCP server

alinea dev includes a Model Context Protocol (MCP) server. Coding agents such as Claude Code, Cursor or Codex connect to it to read your schema and to find, create, edit, publish, move and delete entries and to upload media. This page covers setup, the workflow agents should follow, every tool and the value formats they accept.

Why use it

Every Alinea entry is a JSON file in your repository, so an agent could write those files directly. In practice that is error prone: entries carry internal fields such as _id, _index and _root, list rows and rich text nodes need their own ids, links are stored as objects and media entries hold computed metadata such as dimensions, a thumbhash and a preview.

The MCP server takes those details off the agent's hands:

  • Writes take the same path as the dashboard: the dev server's API handler saves the change, so the result is a valid entry file, laid out exactly like one saved by an editor.

  • Internal fields are generated. Agents send plain values: Markdown for rich text, entry ids for links, rows without ids for lists.

  • Input is validated against your schema. Errors name the field path and what is expected, so the agent can correct its input and retry.

  • An open dashboard updates live, so you can watch the changes come in and review them in the editor.

The files the tools write are ordinary content files: review them with git diff and commit them as usual.

When it runs

The MCP server is part of alinea dev only. It is not included in alinea build, in the generated dashboard or in the handler you deploy, so it never runs in production.

It only accepts requests from the local machine: requests whose Host or Origin header is not localhost, 127.0.0.1 or ::1 are refused with a 403, which also blocks web pages from reaching it through DNS rebinding. There is no authentication beyond that: anything running on your machine can use it while alinea dev runs. Changes are recorded as the local dashboard user.

Connect your agent

Start your dev script (alinea dev, or alinea dev -- next dev in a Next.js project). The banner prints the url of the MCP server:

Terminal
ɑ Alinea 2.0.0
├ dev src/cms.tsx in 690ms (172 records)
╰ MCP server:   http://localhost:4500/mcp

The server listens on the port of the local dashboard, 4500 by default. When 4500 is taken the dev server moves to 4501 and up, and the printed url changes with it. alinea dev --port 4600 (or -p) changes the port it starts from, but it still moves up when that port is taken, so check the banner when the tools don't connect. The endpoint speaks the Streamable HTTP transport: it answers JSON-RPC messages sent with POST and does not keep sessions.

Claude Code

Terminal
claude mcp add --transport http alinea http://localhost:4500/mcp

Add --scope project to write the server to the project's .mcp.json instead of your local settings, so everyone working on the project gets it.

Project .mcp.json

Claude Code and other clients read MCP servers from a .mcp.json file in the project root. Commit it to share the setup with your team:

.mcp.json
{
  "mcpServers": {
    "alinea": {
      "type": "http",
      "url": "http://localhost:4500/mcp"
    }
  }
}

Cursor

Cursor reads project servers from .cursor/mcp.json, where the url alone is enough:

.cursor/mcp.json
{
  "mcpServers": {
    "alinea": {
      "url": "http://localhost:4500/mcp"
    }
  }
}

Codex and other clients

Any client that supports remote (Streamable HTTP) MCP servers can connect: add an HTTP server named alinea with the url from the banner, following your client's documentation. Clients that only launch local stdio servers can reach it through a bridge such as mcp-remote (npx mcp-remote http://localhost:4500/mcp).

Most agents connect to MCP servers when a session starts. Start alinea dev first, and restart or reload the agent's session if the tools don't show up.

The server sends these steps to agents as its instructions when they connect:

  1. Call describe_schema for the workspaces, roots, types and their fields, and the value format of each field kind.

  2. Find content, parents and ids with find_entries and get_entry.

  3. Write with create_entry and update_entry, with data keyed by field name. Rich text is Markdown, links are entry ids. Upload images and files with upload_file first to get the media id for image and file fields.

  4. Writes publish, pass publish: false to save a draft when drafts are enabled.

  5. get_entry lists the entries linking to an entry, check them before deleting or moving it.

After a batch of changes, check them in the dashboard or with git diff, and run your build to catch errors in the pages that render the content.

Tools

The examples show the arguments of a tools/call request and the JSON the tool returns. Ids are shortened for readability.

describe_schema

Describes the workspaces and roots (their locales and the types they accept), every entry type with its fields, and the value format of each field kind. Pass type to describe only that type.

  • Each field reads kind[details] Label (flags). The label is left out when it matches the key, flags are required, shared and readOnly.

  • list[...] and richText[...] name the _type of each row or block. object[name] and fields: name refer to definitions, which describe nested types once. A row type described under another name reads Row=Definition.

  • select[a|b] lists the option keys, link[entry|url] the link types (links for multiple links) and localised[en|nl] text holds a value per locale.

{}
{
  "enableDrafts": false,
  "workspaces": {
    "main": {
      "pages": {"contains": ["Page", "Blog"]},
      "media": {"media": true, "contains": ["MediaLibrary"]}
    }
  },
  "types": {
    "Blog": {
      "contains": ["BlogPost"],
      "fields": {"title": "text (required)", "path": "path (required)", "metadata": "object[metadata]"}
    },
    "BlogPost": {
      "fields": {
        "title": "text (required)",
        "path": "path (required)",
        "publishDate": "date",
        "author": "link[entry]",
        "cover": "link[image] Cover image",
        "body": "richText",
        "metadata": "object[metadata]"
      }
    }
  },
  "definitions": {
    "metadata": {"title": "text", "description": "text", "openGraph": "object[openGraph]", "...": "..."},
    "openGraph": {"image": "link[image]", "title": "text", "description": "text"}
  },
  "valueFormats": "..."
}

Describe a single type:

{"type": "BlogPost"}

find_entries

Lists entries matching filters, with the total number of matches. With parentId the entries are in their sibling order, otherwise they are ordered by url, and with search by relevance. Each entry has a children count and the path of its file.

  • workspace, root, type, locale: filter by location, type or translation.

  • parentId: only direct children of this entry, null for the top-level entries of a root.

  • search: full text search terms.

  • status: which versions to return, one of preferDraft (the default, the latest version of each entry), preferPublished, published, draft, archived or all.

  • limit (default 50) and offset for paging.

{"type": "BlogPost", "limit": 1}
{
  "total": 12,
  "entries": [
    {
      "id": "3JmZ4hN9",
      "type": "BlogPost",
      "title": "Hello world",
      "url": "/blog/hello-world",
      "parentId": "3JmZ1c0Y",
      "locale": null,
      "status": "published",
      "workspace": "main",
      "root": "pages",
      "children": 0,
      "file": "content/pages/blog/hello-world.json"
    }
  ]
}

get_entry

Reads one entry: its metadata, the path of its file, its versions, the entries linking to it (referencedBy) and its field data. Rich text is returned as Markdown, which create_entry and update_entry accept back. The stored JSON is in the entry's file.

  • id or url: the entry to read, for example {"url": "/blog/hello-world"}.

  • locale: the translation to read, defaults to the root's default locale. versions lists the locale and status of every version.

{"id": "3JmZ4hN9"}
{
  "id": "3JmZ4hN9",
  "type": "BlogPost",
  "title": "Hello world",
  "url": "/blog/hello-world",
  "parentId": "3JmZ1c0Y",
  "locale": null,
  "status": "published",
  "workspace": "main",
  "root": "pages",
  "file": "content/pages/blog/hello-world.json",
  "versions": [{"locale": null, "status": "published"}],
  "referencedBy": [
    {"id": "3JmZ5rE8", "title": "Choosing a CMS", "type": "BlogPost", "locale": null, "field": "body"}
  ],
  "data": {
    "title": "Hello world",
    "path": "hello-world",
    "publishDate": "2026-09-23",
    "author": {"_id": "3JmZ9kT2", "_type": "entry", "_entry": "3JmZ2aP5"},
    "body": "Our first post, written by [Ada](entry:3JmZ2aP5).",
    "metadata": {"title": "", "description": "", "openGraph": {"title": "", "image": {}, "description": ""}}
  }
}

create_entry

Creates an entry, or a translation of an existing entry.

  • type: the type name. It must be allowed in the parent (its contains) or, at the top level, in the root.

  • parentId: the parent entry, leave it out to create the entry at the top level of a root. The entry is added after its siblings, unless the parent type sets an insert order.

  • workspace and root: default to those of the parent, or to the first workspace and its first root.

  • locale: the locale of the entry in a translated root, defaults to the root's default locale.

  • translationOf with locale: create the translation of an existing entry. Fields you don't pass are copied from the existing translation.

  • data: field values keyed by field name. title is required, path defaults to the slugified title.

  • publish: true by default, false saves a draft.

{
  "type": "BlogPost",
  "parentId": "3JmZ1c0Y",
  "data": {
    "title": "Choosing a CMS",
    "publishDate": "2026-09-23",
    "author": "3JmZ2aP5",
    "cover": "3JmZ7xC4",
    "body": "## Why git-based\n\nContent lives next to the code, see [our first post](entry:3JmZ4hN9)."
  }
}
{
  "id": "3JmZ5rE8",
  "type": "BlogPost",
  "title": "Choosing a CMS",
  "url": "/blog/choosing-a-cms",
  "parentId": "3JmZ1c0Y",
  "locale": null,
  "status": "published",
  "workspace": "main",
  "root": "pages",
  "file": "content/pages/blog/choosing-a-cms.json"
}

Translate an entry into another locale of its root:

{
  "translationOf": "3JmZ5rE8",
  "locale": "nl",
  "data": {"title": "Een CMS kiezen", "body": "## Waarom git\n\n..."}
}

The parent must already exist in that locale. Media files can't be created this way, use upload_file.

update_entry

Changes fields of an existing entry. Only the fields in data change, everything else stays as it is. Object and localised fields merge key by key, rich text and lists are replaced, and list rows that keep their _id keep their stored values (see Lists).

  • id: the entry to change.

  • locale: the translation to change, defaults to the root's default locale.

  • data: the field values to change.

  • publish: true by default, false saves the change as a draft.

{"id": "3JmZ5rE8", "data": {"title": "How to choose a CMS", "path": "how-to-choose-a-cms"}}
{
  "id": "3JmZ5rE8",
  "type": "BlogPost",
  "title": "How to choose a CMS",
  "url": "/blog/how-to-choose-a-cms",
  "parentId": "3JmZ1c0Y",
  "locale": null,
  "status": "published",
  "workspace": "main",
  "root": "pages",
  "file": "content/pages/blog/how-to-choose-a-cms.json"
}

When the data matches what is stored, nothing is written and the result says "note": "No changes".

delete_entry

Deletes an entry with its children, in all locales. Media files are removed from disk as well. It refuses while other entries link to the entry.

  • id: the entry to delete.

  • locale: only delete this translation.

  • status: only delete this version: "draft" discards the draft, "published" or "archived" remove that version.

  • force: delete even when other entries link to it, see References and safe deletes.

{"id": "3JmZ5rE8"}
{"deleted": "3JmZ5rE8"}

publish_entry

Publishes the draft of an entry, or restores an archived entry. With archive: true it archives the published entry instead: it is hidden from the site but kept, and publishing restores it. An already published entry is left alone and the result says "note": "Already published".

  • id: the entry.

  • locale: the translation to publish.

  • archive: archive the entry instead of publishing it.

{"id": "3JmZ5rE8"}
{"id": "3JmZ5rE8", "locale": null, "status": "published"}

Archive an entry:

{"id": "3JmZ5rE8", "archive": true}
{"id": "3JmZ5rE8", "locale": null, "status": "archived"}

move_entry

Moves an entry, with its children and translations. Pass exactly one of:

  • after or before: an entry id, places the entry right after or before that sibling, under the same parent.

  • parentId: a new parent, the entry becomes its last child. null moves the entry to the top level of its root, or of another root in the same workspace given as root.

The entry's type must be allowed in its new parent.

{"id": "3JmZ5rE8", "before": "3JmZ4hN9"}

The result is the entry's summary with its new url and file.

upload_file

Adds a local file to a workspace's media library. Image dimensions, the average color, a thumbhash and a preview are computed, as in the dashboard. Images larger than the resizeImages option are scaled down first.

  • path: a local file, relative to the project directory (where alinea dev runs) or absolute inside the enclosing git repository. Hidden files such as .env, and files outside these directories, are refused.

  • workspace: the workspace to upload to, defaults to the first one. The file goes to the workspace's media root.

  • parentId: a media folder (MediaLibrary entry) to upload into.

  • title: the title of the media entry, defaults to the file name.

  • alt: the alt text, a string, or strings keyed by locale when the media root is translated.

  • rotate: turns a JPEG, PNG or WebP image clockwise by 90, 180 or 270 degrees before it is uploaded.

  • crop: the region of a JPEG, PNG or WebP image to keep, after rotating, as {"x", "y", "width", "height"} fractions from 0 to 1 of the image.

  • replace: the id of an existing media entry whose file to replace, see Media.

{"path": "assets/cover.jpg", "title": "Cover", "alt": "A desk with a laptop"}
{
  "id": "3JmZ7xC4",
  "type": "MediaFile",
  "title": "Cover",
  "url": "/admin/file/cover.jpg",
  "parentId": null,
  "locale": null,
  "status": "published",
  "workspace": "main",
  "root": "media",
  "file": "content/media/cover.json",
  "location": "/cover.3JmZ7yD5.jpg"
}

Use the returned id in image and file fields, or as ![alt](entry:3JmZ7xC4) in rich text.

Value formats

describe_schema returns these formats as valueFormats, so agents can look them up while they work.

  • Text, code, path, date and time fields: a string. Dates are ISO dates ("2026-09-23"), times "14:30". A path is the url slug and defaults to the slugified title.

  • Number: a number. Check: a boolean.

  • Select: an option key (not its label). Multiple select: an array of option keys.

  • Object fields, such as metadata: an object of nested fields, only the given keys change.

  • JSON and hidden fields: any JSON value, stored as is.

  • Values are checked against the field kind: a value of the wrong type, an unknown option or an unknown field fails with an error naming the field.

Rich text

Rich text fields accept Markdown: headings, **bold**, *italic*, ~~strike~~, links, bullet and numbered lists, > quotes, ---, tables, and images on their own line. get_entry returns rich text as Markdown too, so an agent can read a field, change it and send it back.

  • Link to an entry with [text](entry:ID), or to an anchor on its page with [text](entry:ID#anchor). Other urls become external links.

  • Insert an image from the media library with ![alt](entry:MEDIA_ID) on its own line.

  • A fenced code block becomes the first block type of the field that has a code field, with the language and other fields in the info string. Keep the id of an existing block to keep that block:

```ts id=3JmZ6pQ1 fileName=cms.ts
export const cms = createCMS({...})
```
  • Other blocks are written as an alinea-block fence holding the block's JSON:

```alinea-block
{"_type": "NoticeBlock", "level": "info", "body": "Drafts are enabled on this site."}
```
  • Inline ` code ` stays text, including its backticks: rich text has no code mark.

You can also pass stored TextDoc JSON, as found in the entry's file.

  • Entry, image and file links: the id of the entry or media entry (entry:ID works too), or {"id": "...", ...fields} to fill the link's extra fields. Images and files are media entries, upload them with upload_file first.

  • Url links: a url, or {"url": "https://example.com", "title": "Example", "target": "_self"} (the target defaults to _blank).

  • Multiple links: an array of the above. null clears a single link.

  • The stored link objects that get_entry returns are accepted as they are.

Every id is checked: linking to an entry that doesn't exist, or to an entry that isn't media where an image or file is expected, fails with an error naming the field.

Lists

Pass an array of rows, which replaces the list. Each row is an object with the _type of its row type and its fields; _type can be left out when the list has a single row type. A row with the _id of a current row updates that row, so only send the fields that change. Rows without an _id are added, rows you leave out are removed, and the array order is the new order.

This changes the title of the first row, adds a row after it and keeps the third row, any other rows are removed:

{
  "id": "3JmZ1c0Y",
  "data": {
    "sections": [
      {"_id": "3JmZ8qW1", "title": "New title"},
      {"_type": "Cta", "title": "Get started"},
      {"_id": "3JmZ8rT4"}
    ]
  }
}

Nested lists take the same form. A row's type can't be changed in place: leave the row out and add a new one.

Localised values and translations

Entries in a translated root exist once per locale. Tools read and write one translation at a time with locale, which defaults to the root's default locale, and create_entry with translationOf adds a translation. Fields declared as shared in the schema are copied to the other locales when they change.

Fields wrapped in Field.localiser hold a value per locale in a single entry. Write them as an object keyed by locale, only the given locales change:

{"id": "3JmZ3uB6", "data": {"badge": {"en": "New", "nl": "Nieuw"}}}

Media

Media files are entries of type MediaFile in a media root, created by upload_file. Their alt text is a string, or strings keyed by locale when the media root is translated, and their focus point ({"x": 0.5, "y": 0.3}, from 0 to 1) decides how images are cropped.

To swap the file of an existing media entry, for example a new version of a logo, pass its id as replace:

{"path": "assets/logo-2026.svg", "replace": "3JmZ7xC4"}

The entry keeps its id, so every image field and rich text image that links to it keeps working. The new file gets a new location and the old file is removed. The title, alt text and focus point are kept, unless you pass a new title or alt text.

Change the title, alt text or focus point without a new file with update_entry: {"id": "3JmZ7xC4", "data": {"alt": "The new logo", "focus": {"x": 0.5, "y": 0.3}}}.

Drafts and publishing

Writes are published by default. When the config sets enableDrafts: true, pass publish: false to create_entry or update_entry to save a draft instead: the published version stays live until the draft is published, from the dashboard or with publish_entry. Without drafts enabled publish: false fails. describe_schema reports enableDrafts, so agents know which to use.

Every published change is a file change in your working tree. Nothing is committed or deployed until you commit and push, or until an editor publishes from a deployed dashboard.

References and safe deletes

Entries link to each other through link fields and rich text. Before deleting, moving or replacing content, check referencedBy in the result of get_entry to see what links to it.

delete_entry checks this itself: when other entries link to the entry it refuses, and lists every entry and field that holds a link:

Entry "3JmZ2aP5" is linked from:
- Hello world (3JmZ4hN9) field author
- Choosing a CMS (3JmZ5rE8) field author
Change those links or pass force: true

Update or remove those links first, or pass force: true to delete the entry anyway and leave the links pointing to a missing entry. Links from the entry's own children count as well. Deleting only a draft, or a single translation, isn't checked.

Limitations

  • The server only runs with alinea dev on your machine. To change content on a deployed site, edit in the dashboard, or change the files locally and deploy them.

  • Changes are written to the working tree: the server doesn't commit, push or open pull requests.

  • Changes are recorded as the local dashboard user, not as the person who asked the agent.

  • Uploaded files are stored in the workspace's mediaDir in your project, like uploads in the local dashboard.

  • Schema changes are made in code: the tools read the schema but can't change it. Save cms.ts, let the dev server rebuild and call describe_schema again.

For the shape of content files, when you do need to edit them by hand, see Working with AI agents. To have an agent set up Alinea in a new project, including this server, see Set up with an AI agent.