Skip to content

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:

bash
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

ToolDoesNeeds
list_sitesSites, environments (the envIds), your roleviewer
list_pagesRoutes, titles, types, published flagsviewer
get_pageThe draft (or published document) plus its revviewer
save_page_draftWrite a draft; nothing goes liveeditor
submit_for_reviewMark the draft in_revieweditor
list_versionsDraft and approval historyviewer
approve_versionApprove an in-review versionadmin
set_page_publishedPublish or unpublish approved contentadmin
list_typesBlock and content type schemasviewer
list_stringsUI strings and translations, filterableviewer
update_strings / create_stringsUp to 100 strings per calleditor
create_string / update_stringOne string at a timeeditor
list_mediaThe media libraryviewer
list_releases / get_releaseRelease stateviewer

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:

json
{ "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:

json
{ "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": "..."} leaves en exactly 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, plus applied and failed.
  • Safe retries. Give each op an op_id and a retry after a timeout replays the recorded result instead of writing twice (idempotent: true says 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.

A Nubisco product. Not open source.