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/jsonThis 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 inplugins.ts). - Tokens, scopes and workspace buckets don’t change — this only mounts the door.
Mint a token
Admin → API Tokens → New token. Pick:
| Scope | Carries | For |
|---|---|---|
read | content:read, media:read, segments:read | Retrieval-only agents |
full | read + content create/update/publish/delete, media:create, segments:manage | Agents that author |
No scope carries content:approve. See API tokens.
Connect a client
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(inclaude_desktop_config.json). - Generic — any client that supports Streamable HTTP with custom headers.
The key names around
url/headersvary 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.
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\", … }" }]
}
}| Case | status / code | message |
|---|---|---|
| Bad arguments | 422 validation_failed | Invalid arguments for "content_list". + issues: [{ field, message }] |
| Scope too narrow | 403 forbidden | "content_update" requires content:update, which this token does not hold. |
| Copilot-only or unknown tool | 404 not_found | Unknown tool "content_propose_update". |
Draft asked for on a read token | 403 forbidden | status=draft requires a token with write scope; … |
| Too slow | 504 timeout | "…" did not finish within 30000ms and was abandoned. Retry, or narrow the request. |
| Too large | 413 result_too_large | "…" returned N bytes, over this endpoint's … limit. Ask for less — … |
| Publish blocked by a review rule | 409 conflict | The protection rule’s refusal |
Transport-level failures are plain HTTP, before any JSON-RPC:
| Status | When |
|---|---|
401 | No token, unknown, revoked or expired token — or a session cookie instead of a token |
400 | Token covers several workspaces and none was named; a malformed id; ?workspaceId= repeated |
403 | This token does not cover that workspace. |
405 | GET / 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.
| Tool | Does |
|---|---|
content_types_list, content_type_get | Discover the model |
content_list, content_get | Read entries |
content_relations, content_media, content_translations | An entry’s links, assets and siblings |
content_create, content_update | Write (lands as draft on a publishable type) |
content_publish, content_unpublish | Publish state |
content_delete | Delete |
content_bulk_save, content_bulk_publish, content_bulk_unpublish, content_bulk_delete | Batch |
i18n_locales_list | Configured locales |
media_assets_search, media_folders_list, media_asset_read | The media library |
segments_list, content_access_get | Segments, and who may read one entry |
content_access_set | Replace who may read one entry (all its locales) |
protection_review_status, protection_review_diff | Where a review stands; what changed since approval |
protection_request_review | Ask named members to review an entry |
- Reads return published entries by default. A
fulltoken may passstatus: "draft"or"any"; areadtoken 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_*, everyproposetool) 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 }sourceoncontent_list:"own"(default),"shared","all". Own items reportsource: null.content_getresolves a visible shared id directly, withreadOnly: 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.