Spillway CMSStructured content like Drupal. Page building like Puck. Setup like Ghost. AI that drafts, people who approve.
Menu

MCP server and API tokens

Spillway ships a Model Context Protocol server at POST /mcp, so Claude, ChatGPT and other agents can read the content model, draft pages, run the pre-publish checks and hand drafts to a human editor. The same bearer tokens also work on the REST API, so a script can do anything the admin can within the permissions the token holds.

The governance rule is short: agents draft, humans publish.

1. Create an API token

  1. Sign in to the admin, open the account menu (top right) and choose API tokens.
  2. Create token: give it a label (where it will live), pick an expiry and tick the permissions. The default preset, Drafts only, is what an agent needs: view drafts, create, edit own, submit for review, upload media, use AI. Leave content.publish off unless you really want the agent to publish.
  3. Copy the token. It looks like spw_a1B2c3D4_… and is shown once; Spillway stores only its SHA-256 hash.

Rules:

  • A token can only hold permissions you have, and its effective permissions are always the intersection of the token's set and your current roles. Lose a role and every token you own loses those permissions too.
  • Tokens are created and revoked from a browser session only; a token cannot mint or revoke tokens.
  • Every bearer request is written to the audit log with the token id; every MCP tool call adds a second row naming the tool.
  • Revoke a token any time from the same screen. Expired or revoked tokens get 401 invalid_token.
  • Users with users.manage can list and revoke everyone's tokens (GET /api/tokens?all=true).

REST endpoints: GET /api/tokens, POST /api/tokens ({ label, permissions?, expiresInDays? } → { token, secret }), DELETE /api/tokens/:id.

Using a token on the REST API

curl -H "Authorization: Bearer spw_…" https://cms.example.org/api/auth/me
curl -H "Authorization: Bearer spw_…" -H 'Content-Type: application/json' \
  -d '{"type":"page","title":"Open day","data":{}}' https://cms.example.org/api/entities

Bearer requests do not need the X-Requested-With: spillway CSRF header; the cookie session still does.

2. Connect a client

The server speaks MCP over Streamable HTTP in its stateless form: every request is a JSON-RPC 2.0 message to POST /mcp with the bearer token, and every response is JSON. GET /mcp (the optional server-to-client SSE stream) is not implemented and answers 405. There are no sessions to resume; a client may reconnect at any time with the same token.

Replace https://cms.example.org with your site's BASE_URL and spw_… with your token.

Claude Code

claude mcp add --transport http spillway https://cms.example.org/mcp \
  --header "Authorization: Bearer spw_…"

Or in .mcp.json at the root of a project (the token can come from the environment):

{
  "mcpServers": {
    "spillway": {
      "type": "http",
      "url": "https://cms.example.org/mcp",
      "headers": { "Authorization": "Bearer ${SPILLWAY_TOKEN}" }
    }
  }
}

Then, inside Claude Code: /mcp shows the connection, and @spillway:spillway://types or the write-page prompt (/spillway:write-page) put the content model or the drafting workflow into the conversation.

Claude Desktop

Claude Desktop's Settings → Connectors → Add custom connector takes a remote MCP URL but authenticates with OAuth, not a static header. Bridge the gap with mcp-remote, which runs locally over stdio and forwards to /mcp with your header. In claude_desktop_config.json (Settings → Developer → Edit Config):

{
  "mcpServers": {
    "spillway": {
      "command": "npx",
      "args": [
        "-y", "mcp-remote",
        "https://cms.example.org/mcp",
        "--header", "Authorization:${SPILLWAY_AUTH}"
      ],
      "env": { "SPILLWAY_AUTH": "Bearer spw_…" }
    }
  }
}

Restart Claude Desktop; the Spillway tools appear under the tools icon. (The header value is passed through an environment variable because some shells split on the space in Bearer spw_….)

ChatGPT

  • ChatGPT app: custom connectors (Settings → Connectors → Create, developer mode) accept an MCP server URL with either OAuth or no authentication; a static bearer header cannot be entered. Put a reverse proxy in front of /mcp that adds the Authorization header for a private hostname, or wait for OAuth support in a later Spillway release.
  • OpenAI Responses API (your own code): the hosted mcp tool accepts headers.
{
  "model": "gpt-5",
  "tools": [
    {
      "type": "mcp",
      "server_label": "spillway",
      "server_url": "https://cms.example.org/mcp",
      "headers": { "Authorization": "Bearer spw_…" },
      "require_approval": "never"
    }
  ],
  "input": "Draft a page about the open day on 14 March and submit it for review."
}

Any other client, or curl

curl -sS https://cms.example.org/mcp \
  -H "Authorization: Bearer spw_…" -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

Supported methods: initialize, ping, tools/list, tools/call, resources/list, resources/templates/list, resources/read, prompts/list, prompts/get, plus notifications/* (answered with 202). Batches of up to 20 requests are accepted. Protocol versions 2025-06-18, 2025-03-26 and 2024-11-05 are negotiated in initialize.

3. Tools

Every tool returns JSON (as text content and as structuredContent). Results are compact: ids, titles, statuses and paths, with previewUrl (needs a browser session with content.view_drafts) and url (when published). Errors come back as a tool result with isError: true and the same codes as the REST API: { "error": { "code", "message", "details" } }.

ToolPermissionWhat it does
list_content_typesanyEvery content type with its fields, required flags and AI hints.
get_content_typeanyOne type by key.
list_entitiescontent.view_draftsFilter by type, status, q; page, limit, sort. Summaries only.
get_entitycontent.view_draftsOne entity with its full field data (latest revision).
create_entitycontent.createCreates a draft (revision 1). Never publishes.
update_entitycontent.edit_any or own + content.edit_ownSaves a new revision. data is shallow-merged by default (merge: false replaces). Status is unchanged.
submit_for_reviewcontent.submit_review + edit accessDraft → review; shows up in the editors' queue.
publish_entitycontent.publishRefused with checks_failed when an error-level check fails, unless force: true.
get_checkscontent.view_draftsRequired fields, SEO lengths, image alt text, heading order, banned words, broken internal links.
list_revisionscontent.view_draftsRevision history, newest first.
search_siteanyFull-text search; drafts included only with content.view_drafts.
list_collectionsanySaved collections.
get_collection_itemsanyItems of a collection by id or key; drafts only with content.view_drafts.
list_mediaanyMedia with public URLs and alt text.
get_site_settingsanyName, tagline, base URL, locales, home page, SEO defaults, theme.
draft_page_from_briefai.use + content.createSite AI builds a layout from a brief and saves it as a draft; returns the id. ai_disabled / ai_not_configured when AI is off.
assist_textai.use + content.createBrand-aware copy help: write, rewrite, shorten, expand, tone, fix, translate, summarize. Returns text only.
list_review_queuecontent.publishWhat is waiting for an editor.
add_commentcontent.view_draftsLeaves an editorial comment, optionally on one field.

Unknown tools are a JSON-RPC -32602 error; invalid arguments are a tool error with code validation_error and the failing paths in details.

Resources

URIContent
spillway://siteSite settings
spillway://typesAll content types
spillway://types/{key}One content type
spillway://entities/{id}One entity (needs content.view_drafts)

Prompts

  • write-page (brief, optional type, audience): the drafting workflow: learn the type, draft with draft_page_from_brief or create_entity, run get_checks, fix with update_entity, submit_for_review, leave a summary comment. Explicitly does not publish.
  • review-entity (id): read, run the checks, compare revisions, leave a verdict as a comment. Does not change or publish anything.

4. Governance

  • Drafts only. create_entity and draft_page_from_brief always create drafts; update_entity never changes status. The AI layer itself never publishes.
  • Publishing is gated twice. The token must carry content.publish (off in the default preset) and the pre-publish checks must pass, or the caller must send force: true on purpose.
  • Permissions shrink, never grow. Token ⊆ user, re-evaluated on every request against the user's current roles.
  • Everything is attributable. Content written through a token is authored by the token's user; the audit log has one row per bearer request (api_token.request) and one per tool call (mcp.tool_call), both carrying the token id.
  • Rate limit. 120 JSON-RPC calls per minute per token; over the limit the server answers 429 with a JSON-RPC error whose data.code is rate_limited.
  • Revocation is immediate. Deleting a token or the user ends access on the next request.

5. Troubleshooting

SymptomCause
401 unauthorized on /mcpNo Authorization: Bearer header. Cookie sessions are not accepted on /mcp.
401 invalid_tokenUnknown prefix, wrong secret, expired, revoked, or the owning user was deleted.
tool error forbiddenThe token (or its user) lacks the permission named in the message. Create a token with more permissions.
tool error checks_faileddetails lists the failing checks; fix them with update_entity or publish with force as a human decision.
tool error ai_disabled / ai_not_configuredSite settings → AI is off or has no key. Build the layout with create_entity instead.
405 on GET /mcpExpected; the server does not open an SSE stream. Clients that require one should use JSON responses (all current clients do).

Spillway 0.1.0 · Edit this page on GitHub

Was this page helpful?