Token mode is the default. It needs no database and no extra packages, and it works with file-based users. Use it for any client that can send an Authorization header, such as Claude Code, Cursor, and the MCP Inspector.
Issue a token#
php please mcp:token [email protected] --name="Claude Code" The email must belong to an existing Statamic user. The command prints the token once, then a ready-to-paste command for Claude Code and a config block for Cursor.
claude mcp add --transport http statamic https://example.com/mcp/statamic
--header "Authorization: Bearer mcp_..." {
"mcpServers": {
"statamic": {
"url": "https://example.com/mcp/statamic",
"headers": {
"Authorization": "Bearer mcp_..."
}
}
}
} The JSON block goes in .cursor/mcp.json. The user also needs the Access MCP permission. Grant it on one of their roles in the Control Panel. Super admins already have it, and mcp:token warns you when it’s missing.
Manage tokens from the command line#
php please mcp:token [email protected] --expires-days=90 php please mcp:tokens php please mcp:token:revoke {id} mcp:tokens prints each token’s id, user, label, creation date, and expiry. The id is also the middle part of the token itself. A token with no --expires-days never expires.
To rotate a token, issue a new one, point the client at it, then revoke the old one. A revoked token gets a 401 on its next request.
Manage tokens in the Control Panel#
Anyone with Access MCP can open Tools → MCP → Connections to create and revoke their own tokens. Each new token gets a label of up to 100 characters and an expiry of never, 30, 90, or 365 days. The page always issues for the signed-in user, up to 100 tokens per user. Issuing a token for someone else stays a console operation, with no such limit.
Super admins see everyone’s tokens and can revoke them. See Control Panel. In OAuth mode the token controls are hidden, and the route refuses too, because nothing would accept a token issued there.
How tokens work#
A token looks like mcp_<id>_<secret>. The id is 12 lowercase characters and the secret is 40 random characters.
The token store keeps only a SHA-256 hash of the secret, in storage/statamic/mcp/tokens.yaml, and sets that file to mode 0600 and its directory to 0700. There’s no way to read a token back, which is why it’s shown only once.
On every request, in this order:
- The header is rejected if it’s empty or longer than 256 characters, before any parsing or hashing.
- The token must split into the
mcp_<id>_<secret>shape. - The secret’s hash is compared in constant time. When the id doesn’t exist, the comparison still runs against a random value, so timing doesn’t reveal which ids are real.
- An expired token is rejected.
- The token’s user must still exist. A deleted user’s tokens stop working.
Every one of those failures answers the same 401, so a caller can’t tell them apart.
{
"error": "Unauthenticated."
} If tokens.yaml is corrupt, every request answers 500, so nobody gets in. mcp:doctor reports that state with a recovery path.
Several writers#
Token writes from the command line and the Control Panel are serialized behind one of Laravel’s cache locks. That only works if your default cache store supports atomic locks and is shared by every writer. Array and null cache stores, or per-server caches on a multi-server deploy, degrade to no cross-process locking.
When the lock can’t be acquired, nothing gets written. In the Control Panel you see “The token store is busy”.
Check it#
mcp:doctor checks that the store is writable, including the case where tokens.yaml was created by a different user such as root. It also separates active tokens from expired and orphaned ones, because “5 tokens issued but none are active” is a locked door that looks configured.
php please mcp:doctor