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
- Sign in to the admin, open the account menu (top right) and choose API tokens.
- 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.publishoff unless you really want the agent to publish. - 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.managecan 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
/mcpthat adds theAuthorizationheader for a private hostname, or wait for OAuth support in a later Spillway release. - OpenAI Responses API (your own code): the hosted
mcptool 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" } }.
| Tool | Permission | What it does |
|---|---|---|
list_content_types | any | Every content type with its fields, required flags and AI hints. |
get_content_type | any | One type by key. |
list_entities | content.view_drafts | Filter by type, status, q; page, limit, sort. Summaries only. |
get_entity | content.view_drafts | One entity with its full field data (latest revision). |
create_entity | content.create | Creates a draft (revision 1). Never publishes. |
update_entity | content.edit_any or own + content.edit_own | Saves a new revision. data is shallow-merged by default (merge: false replaces). Status is unchanged. |
submit_for_review | content.submit_review + edit access | Draft → review; shows up in the editors' queue. |
publish_entity | content.publish | Refused with checks_failed when an error-level check fails, unless force: true. |
get_checks | content.view_drafts | Required fields, SEO lengths, image alt text, heading order, banned words, broken internal links. |
list_revisions | content.view_drafts | Revision history, newest first. |
search_site | any | Full-text search; drafts included only with content.view_drafts. |
list_collections | any | Saved collections. |
get_collection_items | any | Items of a collection by id or key; drafts only with content.view_drafts. |
list_media | any | Media with public URLs and alt text. |
get_site_settings | any | Name, tagline, base URL, locales, home page, SEO defaults, theme. |
draft_page_from_brief | ai.use + content.create | Site 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_text | ai.use + content.create | Brand-aware copy help: write, rewrite, shorten, expand, tone, fix, translate, summarize. Returns text only. |
list_review_queue | content.publish | What is waiting for an editor. |
add_comment | content.view_drafts | Leaves 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
| URI | Content |
|---|---|
spillway://site | Site settings |
spillway://types | All content types |
spillway://types/{key} | One content type |
spillway://entities/{id} | One entity (needs content.view_drafts) |
Prompts
write-page(brief, optionaltype,audience): the drafting workflow: learn the type, draft withdraft_page_from_brieforcreate_entity, runget_checks, fix withupdate_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_entityanddraft_page_from_briefalways create drafts;update_entitynever 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 sendforce: trueon 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
429with a JSON-RPC error whosedata.codeisrate_limited. - Revocation is immediate. Deleting a token or the user ends access on the next request.
5. Troubleshooting
| Symptom | Cause |
|---|---|
401 unauthorized on /mcp | No Authorization: Bearer header. Cookie sessions are not accepted on /mcp. |
401 invalid_token | Unknown prefix, wrong secret, expired, revoked, or the owning user was deleted. |
tool error forbidden | The token (or its user) lacks the permission named in the message. Create a token with more permissions. |
tool error checks_failed | details lists the failing checks; fix them with update_entity or publish with force as a human decision. |
tool error ai_disabled / ai_not_configured | Site settings → AI is off or has no key. Build the layout with create_entity instead. |
405 on GET /mcp | Expected; 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