nobrainer / silverstripe-mcp
MCP (Model Context Protocol) server for SilverStripe CMS - lets AI assistants interact with the CMS over Streamable HTTP
Package info
bitbucket.org/nobrainerweb/silverstripe-mcp
Type:silverstripe-vendormodule
pkg:composer/nobrainer/silverstripe-mcp
Requires
- php: ^8.1
- silverstripe/cms: ^5.2 || ^6.0
- silverstripe/framework: ^5.2 || ^6.0
- silverstripe/versioned: ^2.2 || ^3.0
Requires (Dev)
None
Suggests
- tractorcow/silverstripe-fluent: Translation into other locales with the translation_* tools
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-09 13:10:49 UTC
README
An MCP (Model Context Protocol) server for SilverStripe CMS 6. Lets AI assistants like Claude read and manage pages, query the ORM, inspect the database schema and run dev/build — all gated by SilverStripe's normal permission system.
The server implements the MCP Streamable HTTP transport: clients POST JSON-RPC
messages to /mcp and receive JSON responses. The server is stateless, which fits
PHP's request/response model. (The older, deprecated HTTP+SSE transport requires a
persistent server-push channel and is not supported under PHP-FPM.)
Installation
composer require nobrainer/silverstripe-mcp
vendor/bin/sake db:build --flush
dev/build creates an MCP Access group holding the MCP_ACCESS permission, and
the three OAuth database tables (MCPOAuthClient, MCPOAuthCode, MCPOAuthToken).
Configuration
Create (or pick) a member that the AI should act as, add it to the MCP Access
group, and give it whatever CMS permissions it should have (e.g. content author).
Every operation respects that member's canView() / canEdit() / canPublish() /
canDelete().
Connecting a client
Claude Desktop (OAuth — recommended)
Claude Desktop supports OAuth autodiscovery. Add the server URL in the Claude Desktop settings and it will handle authentication automatically:
- Open Claude Desktop → Settings → MCP Servers → Add Server
- Enter the URL:
https://your-site.com/mcp - Claude Desktop discovers OAuth endpoints via
/.well-known/oauth-authorization-server - Your browser opens and you log in with your SilverStripe credentials
- Claude Desktop receives a Bearer token (valid 90 days) and connects
The member you log in as must be in the MCP Access group.
OAuth token lifetime is configurable (default 90 days):
Nobrainer\MCP\OAuth\OAuthController:
token_lifetime: 7776000 # seconds
Claude Code (API key)
claude mcp add --transport http silverstripe https://your-site.com/mcp \
--header "Authorization: Bearer your-api-key"
API key via environment
Set in .env on the server:
MCP_API_KEY="some-long-random-string"
MCP_MEMBER_EMAIL="mcp@example.com"
API key via YAML (multiple keys / members)
Nobrainer\MCP\Auth\MCPAuthenticator:
api_keys:
'some-long-random-string': 'mcp@example.com'
'another-key': 'editor@example.com'
HTTP Basic auth
Pass a member's email and password as HTTP Basic credentials. Disable if not needed:
Nobrainer\MCP\Auth\MCPAuthenticator:
allow_basic_auth: false
OAuth endpoints
| URL | Purpose |
|---|---|
/.well-known/oauth-authorization-server | Autodiscovery metadata (RFC 8414) |
POST /mcp/oauth/register | Dynamic client registration (RFC 7591) |
GET /mcp/oauth/authorize | Authorization endpoint — redirects to SS login |
POST /mcp/oauth/token | Token endpoint — authorization code → Bearer token |
These are consumed by MCP clients automatically; you do not call them directly.
Tools
| Tool | Description |
|---|---|
page_list | List pages (draft or live) with publication status |
page_get | All fields of a page, including Content |
page_create | Create a page as draft |
page_update | Update draft fields |
page_publish / page_unpublish | Publication workflow |
page_delete | Archive a page (draft + live) |
dataobject_classes | List available DataObject classes |
dataobject_query | Filter/sort/paginate any DataObject list |
dataobject_get / dataobject_create / dataobject_update / dataobject_delete | Generic ORM CRUD |
dataobject_relation | Paged records of one has_many / many_many relation of a record |
dataobject_publish | Publish/unpublish versioned DataObjects |
dataobject_schema | Fields, relations and extension-provided info for one class |
dataobject_merge_json | Merge a patch into a JSON text field |
asset_upload | Create a File/Image record with content |
dev_build | Run dev/build (requires ADMIN or CAN_DEV_BUILD) |
flush | Flush manifests and caches (requires ADMIN) |
Relations
page_get and dataobject_get add a "_relations" summary, so Claude can follow relations instead of guessing queries. For example, for a product class with an image, a gallery and variants:
"_relations": {
"Image": { "type": "has_one", "class": "SilverStripe\\Assets\\Image", "id": null },
"Category": { "type": "has_one", "class": "App\\Model\\Category", "id": 4 },
"Images": { "type": "many_many", "class": "SilverStripe\\Assets\\Image", "count": 57, "ids": [311, 312, "..."], "truncated": true },
"Variants": { "type": "has_many", "class": "App\\Model\\Variant", "count": 3, "ids": [88, 91, 140], "via": "ProductID" }
}
id: nullmeans nothing is linked. Polymorphic has_one relations show the stored class.- List relations show up to 20 IDs (
RelationSummary.max_ids) and the fullcount;dataobject_relationpages through the rest. - Counts and IDs (the summary, and
totalindataobject_relation) are not permission-checked, so they can include records the user may not view; fetching a related record is checked, anddataobject_relationleaves such records out ofrecords. - A relation that can't be read shows
"error": "Relation could not be read"; the details go to the log. - Relations to
DataObjectTool.excluded_classesand blocked fields are left out, as are bookkeeping relations such asLinkTracking,VirtualPagesand the permission groups and members on pages and files (RelationSummary.skipped_relations).BackLinks(where a page or file is used) andFileTracking(files used in HTML content) stay.dataobject_relationstill reads skipped relations when asked for by name. - Records in lists (
dataobject_query,dataobject_relation) get a compact outline instead, built without extra queries, so Claude can see what lies one level further down:"_relations": { "Image": "has_one Image #27", "Category": "has_one Category, none", "Images": "many_many Image" } - Create, update and publish results don't include relations.
Translation
Translation tools for Claude. They appear automatically and adapt to the site:
| Site | Tools | What saving does |
|---|---|---|
| Fluent with 2+ locales | translation_overview, translation_status, translation_get_bundle, translation_save, translation_publish, translation_undo, translation_glossary_set | Writes into the target locale; the default locale is never changed |
| No Fluent (one language) | Same without translation_status and translation_publish | Replaces the existing text (draft, after confirmation) |
How it works:
- A bundle is one page with its content blocks, links and sub-items (or site settings, menus, or a shared class such as tags). Translatable fields come from the Fluent config.
- Records a page reaches through
ownsorhas_manyare translated with that page; other translatable classes (e.g. many_many tags) are shared classes with their ownclassscope. Opt a page-reached class intoalso_shared_classesto give it a class scope as well, e.g. records listed by query that no page owns. - A
classscope takesrecord_idsto translate only specific records. - Claude only receives and returns plain-text segments; the HTML, links and shortcodes are rebuilt from the original.
- Pages, blocks and form fields are saved as drafts. Unversioned records (settings, menus, tags) are live on save and flagged
live_on_save. - Status per field: missing, untranslated, outdated (the original changed since), translated.
- Risky writes return
confirmation_requiredand a one-time token; the same call withconfirmgoes ahead. This covers new language versions, in-place translation, live-on-save records, publish, undo, bulk changes, and generic writes through/mcp?l=<locale>. translation_undorestores the text from before translation and never overwrites later edits.
Setup:
Nobrainer\MCP\Translation\TranslationService:
site_context: 'Who the site is for, in one or two sentences'
# Optional
Nobrainer\MCP\Translation\TranslationMode:
enabled: false # no translation tools
site_locale: 'da_DK' # one-language sites, if i18n.default_locale isn't the site's language
Nobrainer\MCP\Util\WriteGuard:
large_change_check: true # confirm page_update/dataobject_update that replace most of a record's text
Nobrainer\MCP\Translation\BundleCollector:
also_shared_classes: # also a "class" scope with every record, besides the page bundles
- App\Model\GalleryItem
Permissions: translators need MCP_ACCESS and CMS page access (Fluent's per-locale group restrictions apply). Editing the glossary and instructions needs MCP_TRANSLATION_MANAGE (admins have it).
On Fluent sites every tool result includes "_locale", the locale the call read or wrote. The server also returns instructions on initialize and offers two prompts: translate_page and translation_status.
Resources
| URI | Content |
|---|---|
silverstripe://schema | Overview of all DataObject classes |
silverstripe://schema/{class} | Fields and relations for one class (use . instead of \ in the FQCN) |
Extension hooks
Projects can teach the server about their own conventions with an Extension on Nobrainer\MCP\MCPController:
| Hook | Use |
|---|---|
updateInstructions(array &$parts) | Append text to the server instructions sent on initialize |
updateToolDefinitions(array &$tools) | Change tool descriptions or add arguments to inputSchema |
updateToolCall(callable &$call, string $name, array &$arguments) | Wrap a tool call, e.g. to run it in another site context chosen by an argument added above |
A DataObject extension can add notes to dataobject_schema results with updateMCPSchema(array &$schema).
class SiteContextExtension extends Extension
{
protected function updateToolCall(callable &$call, string $name, array &$arguments): void
{
$inner = $call;
$call = fn() => SiteContext::withSite($arguments['site_id'] ?? null, $inner);
}
}
Security notes
- Every request must authenticate and the member must hold the
MCP_ACCESSpermission. - All reads and writes go through the ORM and respect record-level
can*()checks; drafts and live stages are handled explicitly (writes always target draft). - Sensitive member fields (password hashes, login tokens, …) are stripped from output and rejected on write — see
RecordSerializer.blocked_fields. - Password/session storage classes are hidden entirely — see
DataObjectTool.excluded_classes. - OAuth authorization codes are single-use and expire after 5 minutes.
- OAuth Bearer tokens are stored as SHA-256 hashes in the database.
- Browser-origin requests are rejected unless the origin is whitelisted in
MCPController.allowed_origins(DNS-rebinding protection). - Confirmation tokens are random, stored hashed, single use and bound to member, tool, arguments, locale and the confirmed message. The transport is stateless, so the server can't force a client to show the message to the user.
- Translation input is plain text only: markup and shortcodes are rejected and text is inserted into the original DOM as escaped text.
- Always serve the endpoint over HTTPS.
License
BSD-3-Clause