This document describes how the Concrete CMS MCP server stores credentials and how to use it safely in local and remote deployments.
Each authorized CMS user has two token files under a site-specific directory:
TOKEN_DIR/<siteKey>/{userId}.tokens.json
TOKEN_DIR/<siteKey>/{userId}.client.json
TOKEN_DIRdefaults to~/.concretecms-mcp/tokens<siteKey>is a 16-character hash ofCONCRETE_CANONICAL_URL, so one MCP install can connect to multiple Concrete CMS sites without clobbering tokens- Legacy flat files directly under
TOKEN_DIR/are migrated into the site subdirectory on startup
| File | Contents |
|---|---|
{userId}.tokens.json |
Access token, refresh token, expiry, obtained_at, CMS user ID |
{userId}.client.json |
OAuth client parameters (redirect URI, scope, PKCE state) |
Legacy combined {userId}.json files are migrated to the split format on load.
These tokens grant API access with the same permissions as the CMS user who authorized.
Default location:
~/.concretecms-mcp/tokens/<siteKey>/
The <siteKey> is derived from CONCRETE_CANONICAL_URL. Configure a different CONCRETE_CANONICAL_URL per site (e.g. separate Claude Desktop MCP entries) and each site gets its own token directory.
Override the base directory with the TOKEN_DIR environment variable. Recommended for local stdio mode so tokens stay outside your repository.
Remove all tokens for a user:
rm -f ~/.concretecms-mcp/tokens/<siteKey>/{userId}.tokens.json \
~/.concretecms-mcp/tokens/<siteKey>/{userId}.client.json \
~/.concretecms-mcp/tokens/<siteKey>/{userId}.auth.lockRemove stale locks and tokens expired more than 30 days:
npm run cleanup:tokensOr after build:
node scripts/cleanup-tokens.mjsWhen Claude Desktop spawns the server locally (TRANSPORT_TYPE=stdio, default):
- A single user key
localis used (override withCONCRETE_USER_ID) - Legacy
.tokens.jsonin the project root is migrated to~/.concretecms-mcp/tokens/<siteKey>/local.*on startup - OAuth runs lazily on the first tool call (not at process startup)
- Concurrent OAuth flows are coordinated via per-user lockfiles
TOKEN_ENCRYPTION_KEYis optional; a warning is logged if unset- Token files are written with mode
0600
- Do not commit token files or share them
- Use
TOKEN_ENCRYPTION_KEYon shared development machines - Set
TOKEN_DIRexplicitly if you prefer a custom location
Generate an encryption key:
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"When the server runs remotely (TRANSPORT_TYPE=http):
TOKEN_ENCRYPTION_KEYis required — tokens are encrypted at rest (AES-256-GCM)MCP_API_KEYorMCP_API_KEYSis required — clients must authenticate to/mcpand OAuth admin routes- Each CMS user authorizes separately; tokens are stored per user ID
TOKEN_DIRmust not be world-writable; run the service as a dedicated OS user/oauth/startreturns409if an OAuth flow is already in progress for that user
Remote clients must send:
Authorization: Bearer <MCP_API_KEY>
X-Concrete-User-Id: <cms_user_id>
If using MCP_API_KEYS with a user-bound key, the X-Concrete-User-Id header is optional:
{"personal-desktop-key": 42, "dashboard-backend-key": null}- A numeric value binds the API key to a fixed CMS user (personal remote clients)
nullrequiresX-Concrete-User-Idon each request (dashboard mode)
The MCP server trusts the holder of MCP_API_KEY to pass the correct X-Concrete-User-Id. The API key must live server-side (CMS dashboard backend or personal env config), never in a browser.
CMS user IDs are sequential integers and are not secret — security relies on API key protection and per-user OAuth tokens.
Per user:
curl -X POST -H "Authorization: Bearer $MCP_API_KEY" \
"https://mcp.example.com/oauth/revoke?user_id=42"Or delete the user's token files on the server and restart if needed.
| Route | Authentication |
|---|---|
/oauth/start |
MCP_API_KEY required (http mode) |
/oauth/status |
MCP_API_KEY required |
/oauth/revoke |
MCP_API_KEY required |
/oauth/callback |
Public (CMS redirect; protected by PKCE) |
/health |
Public |
/mcp |
MCP_API_KEY + user context required |
Rate limiting applies to OAuth routes (10 requests/minute per IP by default).
Access tokens are refreshed automatically when within 60 seconds of expiry (TOKEN_REFRESH_BUFFER_MS). The obtained_at timestamp records when tokens were last issued or refreshed.
OS keychain storage (macOS Keychain, Linux libsecret) may be added as an alternative to encrypted files. Track as a follow-up enhancement.