The Model Context Protocol endpoint. An external agent connects with an API token and gets the CMS’s content tools.
POST /api/v1/mcp stateless Streamable HTTP, JSON responsesThe plugin owns the protocol only — endpoint, bearer auth, workspace
resolution, JSON-RPC. The tools live in the plugins that own the data and in
the shared registry (@orthacms/tools-server) the copilot
also uses.
Install and turn on
npm install @orthacms/mcp-server// apps/server/src/plugins.ts — register LAST: it serves what the plugins above contributed
import { McpPlugin } from '@orthacms/mcp-server';
McpPlugin({ config: config.plugins.mcp });MCP_ENABLED=true # default false — off, /api/v1/mcp is a 404
MCP_CALL_TIMEOUT_MS=30000 # ceiling on one tools/call
MCP_MAX_RESULT_BYTES=4194304 # ceiling on one tool resultapps/server/config/mcp.ts also sets name: 'orthacms' and version, the
identity clients display. In create-orthacms-app, MCP is an opt-in protocol,
unticked by default.
On, it hands an agent what the token can do
A full token lets an agent create, publish and delete content across its
workspaces. Give retrieval-only agents a read token.
Connect a client
| Header / param | When |
|---|---|
Authorization: Bearer orthacms_… | Always. A session cookie is not accepted |
X-Workspace-Id: <uuid> or ?workspaceId=<uuid> | When the token covers several workspaces. The header wins |
X-Reader-Tags: premium | To read as a segment reader |
A client that speaks Streamable HTTP (exact keys vary by client):
{
"mcpServers": {
"orthacms": {
"type": "http",
"url": "https://cms.example.com/api/v1/mcp",
"headers": {
"Authorization": "Bearer orthacms_…",
"X-Workspace-Id": "6b1f0c2e-8a4d-4f7b-9c3e-2d5a7e9f1b40"
}
}
}
}A stdio-only client, through mcp-remote:
{
"mcpServers": {
"orthacms": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"https://cms.example.com/api/v1/mcp?workspaceId=6b1f0c2e-8a4d-4f7b-9c3e-2d5a7e9f1b40",
"--header",
"Authorization: Bearer orthacms_…"
]
}
}
}Call it by hand
No session and no initialize needed — every request authenticates on its own.
Send Accept: application/json, text/event-stream.
curl -X POST https://cms.example.com/api/v1/mcp \
-H 'Authorization: Bearer orthacms_…' \
-H 'Accept: application/json, text/event-stream' \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' curl -X POST https://cms.example.com/api/v1/mcp \
-H 'Authorization: Bearer orthacms_…' \
-H 'Accept: application/json, text/event-stream' \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"content_list","arguments":{"typeName":"article","fields":"title,slug","pageSize":5,"locale":"de"}}}' async function callTool(name, args) {
const res = await fetch('https://cms.example.com/api/v1/mcp', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.ORTHACMS_TOKEN,
Accept: 'application/json, text/event-stream',
'Content-Type': 'application/json'
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name, arguments: args }
})
});
const { result } = await res.json();
const data = JSON.parse(result.content[0].text);
if (result.isError) throw new Error(data.message);
return data;
}
const draft = await callTool('content_create', {
typeName: 'article',
values: { title: 'Autumn catalogue' }
});
await callTool('content_publish', { typeName: 'article', id: draft.id }); A result carries the value twice — as JSON text and as structuredContent:
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"content": [{ "type": "text", "text": "{\"items\":[…],\"total\":42,\"page\":1,\"pageSize\":5}" }],
"structuredContent": { "items": [], "total": 42, "page": 1, "pageSize": 5 }
}
}A tool failure is a result the model can read, not a transport error:
{
"result": {
"isError": true,
"content": [{ "type": "text", "text": "{\"status\":504,\"code\":\"timeout\",\"message\":\"\\\"content_list\\\" did not finish within 30000ms and was abandoned. Retry, or narrow the request.\"}" }]
}
}| Situation | Answer |
|---|---|
| Bad or missing token | HTTP 401 — before any JSON-RPC |
GET or DELETE | HTTP 405 (after auth) — stateless, no stream, no session |
| Workspace outside the token | HTTP 403 |
?workspaceId repeated | HTTP 400 |
| Body over 1 MB | HTTP 413 |
Call past MCP_CALL_TIMEOUT_MS | isError, code: "timeout" |
Result over MCP_MAX_RESULT_BYTES | isError, code: "result_too_large" |
The timeout abandons the call; a tool that ignores the abort still runs to the end.
Tools
Twenty-six. tools/list shows only those whose permissions the token’s scope
holds.
| Plugin | Tools |
|---|---|
| Content — 16 | content_types_list, content_type_get, content_list, content_get, content_relations, content_media, content_translations, content_create, content_update, content_publish, content_unpublish, content_delete, content_bulk_save, content_bulk_publish, content_bulk_unpublish, content_bulk_delete |
| i18n | i18n_locales_list |
| Media | media_assets_search, media_folders_list, media_asset_read |
| Segments | segments_list, content_access_get, content_access_set |
| Protection | protection_review_status, protection_review_diff, protection_request_review |
- Content reads serve published entries.
status: "draft"or"any"needs a full token. A create is a draft — read it back withstatus: "any". - Since 0.7.0,
content_listtakessource: "own" | "shared" | "all"(defaultown). Items from a shared workspace carrysource: { workspaceId, workspaceName };content_getmarks themreadOnly: true. Link them by id — writing one is a403. - The content tools and
content_access_setare MCP-only. The locale, media, segment-read and protection tools are shared with the copilot. - No tool approves a review or sends mail.
Each content type’s schema is also an MCP resource,
orthacms://content-type/<name> (resources/list, resources/read).
See MCP for the agent-side guide and agent tools for adding a tool.