MCP endpoint
The CMS speaks the Model Context Protocol at https://cms.nubisco.io/mcp, so an AI agent (Claude Code, claude.ai connectors, or anything MCP-capable) can read and edit content with the same rules the console enforces.
What it is
A stateless Streamable HTTP MCP server: every request is one JSON-RPC message over POST, answered with JSON. There are no sessions, no SSE streams and no server-initiated messages. The server exposes tools only (no resources, no prompts).
Every tool is a thin wrapper over an existing console API handler. Access control is the handler's: your role decides what a tool may do, staging is where editing happens, and production changes only through releases. A tool call an agent makes is never more powerful than the same person clicking the console.
Authentication
Two ways in, one door: both end as Authorization: Bearer ... and act as a real CMS user with their role, memberships resolved fresh on every request.
Personal tokens (nbcms_...) are minted in the console from your avatar menu → Access tokens, shown once, stored hashed, revocable instantly. Right for CLIs and scripts where you can paste a header.
OAuth 2.1 is for connector UIs that refuse pasted tokens (ChatGPT, claude.ai). The CMS is its own authorization server: clients discover it via the /mcp 401's resource_metadata pointer, self-register (RFC 7591, public clients only), send you through a one-click consent page that rides your console session, and exchange the code with PKCE (S256 only). Access tokens last an hour; refresh tokens rotate on every use.
Connecting
Claude Code:
claude mcp add nubisco-cms https://cms.nubisco.io/mcp \
--transport http \
--header "Authorization: Bearer nbcms_..."ChatGPT (needs a paid plan, and Developer Mode enabled under Settings → Apps & Connectors → Advanced): create a custom connector with URL https://cms.nubisco.io/mcp and authentication OAuth. ChatGPT registers itself and opens the CMS consent page; be signed in to the console in the same browser and click Connect.
Any other client: transport streamable-http, URL https://cms.nubisco.io/mcp, and either the bearer header or the OAuth flow above.
Tools
| Tool | Does | Needs |
|---|---|---|
list_sites | Sites, environments (the envIds), your role | viewer |
list_pages | Routes, titles, types, published flags | viewer |
get_page | The draft (or published document) plus its rev | viewer |
save_page_draft | Write a draft; nothing goes live | editor |
submit_for_review | Mark the draft in_review | editor |
list_versions | Draft and approval history | viewer |
approve_version | Approve an in-review version | admin |
set_page_published | Publish or unpublish approved content | admin |
list_types | Block and content type schemas | viewer |
list_strings | UI strings and translations, filterable | viewer |
update_strings / create_strings | Up to 100 strings per call | editor |
create_string / update_string | One string at a time | editor |
list_media | The media library | viewer |
list_releases / get_release | Release state | viewer |
Concurrency is honest: get_page returns a rev, save_page_draft takes it back as baseRev, and a conflicting save returns a conflict instead of overwriting someone's work. Pass force: true only after a human decision.
Translating a locale
The catalog of a real site is bigger than a context window, and one call per string is hundreds of calls. Both have a shape that avoids the problem.
Read the worklist, not the catalog. list_strings with no filters returns every string in every locale with all its metadata; on the corporate site that is ~49k tokens. Ask the question you actually have instead:
{ "name": "list_strings", "arguments": {
"siteId": "nubisco-corporate",
"missingLocale": "pt",
"locales": ["en"],
"limit": 500
} }Only strings with nothing usable in pt, carrying only their English source: ~20k tokens for the same 515 strings. total is the size of the whole worklist so you can budget your calls before spending them, and nextCursor pages through it (pass it back as cursor; null means you are done).
"Missing" means what a reader would see: an absent locale, an empty string and an empty list all count.
Write them back in batches. Up to 100 per call:
{ "name": "update_strings", "arguments": {
"siteId": "nubisco-corporate",
"ops": [
{ "id": "str_1", "op_id": "pt-str_1", "translations": { "pt": "Olá" }, "if_source_rev": 3 },
{ "id": "str_2", "op_id": "pt-str_2", "translations": { "pt": "Adeus" }, "if_source_rev": 1 }
]
} }500 strings is one read and five writes.
Three things make that safe to run unattended:
- Merge, never replace.
{"pt": "..."}leavesenexactly where it was. - Partial failure. Each op is applied and answered on its own, so one deleted id does not roll back the other ninety-nine. Read
results:{ id, ok, error? }per op, plusappliedandfailed. - Safe retries. Give each op an
op_idand a retry after a timeout replays the recorded result instead of writing twice (idempotent: truesays so). Ids are yours to choose and are scoped per site.
if_source_rev is optional and worth passing: a translation is only correct for the words it was made from, so if someone rewords the English while you are working, the write is refused with the new revision rather than landing a translation of wording nobody uses.
Check your work
list_strings with missingLocale and no other filters returns total: 0 when the locale is complete. That is the end condition, and it costs one call.
What is deliberately absent
Uploading media (multipart does not fit a JSON-RPC tool well; use the console), deleting anything, tenant and site administration, and release publishing. The bar for adding a tool is that an agent doing routine content work needs it, not that the API has it.