Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

The MCP endpoint

MCP plugin @orthacms/mcp-server

The CMS as a tool provider, reached over the Model Context Protocol with an API token.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

Ortha is a Model Context Protocol server. An external agent connects with an API token and does content work through tools.

POST /api/v1/mcp
Authorization: Bearer orthacms_…
X-Workspace-Id: 7f1c…                       (or ?workspaceId=7f1c…)
Accept: application/json, text/event-stream
Content-Type: application/json

This is the opposite direction from the copilot: MCP is the product exposed as tools to something outside it.

Turn it on

MCP_ENABLED=true                 # default false
MCP_CALL_TIMEOUT_MS=30000        # optional: ceiling on one tools/call
MCP_MAX_RESULT_BYTES=4194304     # optional: ceiling on one tool result
  • Off → the route is not registered and answers 404.
  • In a generated app the plugin is registered only if MCP was picked in the wizard (McpPlugin({ config: config.plugins.mcp }), last in plugins.ts).
  • Tokens, scopes and workspace buckets don’t change — this only mounts the door.

Mint a token

Admin → API Tokens → New token. Pick:

ScopeCarriesFor
readcontent:read, media:read, segments:readRetrieval-only agents
fullread + content create/update/publish/delete, media:create, segments:manageAgents that author

No scope carries content:approve. See API tokens.

Connect a client

Example language
claude mcp add --transport http orthacms \
'https://cms.example.com/api/v1/mcp?workspaceId=7f1c…' \
--header 'Authorization: Bearer orthacms_…'
{
  "mcpServers": {
      "orthacms": {
          "type": "http",
          "url": "https://cms.example.com/api/v1/mcp?workspaceId=7f1c…",
          "headers": { "Authorization": "Bearer ${ORTHACMS_TOKEN}" }
      }
  }
}
{
  "mcpServers": {
      "orthacms": {
          "command": "npx",
          "args": [
              "-y",
              "mcp-remote",
              "https://cms.example.com/api/v1/mcp?workspaceId=7f1c…",
              "--header",
              "Authorization: Bearer orthacms_…"
          ]
      }
  }
}
{
  "url": "https://cms.example.com/api/v1/mcp",
  "headers": {
      "Authorization": "Bearer orthacms_…",
      "X-Workspace-Id": "7f1c…"
  }
}
  • Claude Desktop speaks stdio to local servers, so it reaches the endpoint through mcp-remote (in claude_desktop_config.json).
  • Generic — any client that supports Streamable HTTP with custom headers. The key names around url / headers vary by client.
  • Workspace: optional when the token covers one workspace; required when it covers several. ?workspaceId= exists for clients that can’t set headers; the header wins if both are sent.

Raw requests

The endpoint is stateless Streamable HTTP: no session, no initialize needed, JSON responses (no SSE stream). Every request authenticates from scratch.

Example language
curl https://cms.example.com/api/v1/mcp \
-H 'Authorization: Bearer orthacms_…' \
-H 'X-Workspace-Id: 7f1c…' \
-H 'Accept: application/json, text/event-stream' \
-H 'Content-Type: application/json' \
-d '{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "content_list",
    "arguments": { "typeName": "article", "fields": "title,slug", "pageSize": 2 }
  }
}'
const response = await fetch('https://cms.example.com/api/v1/mcp', {
  method: 'POST',
  headers: {
      Authorization: 'Bearer ' + process.env.ORTHACMS_TOKEN,
      'X-Workspace-Id': process.env.ORTHACMS_WORKSPACE_ID,
      Accept: 'application/json, text/event-stream',
      'Content-Type': 'application/json'
  },
  body: JSON.stringify({
      jsonrpc: '2.0',
      id: 1,
      method: 'tools/call',
      params: {
          name: 'content_list',
          arguments: { typeName: 'article', fields: 'title,slug', pageSize: 2 }
      }
  })
});
const { result } = await response.json();
const data = JSON.parse(result.content[0].text);

A result comes back twice — as text for the model, and as structuredContent:

{
    "jsonrpc": "2.0",
    "id": 1,
    "result": {
        "content": [{ "type": "text", "text": "{\n  \"items\": [ … ],\n  \"total\": 42, … }" }],
        "structuredContent": {
            "items": [
                {
                    "id": "0b9e…",
                    "status": "published",
                    "createdAt": "2026-09-01T08:00:00.000Z",
                    "updatedAt": "2026-09-28T14:03:11.000Z",
                    "publishedAt": "2026-09-28T14:03:11.000Z",
                    "values": { "title": "Pricing, explained", "slug": "pricing-explained" },
                    "source": null
                }
            ],
            "total": 42,
            "page": 1,
            "pageSize": 2
        }
    }
}

tools/list returns each tool’s name, title, description, inputSchema and annotations (readOnlyHint, destructiveHint). resources/list offers each content type’s schema as orthacms://content-type/<name>.

Example calls

A typical authoring session: discover, read the schema, write, publish.

// tools/call content_types_list  {}
{ "items": [
    { "name": "article", "kind": "collection", "label": "Articles",
      "publishable": true, "paranoid": true, "i18n": false }
] }

// tools/call content_type_get  { "typeName": "article" }
// → every field with its type, rules and options, as JSON Schema

// tools/call content_create
{ "typeName": "article", "values": { "title": "Launch notes", "slug": "launch-notes" } }
// → the new entry, as a draft — read it back with "status": "any"

// tools/call content_update  (partial: omitted fields are kept, null clears)
{ "typeName": "article", "id": "0b9e…", "values": { "summary": "What shipped in 0.7.0." } }

// tools/call content_publish
{ "typeName": "article", "id": "0b9e…" }

Errors come back as results

A tool failure is a result with isError: true, so the model can read it and fix its next call. The text is { status, code, message, issues? }:

{
    "jsonrpc": "2.0",
    "id": 3,
    "result": {
        "isError": true,
        "content": [{ "type": "text", "text": "{ \"status\": 422, \"code\": \"validation_failed\", … }" }]
    }
}
Casestatus / codemessage
Bad arguments422 validation_failedInvalid arguments for "content_list". + issues: [{ field, message }]
Scope too narrow403 forbidden"content_update" requires content:update, which this token does not hold.
Copilot-only or unknown tool404 not_foundUnknown tool "content_propose_update".
Draft asked for on a read token403 forbiddenstatus=draft requires a token with write scope; …
Too slow504 timeout"…" did not finish within 30000ms and was abandoned. Retry, or narrow the request.
Too large413 result_too_large"…" returned N bytes, over this endpoint's … limit. Ask for less — …
Publish blocked by a review rule409 conflictThe protection rule’s refusal

Transport-level failures are plain HTTP, before any JSON-RPC:

StatusWhen
401No token, unknown, revoked or expired token — or a session cookie instead of a token
400Token covers several workspaces and none was named; a malformed id; ?workspaceId= repeated
403This token does not cover that workspace.
405GET / DELETE — the endpoint serves POST only (checked after auth)

The tools

Twenty-six, flat. The sixteen content tools take typeName as an argument rather than being generated per type — an MCP client loads every schema into context on connect, and per-type tools would scale that cost with your model.

ToolDoes
content_types_list, content_type_getDiscover the model
content_list, content_getRead entries
content_relations, content_media, content_translationsAn entry’s links, assets and siblings
content_create, content_updateWrite (lands as draft on a publishable type)
content_publish, content_unpublishPublish state
content_deleteDelete
content_bulk_save, content_bulk_publish, content_bulk_unpublish, content_bulk_deleteBatch
i18n_locales_listConfigured locales
media_assets_search, media_folders_list, media_asset_readThe media library
segments_list, content_access_getSegments, and who may read one entry
content_access_setReplace who may read one entry (all its locales)
protection_review_status, protection_review_diffWhere a review stands; what changed since approval
protection_request_reviewAsk named members to review an entry
  • Reads return published entries by default. A full token may pass status: "draft" or "any"; a read token never can.
  • Every call goes through the same visibility rules, validation, revision numbering and audit trail as the equivalent REST call.
  • Copilot-only tools (admin_*, every propose tool) are neither listed nor callable here. See surfaces.

Shared workspaces

Since 0.7.0 the content tools read published records of shared workspaces, read-only.

// tools/call content_list
{ "typeName": "author", "source": "shared" }
// items carry "source": { "workspaceId": "…", "workspaceName": "Brand library" }

// tools/call content_get  { "typeName": "author", "id": "<shared id>" }
{ "id": "…", "values": { "name": "Ada" },
  "source": { "workspaceId": "…", "workspaceName": "Brand library" }, "readOnly": true }
  • source on content_list: "own" (default), "shared", "all". Own items report source: null.
  • content_get resolves a visible shared id directly, with readOnly: true.
  • Link, don’t copy. A relation or single-relation value may point at a shared record:
// tools/call content_create
{ "typeName": "article",
  "values": { "title": "Guest post", "author": "<shared author id>" },
  "relations": { "tags": { "link": ["<own tag id>", "<shared tag id>"] } } }
  • Every write to a shared record is refused:
{ "status": 403, "code": "forbidden",
  "message": "This record belongs to the shared workspace \"Brand library\" and is read-only here. To use a shared record, link to it by id from a record in this workspace; never create a local copy." }
  • A bulk write naming any shared id changes nothing and lists the ids.
  • A type the workspace can only use from shared sources refuses content_create: This workspace can only use "Authors" records from shared workspaces; it cannot create its own.
  • A foreign draft, or a record of a workspace not shared with you, reads as plain not_found.

An MCP agent can publish and delete; the copilot cannot

The copilot has no publish tool at any role. MCP does expose publish, unpublish, delete and their bulk forms — the caller is a credential an operator minted, with no interactive user to prompt.

A full token is a credential that can publish and delete across every workspace in its bucket. Mint narrowly; prefer read.

Publication protection still applies: on a type with a review rule, a publish by token is refused (409) unless the rule allows token publishing.

Architecture

  • The MCP package owns only the protocol: endpoint, bearer auth, workspace resolution, JSON-RPC. No content knowledge.
  • Tools live in the plugins that own the data and register into the shared registry. Adding one needs no change to the MCP package — see agent tools.
  • The server identifies itself as orthacms.

Ortha is an MCP server, not an MCP client

Agents call into Ortha. The copilot cannot call out to your own MCP servers — that is a separate, planned feature that does not exist yet.