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:
ɑ Alinea 2.0.0
├ dev src/cms.tsx in 690ms (172 records)
╰ MCP server: http://localhost:4500/mcpThe 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
claude mcp add --transport http alinea http://localhost:4500/mcpAdd --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:
{
"mcpServers": {
"alinea": {
"type": "http",
"url": "http://localhost:4500/mcp"
}
}
}Cursor
Cursor reads project servers from .cursor/mcp.json, where the url alone is enough:
{
"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.
Recommended workflow
The server sends these steps to agents as its instructions when they connect:
Call
describe_schemafor the workspaces, roots, types and their fields, and the value format of each field kind.Find content, parents and ids with
find_entriesandget_entry.Write with
create_entryandupdate_entry, withdatakeyed by field name. Rich text is Markdown, links are entry ids. Upload images and files withupload_filefirst to get the media id for image and file fields.Writes publish, pass
publish: falseto save a draft when drafts are enabled.get_entrylists 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 arerequired,sharedandreadOnly.list[...]andrichText[...]name the_typeof each row or block.object[name]andfields: namerefer todefinitions, which describe nested types once. A row type described under another name readsRow=Definition.select[a|b]lists the option keys,link[entry|url]the link types (linksfor multiple links) andlocalised[en|nl] textholds 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,nullfor the top-level entries of a root.search: full text search terms.status: which versions to return, one ofpreferDraft(the default, the latest version of each entry),preferPublished,published,draft,archivedorall.limit(default 50) andoffsetfor 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.
idorurl: the entry to read, for example{"url": "/blog/hello-world"}.locale: the translation to read, defaults to the root's default locale.versionslists 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 (itscontains) 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.workspaceandroot: 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.translationOfwithlocale: 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.titleis required,pathdefaults to the slugified title.publish:trueby default,falsesaves 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:trueby default,falsesaves 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:
afterorbefore: 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.nullmoves the entry to the top level of its root, or of another root in the same workspace given asroot.
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 (wherealinea devruns) 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 (MediaLibraryentry) 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  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
on its own line.A fenced code block becomes the first block type of the field that has a
codefield, with the language and other fields in the info string. Keep theidof an existing block to keep that block:
```ts id=3JmZ6pQ1 fileName=cms.ts
export const cms = createCMS({...})
```Other blocks are written as an
alinea-blockfence 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.
Links
Entry, image and file links: the id of the entry or media entry (
entry:IDworks too), or{"id": "...", ...fields}to fill the link's extra fields. Images and files are media entries, upload them withupload_filefirst.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.
nullclears a single link.The stored link objects that
get_entryreturns 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: trueUpdate 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 devon 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
mediaDirin 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 calldescribe_schemaagain.
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.