| title | MCP Server | ||||||
|---|---|---|---|---|---|---|---|
| description | Expose your Total CMS site as a Model Context Protocol server so AI agents (Claude, ChatGPT, custom integrations) can discover, query, and manage content. | ||||||
| related |
|
||||||
| audience | intermediate | ||||||
| updated | 2026-08-27 |
Every Total CMS site is an MCP server out of the box. Point Claude Code, Claude Desktop, ChatGPT, or any conformant MCP client at https://your-site/mcp and an AI agent can query your collections, fetch objects, search content, and (with an API key) manage schemas and collections.
The MCP server ships with Total CMS 3.5 and is available on Standard and Pro (Lite does not include it). Reading works on both; the personas that write to the site depend on Pro credentials — see Editions.
The Model Context Protocol is Anthropic's open standard for how AI agents talk to tools and data sources. An MCP server publishes a set of tools (callable functions with typed inputs/outputs) and resources (addressable content URIs); the AI host (Claude, etc.) lets the agent invoke them.
For Total CMS specifically, MCP gives agents:
- Discovery —
list_collections,describe_collection,list_views,describe_viewmap the site's content shape and pre-computed views. - Read —
query_collection,search_collection,get_object,query_view,get_viewfetch content with the same filter/sort syntax as the REST API. - Resources —
tcms://{collection}/,tcms://{collection}/{id}, andtcms://view/{id}URIs the agent can address by URI, subscribe to, and re-fetch across sessions. - Content writes (group-gated) —
create_object,update_object,patch_objectanddelete_objectlet an agent write content on behalf of whoever connected it — an OAuth caller can create, update or delete objects in any collection their access groups grantcreate/update/deleteon, no admin API key required. See The three-layer rule. - Structural admin (admin-group or group-granted) —
create_schema,update_schema,delete_schema,create_collection,clear_cache,list_extensions,get_site_infofor operator-driven workflows from inside the agent. Schema, collection, and cache tools are reachable by a non-admin caller whose groups grant the matchingschemas/collectionsMeta/utilspermission;get_site_infostays admin-only.
The same /mcp URL serves three personas; the tool surface scales per caller:
| Persona | How they authenticate | What they see |
|---|---|---|
Developer / operator (admin) |
X-API-Key: <admin-key> header on every request, with an All endpoints (*) key |
Every tool — including the admin write tools. Same surface as the admin UI. A key limited to some collections is not an admin: see API key authorization. |
Authenticated consumer (authenticated) |
Authorization: Bearer <oauth-token> header with at least one mcp:* scope |
The intersection of what the token's scopes consent to and what the approving user's access groups grant — not every mcp.access: 'authenticated' collection. A blogger-group user's token can read/write the collections their groups say blogger can touch, plus anything marked mcp.access: 'public'; nothing else. Used by "Connect Claude" / "Connect Cursor" style flows where an end-user grants a third-party AI client scoped access to their content. See The three-layer rule and OAuth Server for setup. A token approved by an admin-group user and carrying the cms:admin scope elevates to the admin persona — see Required scopes for MCP. |
Public AI agent (public) |
No credentials (anonymous) | Only tools marked access: public AND only collections with mcp.access: 'public'. Drafts are auto-hidden. |
Public content is opt-in per collection. mcp.publicAccess is on by default, so an anonymous caller can open a session, but every collection's mcp.access defaults to admin — the public persona sees nothing until the operator marks a collection's mcp.access as public in the schema editor. To refuse anonymous callers outright (401), set mcp.publicAccess to false.
The bundled WebMCP extension registers this server's read tools in the visitor's browser. Its calls reach /mcp with the visitor's session cookie, and a same-origin session is treated like an OAuth client the user approved for cms:read and mcp:tools only — read-only and tools-only, whatever the user could do in the admin: an administrator's session is the admin persona, any other signed-in user is the authenticated persona, reaching only collections whose MCP Access is Authenticated or Public and that their access groups grant read on — never a collection marked Admin only — and the server itself lists no write tool for either. A visitor with no session stays the same anonymous client it always was, reaching only collections whose MCP Access is Public; the server does not narrow an anonymous caller's tool list the way it does a session's, so it is the extension's own script that keeps only tools the server marks readOnlyHint: true — a public-access tool that writes would otherwise appear in an anonymous tools/list like any other. Nothing changes for API-key, OAuth or anonymous clients calling /mcp directly, outside a browser page.
The MCP endpoint requires Standard or Pro. Lite does not include it. What differs between Standard and Pro is which personas are available to reach it.
The public persona works everywhere, so any Total CMS site can expose collections for an AI agent to read. The other two personas depend on credentials that are Pro features — an API key for the admin persona, the OAuth server for the authenticated one — so writing to your site from an agent requires Pro, as does user-scoped access from an external client — while a signed-in browser session reads as that user on Standard (see the fourth caller above).
That gate is enforced where the persona is decided, not only where credentials are issued. A key or token that outlives the licence which created it — after a trial lapses, a downgrade, or a restored backup — is treated as absent rather than honoured: the caller falls through to anonymous and still gets whatever is genuinely public.
Two things are reduced below Pro: Data Views are a Pro feature, so the view tools and tcms://view/{id} resources are not offered at all; and custom schemas are Pro, so only built-in schemas are exposed.
Every authenticated (OAuth) call to a collection or object has to clear three independent gates — think of them as three separate locks that all have to be open:
- Scope — did the person who approved the connection consent to this class of action? Reading needs
cms:read, writing needscms:write, schema/collection/cache work needscms:admin. - Group — do that person's access groups actually grant this operation on this collection? The same groups you already manage under Admin → Access Groups for the dashboard.
- Exposure — is the collection turned on for MCP at all (
mcp.access: 'authenticated'or'public')? A collection left at the defaultmcp.access: 'admin', or set to'apikey', is invisible to every OAuth caller regardless of scope or group.
All three have to pass. The tool list follows the first two: an agent is offered only the tools its connection's scopes cover and its groups grant on at least one collection or schema, so a token approved for cms:read sees no write tools. Content read tools stay listed without cms:read, since public collections need no scope. The admin persona sees every tool. The list cannot narrow a tool to particular collections, so every call is still checked against the collection or schema it names.
Concretely:
- A blogger whose access group can create/update the
blogcollection, connecting with acms:write mcp:toolstoken, can ask Claude to draft and save a blog post —create_object/update_objectsucceed onblog. The same token gets refused onproductsorteam, because the blogger's groups don't grant anything there, even though the token's scope would otherwise allow a write. - A viewer whose access group only grants
readnever gets a workingcreate_object/update_objectcall, no matter what scopes their token carries — group is the gate that matters once scope has cleared. - A collection marked
mcp.access: 'public'stays readable to every authenticated caller even if their groups say nothing about it — authenticating never subtracts reach versus what an anonymous caller already gets. Public exposure alone is enough for reads; it never grants writes. - Drafts are the one place group exposure isn't enough. Seeing draft objects in a collection requires an actual access-group
readgrant on that collection — publicmcp.accessexposure by itself does not unlock drafts, even though it unlocks published content. An authenticated caller with no group grant on a public-exposed collection sees only its published items, same as an anonymous caller.
This is launch semantics for the AUTHENTICATED tier, not a migration: MCP had not shipped to production when the group rule landed, so there's no prior behavior to preserve. A legacy OAuth token approved before this shipped (grants created by the earlier super-admin elevation feature, for example) is unaffected for admin-group users; a non-admin user's existing token loses reach it arguably should never have had — it now sees only what their groups actually grant.
Public access with Claude requires disabling OAuth. claude.ai and the Claude desktop app demand a login at connector setup whenever they can discover an OAuth server on your site — they never fall back to the anonymous tier, and your consent screen requires an operator login no site visitor has. If you want visitors to connect their Claude to your public collections, turn off Admin → Settings → OAuth Server → Enable OAuth Server. Access is then anonymous (public) or API key (admin) — see Running public-only MCP. Clients that try anonymously first (ChatGPT connectors, Claude Code, MCP Inspector) reach the public tier either way.
The initialize response carries instructions, and every MCP client keeps
that text in context for the conversation. Total CMS uses it to hand the agent
the judgment a human editor would bring: discover collections and read the
schema's help text before acting, look things up with the docs_* tools
instead of guessing, patch objects rather than replace them, respect field
shapes, never invent ids, and — on an admin connection — how to model a
schema well (start from the totalcms reference schema, help text on every
property, the SEO card on anything that becomes a page). The text is
persona-aware: a read-only connection is told it can only read and what
writing needs, not how to write. Together with the tcms_* prompts this is
the equivalent of the terminal agent skill for people who reach Total CMS
only through claude.ai, Claude Desktop or ChatGPT.
- Check your edition. MCP requires Standard or Pro. Trial counts as Pro for testing, which also unlocks the API-key and OAuth personas.
- Verify it's enabled. In Admin → Settings → MCP Server,
Enabledshould be checked (default true on fresh install). - Confirm with the CLI:
Look for
tcms mcp:status
enabled: yes,edition gate: yes, and a non-emptyAdmin personatool list. - Test through the endpoint. From a terminal:
A successful response carries
curl -X POST https://your-site/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -H 'X-API-Key: <your-admin-key>' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"manual","version":"1"}}}'
result.serverInfoandresult.capabilities.
For Claude Desktop / Claude Code: add your site under MCP servers in the client's settings — point at the /mcp URL and provide the API key as the Bearer token if the host supports it, otherwise as a header config.
Server firewalls block MCP clients. WAF rulesets that filter by user-agent (7G/8G firewall, some security plugins and host-level rules) 403 non-browser clients — which is every MCP client: claude.ai connectors, Claude Desktop, Claude Code, and
curlalike. The symptom is an endpoint that works in a browser but fails from clients — a connector that errors or falls back to asking for manual credentials, orcurlreturning 403 with the default user-agent. Exempt/mcp,/oauth/*, and/.well-known/*from user-agent filtering (or drop the UA rules) before debugging anything else.
API keys and OAuth both authenticate /mcp requests, but they serve different trust relationships. Choose based on who is operating the tool.
Use an API key for your own development tooling: Claude Code pointed at your site during a content project, a Stacks workflow that runs on your machine, or an admin script you control end-to-end. The key lives in your config file and never touches a consent screen.
Use OAuth when you ship a "connect to Claude" experience to end-users — your customers, your clients, team members who do not have T3 admin credentials. OAuth issues each person a scoped token through a consent screen; you can revoke individual connections without changing any shared secret; and every token action is logged to the OAuth activity log so you have a record.
OAuth also covers full site management for users in the admin group: a connection they approve with the cms:admin scope is elevated to the admin persona — the complete tool surface, no API key to paste. In clients whose connector UI has no header field (claude.ai, the Claude desktop app), this is the only way to reach the admin tools, and it beats a shared API key on posture anyway: per-connection revocation, hourly token expiry, full audit trail.
- Pro edition — the OAuth server is a Pro feature (trials count). The MCP endpoint itself is not gated; only this authenticated persona is.
- Keys generated. Run
tcms oauth:setuponce if you haven't already. - OAuth server enabled. Toggle on in Admin → Settings → OAuth Server.
- Read the full OAuth setup guide at OAuth Server before continuing — it covers key generation, client creation, scope definitions, and dynamic registration.
A Bearer token must carry at least one mcp:* scope to do anything useful at /mcp. Tokens without any mcp:* scope receive a 403 on every request — even for collections the token's cms:read scope would otherwise cover.
| Scope | What it unlocks at /mcp |
|---|---|
mcp:tools |
Authorize all tools/call requests. Every persona-filtered tool the token's collection scopes can reach becomes callable. |
mcp:resources |
Authorize resources/read, resources/list, resources/templates/list, and resources/subscribe. Without this scope, resource methods return 403. |
mcp:search |
Currently inherits from mcp:tools — a token with mcp:tools can call search_collection and search_collections. The scope is reserved separately so it can be gated independently in a future release. |
mcp:prompts |
Authorize prompts/list and prompts/get. Prompts are visible in prompts/list but the content is withheld until the token carries this scope. |
cms:admin |
Issuable when the approving user is in the admin group OR their access groups grant at least one schemas / collectionsMeta / utils permission — otherwise the consent screen doesn't offer it and the issued token never carries it. Holding the scope only grants what those groups actually allow: a schema-editor's cms:admin token can create/update/delete the schemas their groups permit and nothing else. Full elevation to the admin persona (every tool, unrestricted) additionally requires admin-group membership — a non-admin schema editor keeps cms:admin's REST/MCP reach but never elevates. |
A typical "read-only AI browser" connection requests cms:read mcp:tools mcp:resources. A connection that also needs prompts adds mcp:prompts. An operator managing their own site from Claude requests cms:admin mcp:tools mcp:resources. A schema editor who is not in the admin group can also request cms:admin — their groups cap what the resulting token reaches. Grant the minimum scopes for the use case.
Dynamic registration is on by default, which gives the zero-touch Claude flow. If it has been turned off (Admin → Settings → OAuth Server → Allow Dynamic Registration), the discovery document doesn't advertise registration_endpoint, and clients either report the server as incompatible (Claude Code) or ask for a manual Client ID and secret (claude.ai connectors). If you'd rather keep it off — or want a named client you can track and revoke independently — create a static client instead:
- Admin → Utilities → OAuth Clients → Create Client.
- Name it something traceable: "Claude Desktop – Joe", "Cursor – Content Team".
- Add
http://localhost/as a redirect URI (themcp-remoteshim binds a local loopback server for the code exchange). - Check
mcp:tools,mcp:resources, andmcp:promptsunder Scopes. - Save and copy the Client ID (UUID). The client secret is not needed — you'll pass only the client ID to
mcp-remote.
Then add your site to Claude Desktop's claude_desktop_config.json:
{
"mcpServers": {
"my-site": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"https://your-site.example.com/mcp",
"--client-id",
"<paste-client-id-here>"
]
}
}
}Replace https://your-site.example.com/mcp and <paste-client-id-here> with your values. mcp-remote is the bridge shim that handles the OAuth authorization-code flow on behalf of native clients that don't have a built-in browser.
- Claude Desktop starts
mcp-remote, which opens a browser tab pointing athttps://your-site.example.com/oauth/authorize. - The user logs in with their T3 account (or is already logged in).
- The consent screen lists the requested scopes in plain language. The user clicks Allow.
mcp-remotecompletes the PKCE code exchange, stores the access and refresh tokens in its local token cache, and the MCP session initializes.- Subsequent connections are silent —
mcp-remoterefreshes the access token automatically until the refresh token expires or the operator revokes the grant.
Navigate to Admin → Utilities → OAuth Grants. Every active grant is listed with its client name and the scopes it holds. Click Revoke to immediately invalidate the access and refresh tokens for that connection. The next tool call from that client returns 401 and the user is prompted to re-authorize.
Deleting the client in Admin → Utilities → OAuth Clients cascades — all grants for that client are revoked at once. Useful when you retire a shared client that multiple users connected through.
Every one of these has bitten a real setup. Match the symptom, apply the fix:
| Symptom | Cause | Fix |
|---|---|---|
| Claude Code: "Incompatible auth server: does not support dynamic client registration" | Dynamic registration is turned off | Enable Allow Dynamic Registration, or create a static client and connect with its Client ID |
| claude.ai connector asks for a manual Client ID and secret | Same — DCR not advertised in discovery | Same as above |
| claude.ai: "Couldn't register with … sign-in service" | Dynamic registration is off, or its rate limit is exhausted (a security.rate_limit entry for /oauth/register in the OAuth activity log). Every connect attempt registers again from Anthropic's servers |
Wait out the hour or raise Dynamic Registration Rate Limit — or create a static client and paste its Client ID and secret into the connector's advanced settings |
OAuth errors about keys / empty jwks.json |
Signing keys never generated | Run tcms oauth:setup once |
Works in a browser, but connectors/curl get 403 |
A firewall (7G/8G, security plugin) filters non-browser user agents | Exempt /mcp, /oauth/*, /.well-known/* from UA rules |
| Every request returns 401 | Wrong or revoked API key / token | Check the key; WWW-Authenticate on the 401 names the scheme it expects |
| Agent connects but write tools are missing | Connected as the public or OAuth persona, not admin | Send the API key as X-API-Key; confirm with tcms mcp:status which persona sees which tools |
| Login succeeds but token lacks access | Token missing mcp:* scopes |
Re-authorize requesting mcp:tools (+ mcp:resources, mcp:prompts as needed) |
All tool descriptions are also visible to the AI client at runtime via tools/list. The catalog below is the canonical reference.
| Tool | Access | What it does |
|---|---|---|
list_collections |
public | Persona-filtered overview of every collection. Returns id, name, schema, description, url_pattern, access, total_objects. |
describe_collection |
public | Detailed view of one collection — its properties with indexed / filterable / sortable flags + type + resolved property descriptions. The agent uses this to learn what's queryable. |
list_views |
public | Persona-filtered catalog of pre-computed data views. Returns id, name, description, last_built, access per view. |
describe_view |
public | Metadata + inferred output shape for a single view, sampled from the first cached item. Admin persona also receives the view's Twig definition. |
| Tool | Access | What it does |
|---|---|---|
query_collection |
public* | Paginated query against a collection's index. REST-style include / exclude / sort syntax. Limit caps at 50. |
search_collection |
public* | Free-text search within a single collection. Drafts auto-hidden from callers without a group read grant. |
search_collections |
public* | Cross-collection full-text search. Silently filters out collections the caller can't read; each result carries its collection for chaining into get_object. |
get_object |
public* | Fetch one object by id. Drafts return "not found" to callers without a group read grant (doesn't leak existence). |
query_view |
public† | Paginated query against a data view's cached result. Same REST-style filter/sort vocabulary as query_collection. Limit caps at 50. |
get_view |
public† | Fetch a data view's full cached result, capped at 50 items. Larger views emit a hint pointing at query_view. |
get_resource |
public* | Resolve a tcms://{collection}/{id} URI to its underlying object. Sibling to the SDK's resources/read transport method — same data, different access path. |
* For an AUTHENTICATED (OAuth) caller, reachable collections are the intersection of scope, access group, and mcp.access exposure described in The three-layer rule — this is narrower than every mcp.access: 'authenticated' collection. Anonymous callers are unaffected: access: public still means "readable by everyone" for mcp.access: 'public' collections.
† query_view / get_view follow the dataviews permission, not per-collection grants — see Data views follow the data views grant below.
| Tool | Access | What it does |
|---|---|---|
get_site_info |
admin | Site name, version, edition, PHP version, installed extensions. Smoke test for "am I connected to the right site?" No group-permission analogue — stays admin-only. |
list_schemas |
admin‡ | List the schemas the caller may read (id + description + category): every schema for an admin. |
get_schema |
admin‡ | Fetch one schema as JSON — the same shape an operator writes into a schema file. Needs read on that schema. |
create_schema |
admin‡ | Save a new schema. Errors on reserved ids and id collisions. seedObjects fills the new collection the call creates; if a collection with that id already exists, the schema is saved and seeding is refused (use create_object). |
update_schema |
admin‡ | Replace an existing schema definition. Idempotent (same input → same final state). |
delete_schema |
admin‡ | Destructive. Refuses to delete reserved schemas, inherited schemas, or schemas still used by a collection. |
create_collection |
admin‡ | Create a new collection bound to a schema. Errors on duplicate id. extra takes any collection setting, field overrides (properties) included. publicRegistration and registrationGroups in extra are refused unless the caller is an admin. |
update_collection |
admin‡ | Change an existing collection's settings. Send only what changes. The settings maps (properties, customProperties, formSettings, mcp, seo, sitemap, feed) merge one key at a time, and field overrides merge one attribute at a time, so {"properties": {"price": {"label": "Cost"}}} changes one label and keeps the rest; null removes a key. A missing field on an override is filled in from the schema. No renames; system collections refused; publicRegistration and registrationGroups admin only. An admin, or an OAuth member whose groups grant collection-settings update; never a limited API key. |
delete_collection |
admin‡ | Destructive. Delete a collection's settings — and only those: its objects and files stay on disk, and creating a collection with the same id brings them back. The result returns the deleted settings for that, and they go to the backup history. Refuses the site's login collections (auth.collection, oauth.loginCollection) and system collections. An admin, or an OAuth member whose groups grant collection-settings delete; never a limited API key. |
create_object |
admin‡ | Create a content object in a collection. Runs the same ObjectSaver path as the admin form — schema validation, slug generation, events. See Binary fields below. |
update_object |
admin‡ | Update a content object by id. Idempotent. Merges like patch_object: omitted fields keep their values. See Binary fields below. |
patch_object |
admin‡ | Merge a subset of fields into an existing object — omitted fields keep their current values, so no get/round-trip is needed. Containers (card/deck/list) replace whole. The safer default for targeted agent edits. See Binary fields below. |
delete_object |
admin‡ | Destructive. Delete one object by id, as the admin's delete button does. Needs the delete grant (an API key: the DELETE method). The record stays in the backup history when backups are on, but its uploaded files are deleted permanently; the result lists the fields that held files and whether a backup was kept. Marked destructive, so clients ask before calling it. |
list_extensions |
admin | Every installed extension with id, name, enabled flag, capabilities. |
list_templates |
admin‡ | List the Site Builder Twig templates (builder templates only — reserved admin templates are never listed). Read-only. See Site Builder templates below. |
get_template |
admin‡ | Fetch one template's raw Twig source plus the read layer it resolved from. Read-only. See Site Builder templates below. |
clear_cache |
admin‡ | Destructive. Flush every available cache backend. Returns per-backend status. |
‡ Not admin-only in practice: each of these carries a group requirement that extends the base admin gate — an AUTHENTICATED caller whose access groups grant the matching operation (objects create/update for the object-write tools, schemas for the schema tools, collectionsMeta for create_collection, the cache util for clear_cache, the builder page permission for the template tools) sees and can call the tool too, scoped to the collections/schemas their groups actually cover. The call-time guard re-checks the specific target on every call — tools/list visibility is a convenience, not the enforcement. list_schemas and get_schema follow the schemas read grant, and list_schemas lists only the schemas the caller may read; list_extensions stays plain admin-only (no requirement declared).
Files can't be uploaded through MCP — that needs an upload pipeline (multipart bodies, storage handles) that a JSON tool call doesn't carry. What an agent can do is describe a file that is already uploaded: alt text, tags, a focal point, a download label.
- A collection that merely contains a binary field is fully writable — just omit those fields from your payload. On
create_objectthey're left unset; onupdate_objectandpatch_objectthey keep their current value (a write never wipes an image you didn't touch). create_objectrefuses a value in an image, file, gallery or depot field: there is no uploaded file to describe yet.update_objectandpatch_objecttake a metadata edit there, merged over the stored value:
| Field | Send | Editable keys |
|---|---|---|
| image | {"alt": "Team photo", "focalpoint": {"x": 40, "y": 30}} |
alt, link, tags, featured, focalpoint, exif |
| gallery | [{"name": "stage.jpg", "alt": "Main stage"}] — matched by name |
as image |
| file | {"download": "Brochure (PDF)", "comments": "…"} |
download, comments, tags |
| depot | [{"path": "docs/guide.pdf", "comments": "…"}] — matched by path |
as file |
exif and focalpoint merge one level deep; other keys replace. Gallery images and depot files cannot be added or removed, and naming one that is not stored is refused with the list that is. A gallery list that names every stored image exactly once also sets their order — the first is the gallery's lead image — while a partial list leaves the order alone. Depot files cannot be reordered. Keys that identify the file — name, size, mime, width, height, hash, palette, uploadDate, count — and a file's password / protected setting cannot change; echoing their stored values back is fine. describe_collection lists the editable keys on each media property. Video posters are uploads and stay refused.
Reading objects with
get_object/query_collectionreturns binary fields too. Writing a fetched object back works as long as the media values are unchanged or only their editable keys changed. Sending only the fields you changed is simpler.
list_templates and get_template expose the Site Builder's Twig templates read-only. There is deliberately no save tool: writing Twig is a much larger authority grant than writing content — a template reaches the whole cms.* surface — so it is a separate decision rather than something that arrives alongside read access.
Read access exists because agents editing builder-pages objects are otherwise working blind. A page's free-form data field is exposed to its template as page.data.*, and without reading the template an agent can only guess which keys it consumes. The workflow is:
get_objectthe page frombuilder-pagesto see itstemplate(a selector — the name of the template file, not its source).get_templatethat path to read the Twig and see whichpage.data.*keys it reads.patch_objectthe page'sdatawith matching keys.
Both tools require the builder access-group permission and a cms:admin scope — templates are structural, so even reads sit behind the admin scope rather than cms:read.
Paths are builder-relative and carry no .twig extension, exactly as list_templates returns them (pages/home, layouts/base, partials/nav). Traversal (.., backslashes, a leading /) is rejected outright rather than silently rewritten.
Every response reports where templates are actually edited on this install:
git_managed: false— templates live intcms-data/builder/and are edited in the Site Builder admin UI.git_managed: true— the project has abuilder/directory, so templates are source-controlled and read-only to the admin UI and MCP alike. The git repository is the only write path. See Site Builder.
get_template additionally reports the layer the file resolved from — project (git-managed source of truth), data (admin-edited, in tcms-data), or built-in (shipped default) — so an agent knows which file it just read.
Operators control AI exposure via two layers of MCP config — one per collection, one per property.
"mcp": {
"access": "public",
"description": "Public blog posts. Drafts are auto-hidden from anonymous callers.",
"resource": true
}| Field | Default | Meaning |
|---|---|---|
access |
"admin" |
Who can reach this collection over MCP. See the levels below. |
description |
empty | AI-targeted description shown in list_collections and the dynamic tool-description catalog. Falls back to the collection's general description if blank. |
resource |
false |
When true, the collection is exposed as a tcms://{collection}/ resource and its objects via the tcms://{collection}/{id} template. Off unless you turn it on — resources are a second way to reach data the tools already serve, so a collection stays out of resources/list until you ask for it. |
The access levels, each including everything above it:
access |
Who reaches the collection or view |
|---|---|
"admin" |
Admins only: an All endpoints (*) API key, a super admin over OAuth with cms:admin, a super admin's own browser session. |
"apikey" |
Admins, plus API keys limited to some collections or views whose endpoints name this one. |
"authenticated" |
All of the above, plus OAuth members and signed-in browser sessions whose access groups grant it. |
"public" |
Everyone, including anonymous AI agents. |
Use "apikey" to give an integration a key for one collection without opening it to signed-in members.
The same mcp card lives on each data view (in the dataviews editor) with identical fields. A view marked mcp.access: 'public' shows up in list_views for anonymous callers and is fetchable at tcms://view/{id}. At authenticated, an OAuth member also needs the dataviews permission.
"properties": {
"content": {
"type": "string",
"field": "styledtext",
"mcp": {
"description": "The post body, rendered as markdown by default for AI consumption.",
"expose": true
}
},
"internal_notes": {
"type": "string",
"field": "textarea",
"mcp": {
"expose": false
}
}
}| Field | Default | Meaning |
|---|---|---|
description |
falls back to help → label |
AI-targeted description shown per property in describe_collection and tool-description catalogs. |
expose |
true (see below) |
When false, this property is stripped from every MCP response entirely. Use for operator-only fields (credentials, internal references, supplier costs). Defaults to false on password and secret fields — explicit operator opt-in with mcp.expose: true is required to surface a sensitive field. |
Filterability and sortability are NOT operator-controlled — they're derived from:
- The schema's
indexlist (a non-indexed property can never be queried regardless of intent). - The property's field type (e.g.
textandidare filterable by default;styledtextis not).
To make a property queryable, add it to the schema's index array. That's the lever.
Two layers of defensive defaults protect credential-shaped data:
- Field-type default. Any property using the
passwordorsecretfield type is treated as non-exposed unless the operator explicitly setsmcp.expose: trueon it. Catches the common case (hashed passwords, API keys, OAuth tokens stored via SecretField) for every custom schema without requiring per-schema opt-out. - Explicit schema opt-out. The reserved
authschema additionally ships withmcp.expose: falseonpassword(redundant with the field-type default but kept for defense in depth and reader clarity) and onpasskeys(ahidden-type array of WebAuthn credentials — the field-type default doesn't cover it, so the explicit entry is load-bearing).
Both layers are honored regardless of persona — even if an operator marks the auth collection public, these properties stay stripped.
Settings live in Admin → Settings → MCP Server and serialize to mcp.* under tcms-data/.system/settings.json.
| Key | Default | Effect |
|---|---|---|
mcp.enabled |
true |
Master switch. When false, POST /mcp returns 404 and discovery reports disabled: true. |
mcp.publicAccess |
true |
Master switch for anonymous callers. When true, they resolve to the public persona and see only collections marked mcp.access: 'public' (none by default). When false, requests without an API key get 401 + WWW-Authenticate: Bearer realm="MCP", error="login_required". |
mcp.allowedOrigins |
[] |
CORS origin allow-list for browser-rendered AI clients. Empty = any origin, the same as *; list origins to restrict. See CORS and browser AI clients below. |
mcp.publicIpPerMinute |
60 |
Per-IP rate limit on anonymous requests, 60-second window. API key callers bypass. Set to 0 to disable. |
mcp.toolPrefix |
"" |
Optional snake_case prefix prepended to every tool name (bistro → bistro_list_collections). Useful when an agent connects to multiple T3 sites simultaneously. |
mcp.subscriptionsEnabled |
true |
Master switch for resource subscriptions. When false, resources/subscribe still succeeds at the protocol level but T3 won't push notifications/resources/updated when content changes, and subscriptions/listen is refused outright. See Resource subscriptions. |
mcp.subscriptionStreamSeconds |
1 |
How long one modern-era subscriptions/listen stream stays open, in seconds. Each open stream holds one PHP worker, so raise it only with headroom to spare. Clamped to 0.25-30. See Protocol eras. |
Changing any of these triggers a session invalidation — active agent sessions get "session not found" on their next request and auto-reconnect with the new surface.
Resources are MCP's URI-addressable content primitive. Where tools are imperative (query_collection({name: "blog"})), resources are declarative — every resource has a stable URI an agent can bookmark, fetch, and re-fetch across sessions.
Total CMS exposes three URI shapes:
| URI | What it is | Registered when |
|---|---|---|
tcms://{collection}/ |
Collection summary — recent items, capped at 50 | Collections with mcp.resource: true (off by default) |
tcms://{collection}/{id} |
Single object | Same; registered as a template, not enumerated per-object |
tcms://view/{id} |
A data view's cached result | Data views with mcp.resource: true (off by default) |
Agents address these via three SDK transport methods:
resources/list— flat list of concrete resources (collection summaries + per-view resources). Persona-filtered — for AUTHENTICATED callers, collection resources are filtered by the same group + exposure layers as the tools (see below), except layer 1 (scope): resource methods are gated by themcp:resourcesscope, nevercms:read.resources/templates/list— list of URI templates (tcms://{collection}/{id},tcms://view/{id}). Agents use templates to construct concrete URIs from known ids; persona-filtered the same way asresources/list.resources/read— fetch the content at a URI. Returns the same data the equivalent tool would (get_object/get_view);tcms://{collection}/returns recent-item summaries.
The get_resource tool is an in-tool-flow alias for resources/read — handy when a URI lives in another tool's output (a recommendation, a search result) and the agent wants to dereference it inline.
A site with 50k blog posts does NOT register 50k resource entries. resources/list returns one entry per collection (tcms://blog/); the template (tcms://blog/{id}) tells agents "this URI shape exists, plug in any blog post id." Concrete per-object URIs are dereferenced on demand via resources/read, which delegates to the same persona-aware code path as get_object — tcms://{collection}/ and tcms://{collection}/{id} both follow the three-layer rule: readable when mcp.access is 'public', or when the caller's groups grant read on that collection.
Data views are different — each view IS independently named, so each gets its own concrete tcms://view/{id} entry in resources/list. The shared template still appears in resources/templates/list for admin agents.
tcms://view/{id} and the view tools (list_views, describe_view, get_view, query_view) use the same two gates as the REST data view routes, plus the view's own mcp.access field (public / authenticated / apikey / admin, set on the dataviews editor's MCP card):
- Edition. Data Views are a Pro feature. On an edition without them, the view tools and resources are not offered at all, to any caller, public views included.
- Data views grant. A signed-in OAuth member reaches a view at
authenticatedorapikeyonly when one of their access groups has thedataviewspermission. An API key limited to some views reaches the ones its endpoints name. Admins reach every view. - Public views are open to every caller, including members without the grant, so a member never gets less than an anonymous caller.
The grant is all or nothing. A view is built on a schedule with full access, so its result can include objects from any collection its Twig reads, drafts and other members' objects among them. Per-collection grants and ownership do not narrow it. Give the dataviews permission only to groups that may see every view open to signed-in callers, and keep a view at admin (the default) when its output is not meant for them.
Subscribed agents get pushed notifications/resources/updated events when content behind a URI changes — no polling required.
- Agent calls
resources/subscribewith atcms://{collection}/ortcms://view/{id}URI. - Total CMS records the subscription in
tmp/mcp-subscriptions.json(a reverse index keyed by URI). - When any
object.created/object.updated/object.deletedevent fires for the collection, T3's listener walks the index, finds subscribed sessions, and pushes a JSON-RPC notification into each session's outbox file. - The subscriber's open SSE connection drains the outbox on its next loop tick (typically ~100ms latency) and the agent host surfaces the change.
- No per-object subscriptions in 3.5. You subscribe to
tcms://blog/, nottcms://blog/hello-world. Per-object granularity is a candidate for 3.5.x if customer demand surfaces. - No notification storms during imports. T3's EventDispatcher auto-suspends
object.*events for collections mid-import (e.g. JumpStart bulk loads fireimport.*events instead, which the listener doesn't subscribe to). Bulk operations produce zero subscription notifications by design. - Within-request coalescing only. A 1-second per-
(session, uri)window collapses duplicate notifications. Across-request coalescing isn't implemented — agents may see multiple notifications when sweeps span requests.
Data views are the bounded exception to the "collection-level only" rule. Subscribing to tcms://view/{id} notifies on every successful DataViewBuilder::buildView for that specific view (the builder calls ObjectUpdater::updateObject at the end of every rebuild, which fires object.updated on the dataviews collection — the listener routes those to per-view URIs instead of tcms://dataviews/).
Set mcp.subscriptionsEnabled: false to disable push entirely. Handshake-era resources/subscribe calls are still accepted (so non-conformant clients that error on rejected subscriptions don't break) but T3 stops fanning out notifications. Modern-era subscriptions/listen is refused with a JSON-RPC error and HTTP 501 instead — that method is a long-lived stream, and accepting one only to deliver nothing would hold a worker for the full window. Useful when a noisy listener interferes with a deploy or migration.
Total CMS serves two generations of the MCP specification from the same /mcp URL. Every request is classified once, on arrival, and routed to the matching dispatcher — clients do not choose an endpoint, and nothing needs configuring.
| Era | Revisions | Shape |
|---|---|---|
| Handshake | 2025-03-26, 2025-06-18, 2025-11-25 |
Client calls initialize, gets an Mcp-Session-Id, and reuses it. State lives in the session. |
| Modern | 2026-07-28 |
No handshake and no session. Every request carries the client's identity in params._meta and repeats its subject in Mcp-Method / Mcp-Name headers. |
Tools, resources and prompts are identical across both — the same registry, the same persona filtering, the same authorization. Only the lifecycle differs.
Two differences are worth knowing about:
Version negotiation. A client states the revision it speaks; the server agrees when it supports it and counter-offers its own when it does not. A client asking for 2026-07-28 on a server that tops out earlier gets a supported revision back and decides for itself whether to continue.
Subscriptions. The modern era removed resources/subscribe. Its replacement is subscriptions/listen, a single SSE stream on which the client names the resource URIs it wants and receives updates until the window closes. T3 filters that stream by the caller's persona, so a client only ever receives updates for resources it could read directly — naming a URI it has no access to is accepted but silently never delivers. Each stream holds one PHP worker for mcp.subscriptionStreamSeconds, and the site-wide mcp.listeningStreamMaxConcurrent cap bounds how many can be open at once; over the cap, callers get a JSON-RPC error and HTTP 503 and should retry.
CORS is a browser-only enforcement mechanism. It does nothing for server-side or native clients.
| Client | Does CORS apply? |
|---|---|
| Claude Desktop, Cursor, Claude Code, MCP Inspector (native apps) | No. They make raw HTTP requests; the browser security model never enters. |
| Server-side AI integrations (your own agent on a backend) | No. |
| Claude.ai web UI, ChatGPT.com, Google Gemini, Mistral Le Chat, etc. (browser-rendered) | Yes. The browser refuses the call unless T3 returns Access-Control-Allow-Origin: <their-origin>. |
So mcp.allowedOrigins is only meaningful if your customer plans to use a browser-rendered AI client against your site. Native and server-side AI works regardless.
The settings UI ships clickable presets for the well-known browser AI clients (Claude.ai, ChatGPT, Google Gemini, Microsoft Copilot, Mistral Le Chat, Perplexity) plus a wildcard option. Operators can also type custom origins for in-house playgrounds.
Admin → Settings → MCP Server → CORS Allowed Origins
Open by default. An empty list is the same as the wildcard (*): the request's Origin is echoed back, so browser-based AI clients work out of the box. That is safe because T3 never sends Access-Control-Allow-Credentials and the session cookie is SameSite=Lax, so another site's JavaScript can read only your public MCP surface, never a signed-in visitor's view. List specific origins to restrict browser access, which is worth doing when public collections hold anything you would rather other sites could not read. A listed origin is matched in full, scheme, host and port, so https://claude.ai does not admit http://claude.ai. The site's own origin always passes, over http or https, so a page on the site still reaches /mcp behind a TLS-terminating proxy.
Preflight OPTIONS requests short-circuit to a 204 with the standard handshake headers; the rate limiter doesn't see them so a single browser tab can perform a normal session without burning the public-IP quota on preflights.
MCP reads a key's endpoint list the same way REST does: a key reaches the content its paths name, and nothing else.
paths: ["*"](the default "All endpoints" choice) with every method is the operator's admin key: the ADMIN persona, every tool, every collection. A*key missing a method is limited like any other key: it reaches everything its methods allow, but not Admin only collections.- Any other key needs
/mcpin its paths to reach the endpoint./mcpopens the door and grants no content. - Its content comes from its other paths, read as an access group:
/collectionsgrants every collection,/collections/{id}that one;/schemasand/schemas/{id}reading schemas;/templatesthe Site Builder template tools (read-only)./dataviewsgrants every data view and/dataviews/{id}/datathat one. Such a key is an AUTHENTICATED caller with those grants. It reaches a collection or view only when that one's MCP access isapikey,authenticatedorpublic: anadmincollection stays closed to it even when its paths name it. A key with only/mcpreaches no content. - Structure is read-only to a limited key. Whatever its methods, it never gets
create_schema,update_schema,delete_schema,create_collection,update_collectionordelete_collection: REST's schema and collection-settings write routes take no API key at all. Changing structure needs the admin key, or an OAuth login whose access groups grant it. - Methods decide the tools, as on REST. GET reads, POST creates, PUT or PATCH updates, DELETE deletes. MCP checks a key's paths but not its method when it connects, since every MCP call is a POST, so a GET-only key connects and gets read tools only. A key without PUT or PATCH gets no
update_objectorpatch_object; one without POST nocreate_object. The storage layer refuses those writes as well. - Within its collections a key is trusted: it sees drafts and every owner's objects, and may set protected fields, as on REST.
A key that listed /mcp with some collection paths reached every collection before 3.6.3; it now reaches only the collections it names.
query_collection's include and exclude mirror the REST API's filter syntax exactly:
{
"collection": "blog",
"include": "featured:true,category:tech",
"exclude": "draft:true",
"sort": "date:desc",
"limit": 5
}- Comma-separated
field:valuepairs. - AND-semantics for
include, OR-semantics forexclude. - Wildcards:
*foo*(contains),foo*(starts with),*foo(ends with). - Public callers always get
draft:truemerged intoexcludeserver-side — drafts can never leak.
search_collection and search_collections take a free-text query:
- Default AND across terms (
rust performancematches items containing both). orbetween terms switches to OR semantics."quoted phrases"match contiguously.
Every tool that returns object data accepts an optional format parameter:
{"collection": "blog", "id": "hello-world", "format": "markdown"}format |
What you get for styledtext / localizedstyledtext properties |
|---|---|
markdown (default) |
Stored HTML converted to GitHub-flavored markdown. Friendliest for AI agents. |
html |
Raw stored HTML — pass-through. |
text |
HTML stripped to plain text with entities decoded. |
For localizedstyledtext (locale-keyed objects), each locale's HTML is converted independently and the keys preserved.
# Show enabled state, edition gate, and the tool list per persona.
# Saved-query tools from collection MCP cards are included and
# annotated "(saved query)"; --json lists them in `schema_tools`.
tcms mcp:status
# Invoke a tool locally without going through the HTTP endpoint
tcms mcp:test query_collection --params='{"collection":"blog","limit":3}'
# Simulate an anonymous caller (default persona is admin)
tcms mcp:test query_collection --params='{"collection":"blog"}' --persona=public
# Machine-readable output
tcms mcp:status --json
tcms mcp:test list_collections --jsontcms mcp:test runs the tool directly against the registry — it doesn't hit the HTTP endpoint or the rate limiter. Useful for scripted smoke tests and CI.
Extensions can publish their own MCP tools and resources via ExtensionContext::registerMcpTool(), registerMcpResource(), and registerMcpResourceTemplate(). Custom tools and resources show up alongside the core surface — AI agents see them the same way they see query_collection or tcms://blog/. The bundled Documentation Tools (docs_search, docs_get, docs_lookup) are a real-world example — a bundled extension registering authenticated-access tools with the same ExtensionContext API a third-party extension would use.
See Extending MCP for the full authoring guide, including registration examples, naming conventions, capability toggles, and real-world use cases.
Anonymous callers are throttled at mcp.publicIpPerMinute requests per IP per 60-second window. The counter routes through CacheManager — Redis on production installs that have it (cross-worker accurate), graceful fallback through APCu / Memcached / filesystem.
A 429 response includes Retry-After, X-RateLimit-Limit, and X-RateLimit-Window headers.
Multi-worker caveat: APCu-only installs see a per-worker counter, so effective limit ≈ publicIpPerMinute × worker_count. Configure Redis for accurate accounting.
Reverse-proxy rate limits: This is T3's application-layer throttle for anonymous callers. If you also rate-limit at the web server (Nginx limit_req, etc.), do not put the MCP endpoint in the same low-rate zone you use for admin login — agents batch tool calls in parallel and will hit a 1 req/s login zone instantly, surfacing as timeouts rather than clean 429s. Give /mcp its own generous zone with a healthy burst. See Nginx → Rate limiting the MCP endpoint for a worked config.
Tool dispatch is logged to tcms-data/.system/logs/mcp.log under the mcp-activity channel at DEBUG level. Each call writes:
[2026-05-21T10:22:33-07:00] mcp-activity.DEBUG: Executing tool {"name":"query_collection","arguments":{"collection":"blog","limit":3}}
[2026-05-21T10:22:33-07:00] mcp-activity.DEBUG: Tool executed successfully {"name":"query_collection","result_type":"array"}
Tool errors land at ERROR level.
Active MCP client sessions cache the tools/list response from initialize. When any setting that affects the tool surface changes — mcp.* settings, a schema's mcp.access toggle, a per-property mcp.expose flip — sessions are dropped from tmp/mcp-sessions/ and clients auto-reconnect on their next request.
The reconnect path is universally supported by conformant MCP clients. A future enhancement will add notifications/tools/list_changed push for in-place refresh without the reconnect blip.
Failed authentication returns:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="MCP", error="login_required"error="login_required" for absent credentials (no API key + mcp.publicAccess: false); error="invalid_token" for bad/insufficient credentials. MCP hosts use this to pick the right lazy-auth UX.
Before submitting your site to Anthropic's Connector Directory, walk through:
- HTTPS enforced (production deployment).
-
WWW-Authenticateheader returned on 401 — verified in this implementation. - Discovery JSON published at
/.well-known/mcp.json. - Every tool has full annotations (
title,readOnlyHint,destructiveHint,idempotentHint,openWorldHint) — Total CMS ships these on every core tool. - Tool names ≤ 64 characters (including any
toolPrefix). - Read and write operations split into separate tools (no mixed-mode tools).
- Tool descriptions don't instruct Claude ("always do X", "you must call Y first") — Total CMS uses
setInstructions()for cross-tool guidance. - Tools report errors by throwing
Mcp\Exception\ToolCallExceptionwith a recovery hint in the message — the SDK setsisError: truefor you (see Extending MCP, "Returning data and reporting errors"). A hand-built['isError' => true, ...]return array does NOT set the outerCallToolResult.isError. - Lazy authentication verified: public tools work unauthenticated; only protected tools challenge.
- Submission slug chosen carefully (fixed after publication).
- Anonymous content is opt-in.
mcp.publicAccessdefaults totrue, so anonymous callers can open a session, butmcp.accessdefaults toadminon every collection, so a fresh install never leaks content until the operator opts a collection in. Setmcp.publicAccess: falseto turn anonymous callers away with a 401. - Drafts are server-filtered. Public/anonymous callers can never see
draft:trueitems regardless of caller intent —query_collection,search_collection, andget_objectenforce this server-side. AUTHENTICATED (OAuth) callers see drafts only in collections where their access groups grantread— publicmcp.accessexposure by itself does not unlock drafts, even though it unlocks published content.get_view/query_vieware the one exception: a view's result is built with full access, drafts included, and is gated by thedataviewspermission as a whole — see Data views follow the data views grant. - Public registration carries automatic login. Forms that use the public registration endpoint auto-log the new user in; gate them with CAPTCHA / rate limit / email verification when the access group new users land in reaches sensitive content. (Unrelated to MCP directly, but worth flagging — the same operator who exposes a collection to MCP might also be running public registration.)
- API keys are scoped. A key scoped only to
/collections/blogdoes NOT unlock MCP; the operator must explicitly include/mcp(or*) in the scope. - No prompt-injection mitigation at the tool layer. Content stored in
styledtextfields is returned to the agent verbatim (after format conversion). Operators with untrusted user-generated content should sanitize at write time, not rely on MCP-side filtering.
The full MCP server, including all of the following, ships in 3.5:
- Core MCP server —
/mcpendpoint, three personas, full tool catalog (this page). - MCP resources — collection / data-view URIs and
resources/subscribewith SSE notifications. - Saved-query tools — JSON-defined query tools per collection, no PHP needed (Saved-Query Tools).
- MCP prompts — templated workflows like "draft a blog post in our voice" or "audit broken links" (Prompts).
- OAuth 2.1 + PKCE — full authorization-code flow, scoped tokens, customer-visible activity dashboard, the "connect Joe's Bistro to Claude" path (OAuth Server).
- Pluggable search providers — extension hook for swapping the built-in text search for Algolia, Meilisearch, etc.; built-in
textprovider always available. - Bundled documentation tools —
docs_search,docs_get,docs_lookup, shipped enabled by default and matched to this install's own version (Documentation Tools).
- SSE streaming for long tool responses — currently every response is a single JSON-RPC reply.
- Object-level resource subscriptions (subscribe to a single
tcms://blog/post-1rather than the collection) — deferred indefinitely; the collection-level model meets the typical agent's needs. - MCP sampling — letting the server request inference from the client. Not on the near-term roadmap.
See the planning notes in docs/planning/mcp-server.md for the full forward roadmap.