Search by

nobrainer / silverstripe-mcp

Nobrainer

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

Statistics

Installs: 6

Dependents: 0

Suggesters: 0

v0.14.0 2026-10-09 13:08 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:

  1. Open Claude Desktop → Settings → MCP Servers → Add Server
  2. Enter the URL: https://your-site.com/mcp
  3. Claude Desktop discovers OAuth endpoints via /.well-known/oauth-authorization-server
  4. Your browser opens and you log in with your SilverStripe credentials
  5. 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

URLPurpose
/.well-known/oauth-authorization-serverAutodiscovery metadata (RFC 8414)
POST /mcp/oauth/registerDynamic client registration (RFC 7591)
GET /mcp/oauth/authorizeAuthorization endpoint — redirects to SS login
POST /mcp/oauth/tokenToken endpoint — authorization code → Bearer token

These are consumed by MCP clients automatically; you do not call them directly.

Tools

ToolDescription
page_listList pages (draft or live) with publication status
page_getAll fields of a page, including Content
page_createCreate a page as draft
page_updateUpdate draft fields
page_publish / page_unpublishPublication workflow
page_deleteArchive a page (draft + live)
dataobject_classesList available DataObject classes
dataobject_queryFilter/sort/paginate any DataObject list
dataobject_get / dataobject_create / dataobject_update / dataobject_deleteGeneric ORM CRUD
dataobject_relationPaged records of one has_many / many_many relation of a record
dataobject_publishPublish/unpublish versioned DataObjects
dataobject_schemaFields, relations and extension-provided info for one class
dataobject_merge_jsonMerge a patch into a JSON text field
asset_uploadCreate a File/Image record with content
dev_buildRun dev/build (requires ADMIN or CAN_DEV_BUILD)
flushFlush 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: null means nothing is linked. Polymorphic has_one relations show the stored class.
  • List relations show up to 20 IDs (RelationSummary.max_ids) and the full count; dataobject_relation pages through the rest.
  • Counts and IDs (the summary, and total in dataobject_relation) are not permission-checked, so they can include records the user may not view; fetching a related record is checked, and dataobject_relation leaves such records out of records.
  • A relation that can't be read shows "error": "Relation could not be read"; the details go to the log.
  • Relations to DataObjectTool.excluded_classes and blocked fields are left out, as are bookkeeping relations such as LinkTracking, VirtualPages and the permission groups and members on pages and files (RelationSummary.skipped_relations). BackLinks (where a page or file is used) and FileTracking (files used in HTML content) stay. dataobject_relation still 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:

SiteToolsWhat saving does
Fluent with 2+ localestranslation_overview, translation_status, translation_get_bundle, translation_save, translation_publish, translation_undo, translation_glossary_setWrites into the target locale; the default locale is never changed
No Fluent (one language)Same without translation_status and translation_publishReplaces 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 owns or has_many are translated with that page; other translatable classes (e.g. many_many tags) are shared classes with their own class scope. Opt a page-reached class into also_shared_classes to give it a class scope as well, e.g. records listed by query that no page owns.
  • A class scope takes record_ids to 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_required and a one-time token; the same call with confirm goes 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_undo restores 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

URIContent
silverstripe://schemaOverview 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:

HookUse
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_ACCESS permission.
  • 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