@qatobit/cms-mcp
v0.1.1
Published
MCP server over the Qatobit CMS API. Lets Claude read, write, publish and schedule content without touching the repo.
Readme
Qatobit CMS — MCP server
An MCP server that lets Claude read, write, publish and schedule Qatobit content.
It is a client of the CMS HTTP API, not of the repository. It holds a scoped
API key and goes through /api/cms/v1/* exactly as the admin UI does, so every
write it makes passes the same key scope check, Zod validation, brand-voice
check and optimistic lock. Importing lib/cms directly would have been shorter
and would have created a second write path that skips all of them.
What it exposes
Resource
| URI | What |
|---|---|
| qatobit://brand/design-tokens | The palette, type scale, radii, shadows, layout rhythm and copy rules. Attached to the conversation so a colour is looked up rather than invented. |
Tools
| Tool | Scope needed | Notes |
|---|---|---|
| list_content | content:read | Page and filter one type. Two cursor kinds, and they do not mix. |
| get_content | content:read | One item in full, including the version an update needs. |
| search_content | content:read | All three types at once. Metadata only, never body text. |
| list_types | content:read | Probes the live API; a type that is not wired up says so. |
| get_schema | content:read | The JSON Schema the API actually validates against. |
| create_content | content:write | Creates a draft. Never creates something live. |
| update_content | content:write | Needs the version. A 409 comes back with the current one. |
| bulk_import | content:write | Dry run first, always. The confirm token pins the reviewed payload. |
| upload_image | media:write | Returns permanent URLs immediately; derivatives follow. |
| delete_image | media:write | Retracts an upload. Idempotent. Says whether the bytes actually went. |
| publish | publish | Live within about a minute. Returns the public URL. |
| schedule | publish | Book a publish, or cancel one. UTC. |
| unpublish | publish | Take down now, or book an expiry. |
publish is a separate scope from content:write on purpose. A key can be
issued that stages content it cannot put in front of readers, and that gap is
where review happens. A 403 from publish while create_content works is the
design, not a misconfiguration.
Install
Nothing to install for normal use: the server is published as
@qatobit/cms-mcp and npx
fetches it on first run. Node 20 or newer is the only requirement, and a
machine that has never cloned this repository can run it.
To work on the server itself, build from source:
cd mcp
npm install
npm run buildConfigure
| Variable | Required | Default | Notes |
|---|---|---|---|
| QATOBIT_CMS_KEY | yes | — | qb_live_…. The server refuses to start without it. |
| QATOBIT_CMS_URL | no | https://qatobit.com | An origin or a full API base; both work. |
| QATOBIT_CMS_TIMEOUT_MS | no | 30000 | Clamped to 1s–180s. |
Create a local key with the scopes you want it to have:
npm run cms:key -- --label claude-mcp --scopes content:read,content:write,media:write,publishThe plaintext is printed once and is not recoverable. Give the key the narrowest
set of scopes the work needs. A key without publish is the useful default
while you are still reviewing what Claude writes.
Register it
Claude Code — .mcp.json at a project root
{
"mcpServers": {
"qatobit-cms": {
"command": "npx",
"args": ["-y", "@qatobit/[email protected]"],
"env": {
"QATOBIT_CMS_URL": "https://qatobit.com",
"QATOBIT_CMS_KEY": "${QATOBIT_CMS_KEY}"
}
}
}
}.mcp.json is checked in and shared, so keep the key out of it: Claude Code
expands ${VAR} from your shell environment, which leaves the file safe to
commit. Put the literal key in only if the file is ignored.
Working against the local dev stack, set QATOBIT_CMS_URL to
http://localhost:8946 instead.
Claude Desktop — claude_desktop_config.json
macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
Windows: %APPDATA%\Claude\claude_desktop_config.json
Same block. Claude Desktop has no project environment to expand ${VAR} from,
so the literal key goes in the file; treat that file as a secret.
{
"mcpServers": {
"qatobit-cms": {
"command": "npx",
"args": ["-y", "@qatobit/[email protected]"],
"env": {
"QATOBIT_CMS_URL": "https://qatobit.com",
"QATOBIT_CMS_KEY": "qb_live_replace_me"
}
}
}
}From source, for working on the server itself
Build first (npm --prefix mcp install && npm --prefix mcp run build), then
point at the build output instead of the package:
{
"command": "node",
"args": ["/absolute/path/to/mcp/dist/index.js"]
}Or skip the build with npx tsx /absolute/path/to/mcp/src/index.ts.
Publishing a new version
Bump version in mcp/package.json, npm run build, then npm publish from
mcp/ (needs npm 2FA and membership of the qatobit org).
Then bump the pin, and only then. The documented configs name an exact
version, which now lives in one place: MCP_PACKAGE in
app/admin/_lib/mcp-package.ts, which both the Keys page and the connect guide
read. Move it after the publish succeeds, never before, or the admin page hands
out an install that resolves to nothing. The two JSON blocks above are prose and
have to be edited by hand.
Operators do not pick up a new version until that pin moves, which means a fix
to mcp/src is not live for anybody until it is published. delete_image was
written, merged and documented while every installed copy carried on without it,
because 0.1.0 was published the day before and nothing republished it.
That is deliberate, and it is the point. This used to say npx -y "picks up the
new version on its next cold run", which describes the problem rather than a
feature: every machine running this server hands it a qb_live_ key with write
and publish scopes, so an unpinned install means whatever the registry serves next
executes with a credential that can change the live site. One compromised npm
account, or one mistaken publish, and it runs everywhere on the next cold start
with nobody having agreed to it (2026-07-30 audit, M-9 / H-1).
An extra step at release time is the right price for an upgrade being a decision.
Verify
npm run smokeSpawns the server as a client would and speaks raw JSON-RPC to it: initialize,
tools/list, resources/list, and a read of the design tokens. It checks all
twelve tools are present and that the tokens carry the brand orange. Nothing
here needs the CMS to be running.
To exercise a tool against a live CMS:
QATOBIT_CMS_URL=http://localhost:8946 \
QATOBIT_CMS_KEY=qb_live_… \
node scripts/smoke.mjs --call list_types
node scripts/smoke.mjs --call get_content '{"type":"glossary","slug":"apr-vs-apy-staking"}'Against the local stack, start it first: npm run cms:up && npm run cms:setup
in the repository root, then npm run cms:dev for the API on port 8946.
How to drive it
search_content— is this already covered?- Read
qatobit://brand/design-tokens— the voice rules are enforced server-side and a write that breaks a lexical one comes back 422 with the offending span and its character offsets. get_schema— the field list for the type.create_content, thenpublishorschedule.
Updating an existing item needs its version, from get_content. Two writers
is the normal case here, so a write without one would be last-write-wins wearing
the clothes of a safe update. If the item moved underneath you the API answers
409 with the current version; re-read, merge, retry.
A genuine brand-voice false positive, such as an em-dash inside a quoted source,
can be waived with the voice_override argument. The reason is recorded on the
item and shown in the review queue, so it is a documented exception rather than
a silent bypass.
Notes
- All times are UTC. Marketing thinks in IST (+05:30, no daylight saving); the conversion belongs wherever a human types a date.
- A published page appears within about a minute, and a scheduled one within about two. Time-based revalidation is the only refresh mechanism Amplify supports, so this is a property of the hosting, not of the CMS.
- Everything the server prints goes to stderr. stdout carries the protocol.
