Skip to content
MCG Filament CMS Demo

The MCP server

This document explains the Model Context Protocol (MCP) server that the package ships: how to connect an AI client to it, the tools, resources and prompts it exposes, how an agent is expected to use them, how it supports turning a Figma file into a skin, its configuration, and its security model. It is written for the developer who connects a client to a site and for the developer responsible for that site's security.

The MCP server exposes the operations of the admin panel to an AI client such as Claude Code, Claude Desktop or any other client that implements the protocol. An agent connected to it can browse the site, read a page's blocks, create pages, upload media, manage redirects and publish.

The server is enabled by default over the stdio transport and disabled by default over the HTTP transport. Connecting gives the steps for each situation, and Security explains what enabling the HTTP transport exposes.

Connecting

How a client connects depends on where the site runs and on the client. This section has one set of steps for each situation:

Situation Transport Credential Steps
A site on this computer, from Claude Code or Claude Desktop stdio (php artisan mcp:start) None A local site
A live site, from Claude Code HTTP API token (Sanctum) A live site from Claude Code
A live site, from Claude Desktop HTTP, through the mcp-remote bridge API token (Sanctum) A live site from Claude Desktop with a token
A live site, from Claude Desktop, with no token to copy HTTP, as a custom connector OAuth (Passport) A live site from Claude Desktop as a custom connector

A live site is any installation reached over the network, including a staging site. The two token-based HTTP situations also work against a site on this computer that Herd or Valet serves at a .test address. The custom connector does not, because Claude Desktop reaches it through Anthropic's servers, which cannot reach a .test address.

A local site from Claude Code or Claude Desktop

The stdio transport starts the server as a process on the same computer, so it needs no credential and no web server.

From Claude Code

  1. In a terminal at the application's root (the directory that contains artisan), register the server with Claude Code:

Shell

claude mcp add my-site -- php artisan mcp:start cms

To register it from any other directory, give the absolute path to artisan instead:

Shell

claude mcp add my-site -- php /full/path/to/your-app/artisan mcp:start cms
  1. In Claude Code, type /mcp to confirm that the server connected.

The two names in the command have different roles:

  • my-site is the name Claude Code shows for this server. It prefixes every tool name, for example mcp__my-site__cms_get_content. Replace it with the site's own name. See Naming each installation.
  • cms is the handle this application registered for the server, set by cms.mcp.handle. It is the same on every installation that keeps the default.

The working directory matters because mcp:start boots the Laravel application, which requires the directory that holds artisan.

From Claude Desktop

  1. In Claude Desktop, open Settings β†’ Developer β†’ Edit Config. This opens claude_desktop_config.json.
  2. In claude_desktop_config.json, add the server under mcpServers:

JSON

{
  "mcpServers": {
    "my-site": {
      "command": "php",
      "args": ["/full/path/to/your-app/artisan", "mcp:start", "cms"]
    }
  }
}
  1. In Claude Desktop, quit the app: on Windows, right-click the Claude icon in the system tray and choose Quit; on macOS, choose Claude β†’ Quit Claude. Then open Claude Desktop again.

The path to artisan must be absolute. Claude Desktop does not inherit the shell's working directory or PATH.

Enabling the HTTP endpoint

A live site is reached over the HTTP transport, at cms.mcp.web.path (/mcp/cms by default) on the site's APP_URL. The transport is off by default. Two settings switch it on:

Variable Config key Value
CMSMCPWEBENABLED cms.mcp.web.enabled true
CMSMCPWEBAUTH cms.mcp.web.auth sanctum (API tokens), oauth (OAuth) or both

With sanctum, the package puts Laravel Sanctum's auth:sanctum guard on the route; with oauth, Passport's auth:api; with both, auth:sanctum,api, which tries Sanctum first. The steps below set up sanctum. The OAuth setup is in A live site from Claude Desktop as a custom connector. Read The HTTP transport under Security before enabling it.

One-time setup with the installer

  1. In a terminal at the application's root, run php artisan cms:install.
  2. At "How should AI clients connect to this site over HTTP?", choose "API tokens (Laravel Sanctum), for Claude Code and for Claude Desktop through mcp-remote".

    The installer then:
  3. Ends this step when Sanctum is not installed: it prints the command composer require laravel/sanctum, writes nothing for the HTTP endpoint, and continues with the rest of the install. Run that command, then php artisan cms:install again to finish this step.
  4. Publishes Sanctum's personal_access_tokens migration, unless a database/migrations/*_create_personal_access_tokens_table.php file or the personal_access_tokens table already exists.
  5. Prints the two lines to add to the User model when it has no createToken() method. The installer never edits the User model.
  6. Writes CMS_MCP_WEB_ENABLED=true and CMS_MCP_WEB_AUTH=sanctum to .env. When config/cms.php sets mcp.web.enabled to a fixed value, it also prints the line to change there, which the end of One-time setup by hand describes.
  7. After the migrations and the administrator step, asks "Issue a token for the MCP endpoint now?" and "Which user should the agent act as?". The second question takes an email address and defaults to the administrator created in the same run. The token it issues is named mcp. Both questions are skipped when the User model has no createToken() method or the personal_access_tokens table does not exist (for example after --no-migrate). The installer then prints "No token was issued: that needs HasApiTokens on the User model and the personal_access_tokens table. When both exist, run php artisan cms:mcp-token." instead.
  8. Ends with the Claude Code command and the Claude Desktop configuration for this installation. Both contain the token when one was issued, and the placeholder TOKEN when none was.

    The installer does not ask the question when cms.mcp.web.enabled is already true, when --no-mcp is given, or when the run is non-interactive.
  9. In the live site's environment settings (for example the environment variables of a Laravel Cloud environment), set CMS_MCP_WEB_ENABLED=true and CMS_MCP_WEB_AUTH=sanctum, deploy, and run the migrations.

One-time setup by hand

  1. In a terminal at the application's root, install Sanctum if composer show laravel/sanctum reports it missing:

Shell

composer require laravel/sanctum
  1. In the same terminal, publish Sanctum's migration only when neither a database/migrations/*_create_personal_access_tokens_table.php file nor a personal_access_tokens table exists, then migrate:

Shell

php artisan vendor:publish --tag=sanctum-migrations
php artisan migrate

Sanctum names each published copy with the current time, so publishing it a second time adds a second migration, which fails on a database that already has the table.

  1. In the User model (app/Models/User.php in a standard application), add the trait:

PHP

use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable implements FilamentUser
{
    use HasApiTokens;
}
  1. In .env, and in the live site's environment settings, add:

INI

CMS_MCP_WEB_ENABLED=true
CMS_MCP_WEB_AUTH=sanctum
  1. In a terminal at the application's root, run php artisan config:clear.

A config/cms.php published before 0.82.0 has 'enabled' => false written into its mcp.web group, so CMS_MCP_WEB_ENABLED has no effect there. Change that line to 'enabled' => (bool) env('CMS_MCP_WEB_ENABLED', false),. The auth key needs no edit: the package adds keys missing from a published mcp group.

Issuing a token

Each client needs a token, issued on the site it connects to. A token issued on a local installation does not work against the live site.

  1. In a terminal on the site (the application's root on that server, or the host's command runner), run:

Shell

php artisan cms:mcp-token
  1. At "Which user should the agent act as?", type part of the user's email address and choose the account. The agent acts with that account's access.
  2. At "What should the token be called?" (default mcp), enter a name for this client, for example claude-desktop-laptop. Tokens are revoked by name.
  3. At "Revoke existing tokens with that name first?", answer yes to replace an earlier token of the same name.

The command prints the token once, followed by the user, the endpoint URL, whether the HTTP transport is enabled, and how it authenticates. Sanctum stores only a hash of the token, so a lost token is replaced, not recovered.

When the user's id or email address is given as an argument, the command asks nothing and reads the name and the revocation from its options, for example php artisan cms:mcp-token editor@example.com --name=claude-desktop-laptop --revoke.

A live site from Claude Code

  1. In any terminal, register the server, replacing my-site, the URL and TOKEN:

Shell

claude mcp add --transport http my-site "https://example.com/mcp/cms" --header "Authorization: Bearer TOKEN"
  1. In Claude Code, type /mcp. The server is listed as connected.

Claude Code sends the header with every request. my-site follows the same rule as for the local site: see Naming each installation. cms:install prints this command with the endpoint URL built from APP_URL.

A live site from Claude Desktop with a token

Claude Desktop's configuration file starts local processes only. The mcp-remote bridge is a local process that relays to the HTTP endpoint, started with npx.

  1. On the computer that runs Claude Desktop, install Node.js, which provides npx.
  2. In Claude Desktop, open Settings β†’ Developer β†’ Edit Config. This opens claude_desktop_config.json.
  3. In claude_desktop_config.json, merge the object below into the file, replacing my-site, the URL and TOKEN. If the file already has an "mcpServers" object, add only the "my-site" entry inside it:

JSON

{
  "mcpServers": {
    "my-site": {
      "command": "npx",
      "args": [
        "-y",
        "mcp-remote",
        "https://example.com/mcp/cms",
        "--header",
        "Authorization:${AUTH_HEADER}"
      ],
      "env": {
        "AUTH_HEADER": "Bearer TOKEN"
      }
    }
  }
}
  1. In Claude Desktop, quit the app: on Windows, right-click the Claude icon in the system tray and choose Quit; on macOS, choose Claude β†’ Quit Claude. Then open Claude Desktop again.
  2. In Claude Desktop, open Settings β†’ Developer. The server is listed there; an error shown beside it names the failure.

The header is written as Authorization:${AUTH_HEADER}, with the value in env, because Claude Desktop on Windows splits an argument that contains a space, and Authorization: Bearer TOKEN contains two. mcp-remote replaces ${AUTH_HEADER} with the variable's value, so the argument itself has no space. cms:install prints this object with the endpoint URL built from APP_URL and the token it issued, or TOKEN when it issued none.

A live site from Claude Desktop as a custom connector

Claude Desktop's Settings β†’ Connectors β†’ Add custom connector takes a URL and signs in with OAuth: the person connecting signs in to the site in a browser and approves the connection, and no token is copied by hand. The site provides OAuth through Laravel Passport, which is not a dependency of this package; the package registers what OAuth needs only when Passport is installed and CMS_MCP_WEB_AUTH is oauth or both.

Claude Desktop reaches a custom connector through Anthropic's servers, so the site must be reachable over HTTPS from the internet. A .test site on a developer's computer cannot complete the connection.

One-time setup on the site

  1. In a terminal at the application's root, install Passport:

Shell

composer require laravel/passport
  1. In the same terminal, create Passport's keys and tables:

Shell

php artisan passport:install

It writes storage/oauth-private.key and storage/oauth-public.key, publishes Passport's migrations and offers to run them.

  1. In config/auth.php, add an api guard with the passport driver to the guards array:

PHP

'api' => [
    'driver' => 'passport',
    'provider' => 'users',
],
  1. In the User model (app/Models/User.php in a standard application), make one of two changes:
  2. For OAuth only (oauth), add Passport's interface and trait, and remove Laravel\Sanctum\HasApiTokens if the class uses it:

PHP

use Laravel\Passport\Contracts\OAuthenticatable;
use Laravel\Passport\HasApiTokens;

class User extends Authenticatable implements FilamentUser, OAuthenticatable
{
    use HasApiTokens;
}

Removing Sanctum's trait stops the delivery API, the API Tokens screen and existing MCP tokens from working, because all three depend on it. To keep them, choose both instead.

  1. For OAuth and API tokens together (both), keep Laravel\Sanctum\HasApiTokens and do not add Passport's trait.

    The two HasApiTokens traits cannot be used together. Sanctum's declares protected $accessToken; and Passport's declares protected ?ScopeAuthorizable $accessToken = null;, and PHP refuses a class that uses both. both works with Sanctum's trait alone for two reasons:
  2. Sanctum's guard accepts only a model that uses Sanctum's trait.
  3. Passport's guard calls only withAccessToken() on the user, and Sanctum's trait provides that method.
  4. In a terminal at the application's root, run php artisan cms:install and, at "How should AI clients connect to this site over HTTP?", choose "OAuth (Laravel Passport), for Claude Desktop's Add custom connector" or "Both: API tokens and OAuth on the same endpoint". The installer then:
  5. Ends this step when Passport is not installed or, for "Both", when either Passport or Sanctum is not installed: it prints the composer require command for the missing packages, writes nothing for the HTTP endpoint, and continues with the rest of the install. Run that command, then php artisan cms:install again to finish this step.
  6. For "Both", publishes Sanctum's migration as described in One-time setup with the installer.
  7. Prints the guard entry from step 3 when config/auth.php has no api guard with the passport driver.
  8. Prints php artisan passport:install when the oauth_clients table or the private key is missing, and always prints the PASSPORT_PRIVATE_KEY and PASSPORT_PUBLIC_KEY lines from step 6.
  9. Prints the User model change from step 4 when the model does not have it. For "OAuth" it also warns when the model uses Sanctum's trait; for "Both" it warns when the model uses Passport's.
  10. Writes CMS_MCP_WEB_ENABLED=true and CMS_MCP_WEB_AUTH=oauth (or both) to .env, and ends with the steps in Connecting Claude Desktop, pointed at this installation's APP_URL. For "Both" it also offers a token and prints the token connections.

    The installer never edits config/auth.php or the User model, and never runs passport:install. To skip the installer, add CMS_MCP_WEB_ENABLED=true and CMS_MCP_WEB_AUTH=oauth (or both) to .env yourself, then run php artisan config:clear.
  11. In the live site's environment settings, set:

INI

CMS_MCP_WEB_ENABLED=true
CMS_MCP_WEB_AUTH=oauth
PASSPORT_PRIVATE_KEY="<contents of storage/oauth-private.key>"
PASSPORT_PUBLIC_KEY="<contents of storage/oauth-public.key>"

Use CMS_MCP_WEB_AUTH=both for OAuth and API tokens together. Then deploy and run the migrations. The two PASSPORT_ variables are needed on a host whose filesystem is replaced on every deploy, such as Laravel Cloud, where the key files do not survive. Every instance of the site must use the same pair, or a token signed by one instance is refused by another.

  1. In the live site's environment settings, only when an OAuth client other than Claude Desktop will connect, such as ChatGPT, add that client's redirect domain to CMS_MCP_OAUTH_REDIRECT_DOMAINS, keeping https://claude.ai in the list:

INI

CMS_MCP_OAUTH_REDIRECT_DOMAINS=https://claude.ai,https://client-redirect-domain.example

The site accepts client registrations only for redirect URIs under https://claude.ai/ by default, and refuses any other client's registration with 400 and the error invalid_redirect_uri. Take the client's redirect domain from that client's own documentation. Then deploy, or run php artisan config:clear (or php artisan config:cache). The rules are in Restricting OAuth client registration.

Connecting Claude Desktop

  1. In Claude Desktop, open Settings β†’ Connectors and click Add custom connector.
  2. In the dialog, enter a name for the site and the endpoint URL, for example https://example.com/mcp/cms, and add it.
  3. In Claude Desktop, click Connect on the new connector. A browser opens the site's sign-in page: the CMS panel's, or the application's own page when it has a route named login.
  4. In the browser, sign in with an account that has CMS access. The consent page appears with the heading Authorize Claude, the signed-in account's email address under Signed in as, the line "You will be returned to claude.ai", and the scope "Use MCP server" under Permissions requested. "Claude" is the client name Claude Desktop registers; the name typed for the connector in step 2 is not sent to the site. Click Authorize only when the line names claude.ai.
  5. In the browser, click Authorize. The browser hands the connection back to Claude Desktop, and the connector's tools become available.

The connection works in these steps:

  1. Claude Desktop calls the endpoint without a token. The guard answers 401, and laravel/mcp adds a WWW-Authenticate header naming /.well-known/oauth-protected-resource/mcp/cms.
  2. That document names the site as the authorization server. Claude Desktop reads /.well-known/oauth-authorization-server, registers itself as a client at POST /oauth/register, and opens /oauth/authorize in the browser.
  3. A signed-out visitor is sent to the login route. When the application has none, the package registers GET /oauth/sign-in under that name, which redirects to the CMS panel's sign-in page. After signing in, the browser returns to /oauth/authorize, which shows the package's consent screen (cms::mcp.authorize), unless the application has set its own with Passport::authorizationView().
  4. Authorize issues a code, Claude Desktop exchanges it at POST /oauth/token for a Passport access token and a refresh token, and sends the access token with every call.

The panel's sign-in page refuses an account that fails canAccessPanel(). An application that has its own login route lets any of its accounts sign in and approve; the tools still refuse an account that fails canAccessPanel(), as described in Authorization.

A site that already has token clients and adds OAuth chooses both, so the route tries Sanctum's guard first and existing tokens keep working. Changing from sanctum to oauth stops every Sanctum token from working on the MCP endpoint.

The package has no screen for OAuth connections. To revoke a user's connections, revoke both their access tokens and the refresh tokens issued with them; revoking only the access tokens lets the client obtain a new one with its refresh token. In php artisan tinker, replacing 5 with the user's id:

PHP

$ids = DB::table('oauth_access_tokens')->where('user_id', 5)->pluck('id');
DB::table('oauth_access_tokens')->whereIn('id', $ids)->update(['revoked' => true]);
DB::table('oauth_refresh_tokens')->whereIn('access_token_id', $ids)->update(['revoked' => true]);

This snippet revokes every Passport token the user holds, for every OAuth client. To revoke only Claude Desktop's connection, add ->where('client_id', 'CLIENT_ID') after ->where('user_id', 5) on the first line. CLIENT_ID is the id of an oauth_clients row that Claude Desktop registered. List the candidates with DB::table('oauth_clients')->get(['id', 'name', 'created_at']).

Claude Desktop registers its client with the name Claude and the redirect URI https://claude.ai/api/mcp/auth_callback, through POST /oauth/register. Each Connect attempt can register another client, so a site can hold several rows named Claude, and filtering by name can match more than one. To revoke every Claude Desktop connection a user holds, revoke the tokens for each of those client ids, or use the snippet without the client_id filter.

The consent screen is the package's view cms::mcp.authorize (resources/views/mcp/authorize.blade.php). It renders in Filament's simple layout, the layout of the CMS panel's sign-in page, with the CMS panel set as Filament's current panel. The page therefore uses the panel's compiled theme stylesheet, its colors, its logo and its light and dark mode setting, and needs nothing from the application's own Vite build. Its text is in resources/lang/en/cms.php under mcp_authorization. The package also adds Filament's SetUpPanel middleware for the CMS panel to Passport's GET /oauth/authorize route (passport.authorizations.authorize), so the Livewire requests the page makes run with the CMS panel; without it they fall back to Filament's default panel and fail on a site that has none.

The page shows three facts about the request:

  1. The heading Authorize {client name}. The client name is the client_name the client sent to POST /oauth/register. Registration is open to any visitor, so the name is not proof of who the client is.
  2. The line "You will be returned to {host}". The host is taken from the redirect_uri of the authorization request, which is where the authorization code is sent when the user clicks Authorize (McpHttpAuth::consentRedirectHost()). A client can register several redirect URIs, so the page uses the one the request asks for; a request with no redirect_uri is valid only for a client with one registered URI, and the page then shows that URI's host. The port is shown when the URI has one, and the scheme is shown in front of the host when it is not https, for example http://localhost:33418. For Claude Desktop the line reads "You will be returned to claude.ai".
  3. The signed-in account's email address under Signed in as.

The response carries X-Frame-Options: DENY and Content-Security-Policy: frame-ancestors 'none', so browsers refuse to display the consent page inside a frame on another site, where a visitor could be led into clicking Authorize without seeing the page.

The package does not use laravel/mcp's view mcp::authorize. That view is styled with Tailwind utility classes and loads the application's resources/css/app.css through @vite, so it is styled only when the application's Tailwind build scans laravel/mcp's views. A CMS site's resources/css/app.css is a skin and does not scan them: on such a site the page rendered with its icons at their natural size, unstyled buttons, and, in dark mode, near-black text on a near-black background.

The package's view sends Passport the same form fields as laravel/mcp's view: _token, state (empty), client_id and auth_token, posted to POST /oauth/authorize to approve and to DELETE /oauth/authorize (through a _method field) to cancel. An application that binds its own view with Passport::authorizationView() in a service provider keeps it; the package binds its view only when nothing is bound.

Naming each installation

A client connected to more than one site (for example a local install, a staging site and production) has two names to keep distinct for each site:

  1. The client's name for the server. This is the name given to claude mcp add, or the key under mcpServers in a JSON config. It prefixes every tool the agent calls, so two servers both registered as cms cannot be told apart by the person or the agent using them. Give each site its own name. cms:install prints the claude mcp add command with a name built from the project directory's name (Str::slug() of the base path's last segment).
  2. The name the server reports. The server sends cms.mcp.name in the MCP handshake, read from the CMS_MCP_NAME environment variable. When it is not set, the server reports Filament CMS: followed by app.name. The server's instructions also open with the site's app.name and app.url, and tell the agent to confirm it has the right site before writing when several CMS servers are connected.

Set APP_NAME and APP_URL correctly on each installation, because the reported name and the instructions are built from them. Two installations left on Laravel's default APP_NAME=Laravel report the same name.

Debugging a connection

To inspect a connection, run the MCP Inspector in a terminal at the application's root:

Shell

php artisan mcp:inspector cms

Do not run php artisan mcp:start cms in a terminal to test the server. It waits for protocol messages on stdin and produces no output, which looks like a hang.

Restart the client after changing code. mcp:start is a long-lived process, so a session started before an edit keeps serving the tools and the schemas it loaded when it booted. This is the usual cause of a tool ignoring an argument that was added since, or of cms_describe_block omitting a block's new field.

Agent skills

The package ships two agent skills. An agent skill is an instruction file that an AI coding agent such as Claude Code loads when its task matches the skill's description.

Skill Loaded when the agent What it covers
filament-cms-development writes or changes a block class or view, a layout, a skin, a content type, a route resolver or config/cms.php The cms:make- generators and where each writes, the block contract, the elements every layout needs, skin registration, and which file in docs/ to read for each task
filament-cms-content builds or edits pages through the cms tools of this MCP server The order of work for a new page, how cmsupdatecontent replaces the block list, snippets and shared regions, translating with cmstranslatecontent, and publishing only when asked

The MCP server's instructions reach an agent only while the server is connected. The skills also apply when the agent is editing PHP and Blade files in the application, where the server's instructions are not present.

The always-loaded guideline

A skill loads only when the agent decides its task matches. An agent that is planning, for example deciding how to store a new kind of record, may never load one. The package therefore also ships a Boost guideline, resources/boost/guidelines/core.blade.php, which Boost writes into the application's AGENTS.md and CLAUDE.md, so every agent session reads it.

The guideline contains:

  1. A table titled "Choose the CMS mechanism before writing code", mapping each kind of need to the CMS feature that meets it: a content type rather than a new table for editorial records, a snippet for repeated content, a core block before a new one, skin tokens before new CSS, and the form builder rather than a custom form.
  2. The rules that always apply: generate files with cms:make-*, never edit vendor/mcg/filament-cms, write content through the MCP tools.
  3. A table of every feature the package provides, with its documentation file.
  4. Which skill to activate for which task.

Boost installs the guideline in the same boost:update run that installs the skills, for every package listed in boost.json's packages array.

Claude Code reads CLAUDE.md, and Boost writes its guidelines there only when that file already exists. Without a CLAUDE.md at the application's root, Boost writes them to AGENTS.md (ClaudeCode::guidelinesPath() in laravel/boost), which Claude Code does not read, and no Boost guideline reaches a Claude Code session. Nothing reports this. To fix it while keeping one copy of the guidelines in AGENTS.md, which other agents also read:

  1. In a terminal at the application's root, publish Boost's config: php artisan vendor:publish --tag=boost-config.
  2. In config/boost.php, add:

PHP

'agents' => [
    'claude_code' => [
        'guidelines_path' => 'AGENTS.md',
    ],
],

Without this key, Boost writes a second copy of the guidelines into CLAUDE.md once that file exists, and Claude Code loads both.

  1. At the application's root, create CLAUDE.md containing the line @AGENTS.md. Claude Code follows that line as an import and loads AGENTS.md.
  2. In the same terminal, run php artisan config:clear, then php artisan boost:update. CLAUDE.md is left as written, and the guidelines are written to AGENTS.md.
  3. Start a new Claude Code session. A running session does not reload CLAUDE.md.

The skill files are resources/boost/skills/filament-cms-development/SKILL.md and resources/boost/skills/filament-cms-content/SKILL.md inside the package. Laravel Boost discovers that directory in any installed package.

Installing the skills with Laravel Boost

One-time setup:

  1. In a terminal at the application's root, run php artisan boost:update.
  2. In the same terminal, at the question "New packages with guidelines/skills discovered! Which would you like to add?", select mcg/filament-cms.

    Both skills are written to each configured agent's skills directory, for example .claude/skills/filament-cms-development/SKILL.md for Claude Code, and mcg/filament-cms is added to the packages array in boost.json.

The question appears only when boost:update runs in an interactive terminal. When it runs as a Composer script after composer update, it does not ask. To add the package without the question, add "mcg/filament-cms" to the packages array in boost.json, then run php artisan boost:update.

After the one-time setup, every run of boost:update copies the skills again, so a package update that changes them reaches the application.

The package does not require laravel/boost. Boost is a development dependency of the application, and the skill files are plain Markdown that nothing reads without it.

Installing the skills without Boost

For Claude Code:

  1. In a terminal at the application's root, create the directory .claude/skills if it does not exist.
  2. In the same terminal, copy both skill directories into it:

Shell

cp -r vendor/mcg/filament-cms/resources/boost/skills/filament-cms-development .claude/skills/
cp -r vendor/mcg/filament-cms/resources/boost/skills/filament-cms-content .claude/skills/
  1. After every package update, repeat step 2. A copied skill does not change when the package does.

What the server exposes

The server exposes 49 tools, 5 resources and 2 prompts, registered in src/Mcp/CmsMcpServer.php. Every tool calls the same domain services that the Filament panel uses, so an operation behaves the same way whether it is performed in the panel or over MCP.

Discovery tools

Tool What it does
cmslistcontenttypes Lists the content types this site has, what each supports, and each type's attributes: the field names, Filament components and required flags that cmscreatecontent accepts under attributes
cmslistblocks Lists the available blocks, grouped by category, filtered by content type
cmsdescribeblock Returns one block's exact field contract
cmslistlayouts Lists the layouts a page can use, and the regions each one has

The two listing tools also report where each block or layout is defined. That location determines whether editing a file changes what renders:

  • cms_list_blocks reports source, which is package for a block the CMS ships and host for a block this application defines. It also reports view_overridden, which is true when a published copy of the block's Blade view exists under resources/views/vendor/cms/blocks; that copy is the view that renders.
  • cms_list_layouts reports published, which is true when the host application has its own copy of the layout under resources/views/vendor/cms/layouts.

A package block with view_overridden: false renders from the vendor directory, where an edit is overwritten by the next composer update.

Reading tools

Tool What it does
cmslistcontent Lists pages with path and status; filters by type, status, parent and search text
cmsgetcontent Returns one page in full, including its blocks
cmslistversions Lists a page's version history
cmsdiffversions Reports what changed between two versions
cmspreviewcontent Returns a signed URL for an unpublished draft

Writing tools

Tool What it does
cmscreatecontent Creates a page as a draft
cmsupdatecontent Saves a new draft version
cmstranslatecontent Creates a page's translation into another language, with its translated title, slug, blocks and SEO, as a draft
cmsappendblock / cmsreplaceblock / cmsremoveblock / cmsmoveblock Edit one block without resending the rest of the block list
cmspublishcontent / cmsunpublishcontent Make a draft live, or take a page off the site
cmsrestoreversion Copies an old version forward as a new draft
cmsdeletecontent / cmsrestorecontent Soft-delete a page, and undo the soft delete. cmsrestorecontent recomputes the page's path from its current parent, and refuses without changing anything when another page uses that path. Permanent deletion is not available over MCP; it is in the admin panel only

Media tools

Tool What it does
cmslistmedia Lists library files with their media ids and conversion URLs
cmsuploadmedia Adds a file, either from an absolute server path (honored only for a trusted local stdio session) or from base64-encoded contents
cmsupdatemedia Renames a file, or changes its alt text
cmsdeletemedia Removes a file; refuses, naming them, when a live page or a row in one of the application's own tables references it, unless force: true is passed. The refusal names at most 25 application rows per column and appends "and N more" for the rest. A forced delete returns brokenpages, apprecords (at most 25 rows per column, each with field, key and label) and apprecordstotal (the number of referencing application rows in all). A row's label carries the record title only when the token's user may view the managing Filament resource (canViewAny()); otherwise it reads, for example, Product #12. With the stdio transport, which has no authenticated user, every row reads {model label} #{key}

Form tools

The form tools build and edit the forms listed under Forms in the admin panel: a form's settings, its steps, its fields and each field's visibility rules. The server registers them only while cms.forms.enabled is true. With forms disabled, the server exposes 37 tools instead of 49. The three draft tools are also absent while cms.forms.drafts.enabled is false.

Tool Kind What it does
cmslistforms Read Lists forms, ordered by name, with each form's id, name, slug, isactive, updatedat and counts of steps, fields and submissions. Accepts search (a substring of the name or slug), active (true or false), limit (default 25, maximum 200) and offset. The response also carries total, the number of matching forms before limit and offset apply
cmsdescribeformfields Read Returns the field type catalog. For each of the 20 field types it gives the settings the type accepts (each with its JSON type, allowed values, length limit and default), the kind of default value it takes, what its Minimum and Maximum limit (limitkind), what an answer is stored as, and the visibility rule operators available when another field's rule refers to a field of that type. It also returns the field name pattern, the 64-character name limit, the reserved names, the full operator list and the allowlist of extra validation rules
cmsgetform Read Returns one form, addressed by id or slug, in the definition shape that cmscreateform and cmsupdateform accept, plus id, revision, submissions (a count), updatedat, previewurl, webhookconfigured, withheld and warnings
cmscreateform Write Creates a form from a whole definition: settings, steps and fields in one call. The form starts inactive
cmsupdateform Write Changes form-level settings, and optionally replaces the complete list of steps and fields
cmsaddformfield Write Inserts one field into a step at a position
cmsupdateformfield Write Changes one field's properties, its type, or its name
cmsremoveformfield Write Removes one field
cmsmoveformfield Write Moves one field to another position or another step
cmscreateformdraft Write Creates a pre-filled draft of a form and returns its private link. Takes formid, page (the path or content id of a published page holding the form), label (a note naming the recipient), answers (field name to value) and optional expiresindays (default 30). Refuses an unknown field, an opt-in, file, content or hidden value field, and a value that does not suit its field type, each at its path, such as answers.email
cmslistformdrafts Read Lists a form's drafts with id, label, page, status (open, submitted, revoked or expired), expiresat and lastopenedat. Takes formid and optional status. Returns no answers, because once the recipient opens the link a draft holds answers the recipient typed
cmsrevokeformdraft Write Stops an open draft's link. Takes the draft id

cms_get_form, cms_update_form and the four field tools take form, which is the form's id or its slug. cms_update_form_field, cms_remove_form_field and cms_move_form_field take field, which is the field's current name. cms_add_form_field takes field as the new field's definition, in the shape of one entry of a step's fields list. cms_create_form takes a whole definition and no form, and cms_list_forms and cms_describe_form_fields take no form reference.

Every write tool returns the same payload as cms_get_form, read after the write, so the response shows the stored state, the new revision and any warnings. Besides the warnings described below, warnings reports a form that requires Turnstile while CMS_TURNSTILE_SITE_KEY and CMS_TURNSTILE_SECRET_KEY are not set, because such a form does not render on the site.

Activation and preview

A form has no drafts and no version history. A write to a form changes the form that visitors see on their next request. Pages that carry a Form block are never page-cached (see "Pages with a form are not cached" in Forms), so no cache purge is needed after a form write.

A form created with cms_create_form starts with is_active set to false unless the call sets it. The panel's Active toggle defaults to on; the MCP default is inactive so that a person reviews the form through preview_url before a Form block can show it to visitors. A Form block showing an inactive form renders nothing. The signed preview_url in every response renders the form whether or not it is active, through the same cms.preview.form route that the panel's Preview action uses, and it expires 30 minutes after the response. While a form is inactive, every response carries the warning "This form is inactive, so a Form block showing it renders nothing. Activate it with cms_update_form (is_active: true) when the user approves."

The order of work the server's instructions give an agent is:

  1. Call cms_describe_form_fields before writing a field type for the first time.
  2. Call cms_create_form. The form starts inactive.
  3. Give the user preview_url, and make corrections with the four field tools.
  4. Call cms_update_form with is_active: true when the user approves the form.
  5. Place the form on a page with a Form block whose form_id is the form's id, and publish that page with cms_publish_content.

The form definition shape

cms_get_form returns a form in the same shape that the write tools accept, so an agent can read a form, change the array, and send it back:

JSON

{
  "name": "Event registration",
  "slug": "event-registration",
  "description": "<p>Register for the October open house.</p>",
  "is_active": false,
  "submit_label": "Register",
  "next_label": null,
  "back_label": null,
  "show_progress": true,
  "review_step_enabled": false,
  "success_message": "Thanks. We will email a confirmation.",
  "redirect_url": null,
  "auto_reply": {"enabled": true, "subject": "You are registered", "format": "text",
                 "body": "Hello {{ first_name }}, ..."},
  "spam": {"honeypot": true, "turnstile": false},
  "retention_days": 365,
  "custom_css": null,
  "steps": [
    {
      "id": 12,
      "title": "About you",
      "description": null,
      "fields": [
        {"name": "first_name", "label": "First name", "type": "text", "required": true,
         "placeholder": null, "help_text": null, "custom_classes": null, "options": null,
         "validation": [], "settings": {"width": "half", "max": "80"}, "conditions": null,
         "is_hidden": false},
        {"name": "attending", "label": "Will you attend?", "type": "yes_no", "required": true,
         "settings": {"default": true}},
        {"name": "guests", "label": "Number of guests", "type": "number", "required": false,
         "settings": {"min": 0, "max": 4},
         "conditions": {"match": "all",
                        "rules": [{"field": "attending", "operator": "checked"}]}}
      ]
    }
  ]
}

The first field shows every key the reader returns for a field. The other two fields show only the keys this example sets. is_hidden is returned and accepted in a definition but ignored on write (see the table below).

The cms_get_form response also carries keys that describe the stored form and are not part of the definition: id, revision, submissions, updated_at, preview_url, webhook_configured, withheld and warnings. cms_create_form and cms_update_form drop id, submissions, updated_at, preview_url, webhook_configured, withheld and warnings before validation (FormTool::READ_ONLY_KEYS). cms_update_form reads revision as the revision to check, and cms_create_form drops it. A whole cms_get_form response, with form added, is therefore accepted by cms_update_form, except for a form whose stored auto-reply format is html: the response carries auto_reply.format and auto_reply.body, so the call also needs confirm_raw_html: true (see "HTML auto-replies").

notification_recipients is part of the shape, but it is withheld from reads and refused in writes while cms.mcp.forms.denied lists it, which it does by default (see Form settings the server does not read or write).

Steps carry their id, which cms_update_form uses to match steps. Fields carry no id. A field is addressed by its name, which is the key its answers are stored under in cms_form_submissions.data and the name that visibility rules refer to.

The writer translates the definition into the columns the panel writes, and the reader translates them back. tests/Feature/Admin/FormPanelParityTest.php saves the same definition, covering all 20 field types, through the panel's EditForm page and through the writer, and asserts that the stored rows are identical.

Definition Stored Notes
required isrequired
autoreply.enabled, .subject, .format, .body autoreplyenabled, autoreplysubject, autoreplyformat, autoreplybody
spam.honeypot, spam.turnstile honeypotenabled, requiresturnstile honeypot defaults to true
settings.default on a yesno field: true or false The string '1' or '0' The panel stores the same strings. Forms saved by the panel before this release can hold the integer 1 or 0; the reader accepts both
settings.min on a field whose limitkind is length or count, and settings.max on a field whose limitkind is length, count or size A string, such as "80" The panel stores the typed text for these limits. A number field's limits are stored as numbers. FormFieldSettings::for() offers settings.min for the limit kinds length, value, date and count, and settings.max for every limit kind, so a file field (limit kind size) has only settings.max
conditions with rules {"enabled": true, "match": ..., "rules": [...]} A rule whose operator takes no value is stored without value
conditions absent or null {"enabled": false} The value the panel's Show only when toggle stores when it is off
The position of a step in steps, and of a field in its step's fields sortorder, starting at 1 The panel's repeaters number from 1 as well
ishidden ishidden, unchanged Read-only over MCP. The reader returns it and the validator accepts it, so a definition read and sent back validates, but the writer ignores it: a matched field, including a renamed one, keeps its stored value, and a new field stores false. It is changed only with the panel's Hidden (legacy) checkbox. Use the hidden field type for a new hidden value

A Content (text only) field needs no name; when the field is saved without one, FormField generates content_ followed by eight random lowercase letters and digits. cms_get_form returns that generated name, so a definition read and sent back carries it. A Content field's name, when one is sent, passes the same checks as any other field name: the pattern, the reserved names, the ban on __, and the duplicate check, whose error names the path of the earlier field. Content fields and answer fields share the cms_form_fields.name column, and the writer matches stored rows by name, so a Content field named like an answer field would otherwise convert that answer field on update. Every other field without a name gets one derived from its label with str()->slug('_'), so "First name" becomes first_name.

Validation of a form definition

Mcg\FilamentCms\Support\Forms\FormDefinitionValidator validates the whole definition that a write would produce and collects every error before answering. The response is one error. Its first line is "The form definition is invalid.", and each following line is one problem prefixed by its path, for example - steps.0.fields.2.name: The field name 'Email' must start with a lowercase letter and contain only lowercase letters, numbers and underscores. Nothing is written when any error is reported.

The validator enforces every rule the panel's form builder enforces in FormResource::form(): lengths, the field name pattern ^[a-z][a-z0-9_]*$, the six reserved names, the ban on __ in a name, options required on option types, each type's settings, the default value input, the Minimum and Maximum formats, and the rule that a visibility rule may refer only to an earlier field that collects an answer and is not a file field. It adds these checks, which the panel does not need because its inputs make the mistakes impossible:

  1. Unknown keys are refused at every level. A key that is not part of a form, a step, a field, a field's conditions, a visibility rule or an option is an error. A settings key that the field's type does not have is an error that lists the keys the type accepts.
  2. Inputs the panel hides are refused. A Content field cannot carry required, placeholder, help_text or validation. A hidden field whose settings.source is page_url cannot carry settings.value.
  3. Wrong JSON types are reported. A field name sent as an object, a label sent as a number, or a visibility rule sent as a string is reported at its path, in the same list as every other error.
  4. Option values are unique within a field. FormField::optionMap() keys choices by value, so a second option with the same value would replace the first.
  5. Defaults must be possible. A choice default must be one of the field's option values, and a multi-value default a list of them. A default on a number or rating field must be a number within the field's Minimum and Maximum or rating range. A default on a date, time or datetime field must use the format the field submits (YYYY-MM-DD, HH:MM or YYYY-MM-DDTHH:MM) and lie within its limits.
  6. Rule values must be possible. When a rule's source field has options, the rule's value must be one of them. A greater_than or less_than rule on a number or rating source needs a numeric value.
  7. Extra validation rules must be on the allowlist (see Extra validation rules).
  8. Slugs and derived names are not suffixed. A slug already used by another form is refused. slug defaults to the name in slug form, the same derivation the panel uses. A field name derived from a label that collides with an earlier field's name is refused, and the error names the path of the earlier field.
  9. A form has at least one step, and every field belongs to a step.
  10. A step id appears once. A step id that appears more than once in steps is refused at the later step's path, for example steps.1.id, with "Step {id} appears more than once in steps."

A rating range whose from is greater than its to is accepted with a warning, because FormField::ratingRange() swaps the two when the form renders.

Field-level writes

Each field tool reads the stored definition, applies its one change, and validates the whole result. A change that breaks a different field is refused for that reason: for example, moving a field below a field whose visibility rule refers to it produces the error "The visibility rule refers to '{name}', which is not an earlier field in this form." at the dependent field's rule.

step, on cms_add_form_field and cms_move_form_field, accepts a step id or a zero-based step index. Step ids are matched first. A value of 3 names the step whose id is 3 when this form has one, and the fourth step otherwise; send a step id from cms_get_form when the two could be confused. The default is the last step for cms_add_form_field and the field's current step for cms_move_form_field.

position is a zero-based index within the step and must be 0 or more. 0 places the field first. A position equal to or greater than the number of fields in the step places the field last, which is also the default.

cms_update_form_field takes the field's current name as field, the properties to change as changes, and an optional new_name:

  • changes.settings is merged into the stored settings key by key. A key set to null is removed.
  • changes.options, changes.validation and changes.conditions replace the stored value whole.
  • changes.name is ignored. A rename is made with new_name.
  • A change of changes.type validates the field against the new type, drops the stored settings the new type does not have, and clears the options when the new type has none. The response lists the dropped settings in a warning: "Changing '{name}' to {type} removed the settings it does not have: ..."

cms_update_form with steps replaces the complete list of steps and fields:

  1. Steps are matched by id. A step without an id is created. An id that belongs to a different form is refused with "Step {id} does not belong to this form."
  2. An existing step left out of the list is deleted, together with its fields.
  3. Fields are matched by name across the whole form, so a field moved to a different step keeps its row.
  4. A field without a match is created. An existing field left out is removed, under the same guard as cms_remove_form_field.
  5. Settings outside steps that the call does not send are unchanged. auto_reply and spam merge key by key.

A rename cannot be expressed through cms_update_form, because name is the key that matches fields. A field sent with a new name is treated as a new field, and the field with the old name as removed. Renames go through cms_update_form_field with new_name.

Field guards

The guards apply to the field tools and to cms_update_form with steps. No guard has a confirmation flag; a guard either refuses the write or completes it and returns a warning.

Operation Result
Rename (newname) Applied. Every visibility rule that names the old name is rewritten to the new name in the same transaction, and the response carries "Updated the visibility rules of '{field}' to refer to '{new}'." for each field whose rules changed. When submissions hold an answer under the old name, the response also carries "{n} submissions store this answer under '{old}'. They keep showing it under its original label; the CSV export lists '{old}' and '{new}' as separate columns."
Remove, while another field's visibility rule names the field Refused: "Field '{name}' cannot be removed: the visibility rules of '{fields}' refer to it. Change or remove those rules first."
Remove, when submissions hold answers for the field Applied, with the warning "{n} submissions hold an answer for '{name}'. They keep showing it; new submissions will not ask for it."
Move that puts a rule's source field after the field that uses it Refused with the visibility rule error at the dependent field's rule
Delete a step through cmsupdateform Refused when any field on the step would be refused by the Remove row above

Submissions stay readable after a rename or a removal because each submission stores a snapshot of its fields' labels and choices. See "Field snapshots" in Forms.

Revisions and concurrent edits

cms_forms.revision is a counter that every save increments, whether the save comes from the panel's EditForm page or from an MCP tool. Mcg\FilamentCms\Support\Forms\FormRevision::claim() checks and increments it in one statement inside the save's transaction:

SQL

UPDATE cms_forms SET revision = revision + 1, updated_at = ? WHERE id = ? AND revision = ?

When the statement matches no row, another save happened first, and the transaction is rolled back with nothing written.

cms_get_form and every write response carry revision. The write tools use it as follows:

  1. cms_update_form with steps requires revision. That call rewrites every step and field, including ones it does not mention, so without a revision it would overwrite a concurrent panel save. Without one, it is refused with "Send the revision from cms_get_form when replacing steps; this call rewrites every step and field."
  2. The other writes accept an optional revision. These are cms_add_form_field, cms_update_form_field, cms_remove_form_field, cms_move_form_field, and cms_update_form without steps. When the revision is sent and the form has moved past it, the write is refused with "Form {id} is at revision {current}; this change was based on revision {sent}. Call cms_get_form, reapply the change, and send the new revision."
  3. **When revision is omitted, the writer uses the revision the tool loaded.** At the start of the call, the tool loads the form and its stored definition, applies the change to that definition, and then rewrites every field of the form and deletes the fields missing from it. The writer passes the revision of the form as it was loaded to FormRevision::claim(). A panel save or another MCP call that lands between that load and the write's transaction therefore makes the write fail with the revision error above, instead of being overwritten, and a field added in that window is not deleted. Without revision, the writer cannot detect a save made between the agent's earlier cms_get_form and this call; the change is applied on top of that newer save. With revision, the write fails in both cases.

cms_update_form and the four field tools validate revision with Laravel's integer rule. cms_create_form removes revision from its input and does not check it. A number, or numeric text such as "3", is accepted; any other value is refused with "The revision field must be an integer." and nothing is written.

The check is per form. Two writers changing different fields of one form still conflict, because both saves claim the same counter. The panel side of the same check is described in "Two people editing one form" in Forms.

Form settings the server does not read or write

Mcg\FilamentCms\Mcp\Support\FormSettingsPolicy removes two settings from every form payload and refuses them in every write:

Setting Definition keys refused When it is denied Reason
webhook webhook, webhookurl, webhookapikey Always. It is added to cms.mcp.forms.denied whatever the config holds, so a published config cannot remove it webhookapikey is a credential, and webhookurl decides where every submission's personal data is posted. An agent following injected instructions could send submissions to a third party
notificationrecipients notificationrecipients While cms.mcp.forms.denied lists it, which is the default Each recipient receives every submission by email, so the list is also a destination for personal data

A write that contains a refused key is refused before validation with "These form settings cannot be written over MCP: {keys}. Set them in the panel (Forms β†’ {form name} β†’ Notifications or Advanced)." cms_create_form has no stored form to name, so its refusal reads "Forms β†’ the form β†’ Notifications or Advanced" in place of the form name. A read leaves the keys out and lists the denied settings in withheld. webhook_configured reports true when the form has a webhook URL, without returning the URL or the key. A definition read and sent back does not clear these settings: the writer keeps the stored value of any setting the definition leaves out.

A form created over MCP while notification_recipients is denied sends no notification email until someone adds recipients in the panel, on the form's Notifications tab.

To let agents set notification recipients:

  1. In config/cms.php of the host application (publish it with php artisan vendor:publish --tag=cms-config if it is not there), find mcp.forms.denied.
  2. Remove 'notification_recipients' from the list, leaving 'denied' => [].
  3. In a terminal, run php artisan config:clear when the configuration is cached.

cms_get_form then returns notification_recipients, and withheld lists only webhook.

HTML auto-replies

An auto-reply whose format is HTML is sent with its body unescaped to the address a visitor typed. It is the one form setting that sends unsanitized markup to a member of the public, so writing one needs confirm_raw_html: true, the same flag the html block needs:

  • cms_create_form refuses auto_reply.format: "html" without the flag.
  • cms_update_form refuses a call that sends auto_reply.format or auto_reply.body without the flag when the resulting format is HTML, whether the call sets the format or the stored format already is HTML.

The refusal reads "An HTML auto-reply is sent unescaped to whoever submits the form. Re-send with confirm_raw_html: true if you have read the markup and vouch for it." Both tools validate confirm_raw_html with Laravel's boolean rule, which accepts true, false, 1, 0, "1" and "0". Any other value, including the text "false", is refused as a validation error before the gate runs. The other rich text on a form needs no flag. The form description, each step's description, a Content field's settings.body and an opt-in field's settings.agreement_text pass through sanitizeHtml() when the form renders, and Form::safeCustomCss() removes < and > from custom_css.

Extra validation rules over MCP

A field's validation list holds Laravel rule strings, which FormValidator::custom() appends to the submission rules unchanged. Two failures follow from that. A malformed rule, such as regex:/(/, fails when a visitor submits and shows the visitor a server error. A database rule, such as exists:users,email, tells a visitor whether a value is in another table.

Over MCP, Mcg\FilamentCms\Support\Forms\FormRuleAllowlist checks each string:

  1. The rule name must be one of these 26: min, max, between, size, digits, digits_between, decimal, multiple_of, regex, not_regex, starts_with, ends_with, doesnt_start_with, doesnt_end_with, alpha, alpha_num, alpha_dash, ascii, lowercase, uppercase, lte, gte, mimes, mimetypes, extensions, dimensions.
  2. A regex or not_regex pattern must compile.
  3. The rule must run against the string probe through Validator::make() without throwing an exception. The probe is a non-empty string because a field with a null value skips its other rules, so a null probe would not run the rule being checked.

The panel's Extra validation rules input is not restricted by this list.

Form ids in block writes

cms_describe_block reports "references": "form" on the form_id field of the Form and Subscribe blocks, and its description says that such a field takes a form id from cms_list_forms.

The MCP block write path checks each form_id that the call adds or changes, at any depth inside containers. A Form or Subscribe block is checked when its form_id does not already appear on a Form or Subscribe block in the content's current version; a block already stored on the page is not re-checked:

  1. A form_id that names no form is refused with "Form {id} does not exist. Call cms_list_forms for the ids."
  2. A form_id that names an inactive form is accepted, and the response carries "Form {id} ({name}) is inactive, so this block renders nothing until the form is activated." The write is not refused, so a Form block can be placed and its page drafted before the form is activated; the block renders nothing until then.
  3. A form_id that is not a number or a string, such as an array, is refused with "form_id must be a form id from cms_list_forms."

cms_create_content, cms_update_content, cms_append_block and cms_replace_block run these checks and return the warning. InteractsWithCms::guardBlocks() takes the content's stored block list as $storedBlocks and collects the form_id of every Form and Subscribe block in it before walking the block list being written:

  • cms_append_block, cms_replace_block and cms_update_content pass the blocks of the content's current version.
  • cms_translate_content passes the blocks of the source content's current version.
  • cms_create_content passes no stored blocks, so every form_id it sends is checked.

A page that carries a Form block for a deleted form can therefore still be edited with these tools: the stored block is sent back unchanged with the rest of the block list and is not looked up. Changing that block's form_id to an id not already stored on the page, or adding a new Form block with such an id, runs the checks on the new id. A new Form block that reuses a form_id already stored on the page is not looked up. cms_translate_content runs the existence check and refuses an unknown form, but its response does not carry the inactive-form warning.

cms_remove_block and cms_move_block skip both checks. Removing or moving a block places no new form_id, so a page that already carries a Form block for a deleted form can still be rearranged, and that block can be removed with cms_remove_block. The raw-HTML gate does not apply to these two tools for the same reason (see Block and setting permissions). guardBlocks() takes a $checkForms argument, which RemoveBlockTool and MoveBlockTool pass as false. No check runs while cms.forms.enabled is false.

The blocks' own rules() are unchanged, so a page in the panel whose form was deleted after it was placed still saves.

What the form tools do not do

  1. They do not read submissions. Submissions contain personal data. The tools report counts only.
  2. They do not delete forms. Deleting a form deletes its submissions and their uploaded files. To take a form off every page, set is_active to false with cms_update_form.
  3. They do not write the webhook, and they write notification recipients only when cms.mcp.forms.denied allows it.
  4. They do not check the FORMS_* abilities. Like every other MCP tool, the form tools authorize with canAccessPanel() only (see Authorization).

Site-level tools

Tool What it does
cmslistredirects / cmscreateredirect / cmsupdateredirect / cmsdeleteredirect Manage redirects
cmsgetsitesettings / cmsupdatesitesettings Read and change site-wide settings
cmsclearcache Clears cached pages
cmspruneversions Deletes old version history
cmspublishscheduled Runs the scheduled publish queue immediately

Redirects are also managed in the admin panel, on the Redirects screen. The CMS creates a redirect automatically whenever a page is renamed or moved, so the redirect tools are for redirects added by hand.

Theming tools

The theming tools are read-only and are intended for the developer building the site, not for editors writing content. Together with the cms://tokens resource, they let an agent translate a design file into this CMS's own tokens, skins and blocks instead of writing a second stylesheet beside the theme.

Tool What it does
cmsthemestate Reports which skins are registered, which one is active, and whether each is wired up: stylesheet present, stylesheet imported, Blade partial, fonts
cmsvalidateskin Checks a proposed token map before any file is written: unknown token names, partner tokens left unset, contrast against the 4.5:1 floor in both light and dark modes, and unit mistakes
cmsmatchblock Given a described component, returns the five closest registered blocks with their full schemas and a recommendation of reuse, retoken, overrideview or newblock

Call cms_validate_skin before choosing a palette and again after. It takes the token map as data instead of reading a file, so an early call writes nothing. Its most frequent finding is that a light-only proposal has no dark mode. A skin without dark values renders the light palette on a dark background for every visitor whose operating system requests dark mode, and the developer who built it does not see the defect when working in light mode.

Contrast is measured by computation. The oklch-to-sRGB conversion is tested in tests/Unit/ColorTest.php against the published oklch coordinates of the sRGB primaries, whose computed luminances must equal the WCAG channel coefficients (0.2126 for red).

Two tokens are exempt from the text contrast floor, and their contrast is reported for reference without a pass or fail. --color-cms-line and --color-cms-line-strong are hairline border colors, measuring 1.29:1 and 1.67:1 against the base surface. The first version of cms_validate_skin held them to the 4.5:1 text floor, and it failed every proposal that changed the surface color, including the package's own theme. The exemption list is floorFor() in src/Mcp/Tools/Theme/ValidateSkinTool.php.

Resources and prompts

Name Kind Contents
cms://conventions Resource, Markdown How the CMS models content
cms://blocks Resource, JSON The full field contract for every block
cms://tokens Resource, JSON Every design token the theme declares
cms://spacing Resource, JSON Which rule in cms.css draws each gap, the token that sets it, and the fixed offsets no token controls
cms://theme-wiring Resource, Markdown Where generated files are written and what registers them
cmsauthorpage Prompt Guides a model through drafting a page
cmsdesignfromfigma Prompt Guides a model through turning a design file into a skin, layouts and blocks

cms://conventions and cms://blocks are for authoring content. cms://tokens, cms://spacing and cms://theme-wiring are for building the site's theme; cms://spacing is read after php artisan cms:measure reports a gap that differs from the source. The authoring and theme resources are separate documents so that an agent working on a design can find out what a token such as --color-cms-accent-ink controls without reading about draft versions and media ids.

How an agent is expected to work

The server sends an instructions block to the client when it connects, defined as $instructions in src/Mcp/CmsMcpServer.php. It covers the publishing model, blocks, containers, regions, snippets, the order in which to consider existing blocks before writing a new one, skins built from a design file, translations, forms and identifiers. Three of its rules govern every write to content:

  1. Nothing is live until it is published. Every write appends an immutable draft version. Publishing is a separate call to cms_publish_content. A bad edit can be recovered with cms_list_versions and cms_restore_version.
  2. Block shapes are discovered, not guessed. cms_describe_block returns the block's validation contract, derived from the block class's rules() and defaults(). Block data that does not match is rejected; it is not stored partially. See Block field contracts.
  3. cms_update_content replaces the whole block list. The agent reads the page first, edits the array, and sends the whole array back. The four single-block tools are the alternative; they send less data and cannot delete other blocks by omission.

Regions

Each block carries the region of the layout it belongs to. A block with no region is in main. On a layout with a single region, no further handling is needed.

When a layout divides the page into several regions, every content payload reports them under layout:

JSON

"layout": {
  "component": "cms-layout::docs",
  "label": "Docs",
  "regions": [
    {"name": "page-header", "label": "Page header content", "shared": true,
     "blocks": 1, "shared_area": "/_layout-areas/docs-region-page-header"},
    {"name": "main", "label": "Main content", "shared": false, "blocks": 6}
  ]
}

The block tools handle regions as follows:

  • cms_append_block takes a region, and its position counts within that region, not across the page. The stored block list is flat and interleaves regions, so a block's neighbors in the stored list are not necessarily its neighbors on the page.
  • cms_move_block also takes a region, which moves a block from one region to another.
  • cms_list_layouts reports the same region information for a layout that no page uses yet. An agent needs it before creating a page on that layout.

Writes into a shared region are refused. A shared region's blocks belong to the layout, not the page: the region is filled once and rendered on every page that uses the layout. A block written into a shared region from a page would be stored and never rendered, with no error to show it. The refusal names the region's shared_area path instead. That path is ordinary content of type layout_area, which every content tool can edit.

Blocks inside containers

The section, tabs, columns and accordion blocks hold other blocks. A block list is therefore a tree, and every block in it has a path:

Plain text

2                    the third block on the page
2.tabs.0.blocks.1    the second block in its first tab

cms_describe_block reports children for a container block, naming where its lists are stored: tabs.*.blocks means every tab holds one list. Every cms_get_content response includes block_paths, the tree flattened into one entry per block at any depth, each with the path that addresses it. A container's children are visible in the block JSON without block_paths, but the path to each one is built from the container's own data keys, not from any field on the child block, so block_paths is the reliable source.

Task Call
Put a block in a container cmsappendblock with into: "2.tabs.0.blocks"
Edit a nested block cmsreplaceblock with block: "2.tabs.0.blocks.1", or the block's id
Delete a nested block cmsremoveblock, with the same addressing
Reorder a block within its container cmsmoveblock, with the same addressing
Move a block between containers cmsremoveblock, then cmsappendblock

Address a block by its id where possible. An id works at any depth. A path changes when a block before it is removed, and a nested path also changes when its container moves.

Containers hold ordinary blocks, one level deep. Page-structure blocks (a page heading, breadcrumbs, a table of contents) declare themselves non-nestable, and so does every container block.

cms_update_content and cms_create_content return warnings instead of refusing a block list that contains blocks in regions the current layout does not render. Both tools carry a whole block list, and a list read from cms_get_content can legitimately contain such blocks: the CMS keeps them so that moving the page back to a layout with that region restores them. Refusing would make those pages impossible to save, so the write succeeds and warnings lists the blocks that will not appear.

Designing from a Figma file

This server never connects to Figma. It holds no Figma credential and makes no outbound request. The supported arrangement is one client, usually Claude Code, connected to the Figma MCP server and to this server at the same time, so that the agent reading the design is the agent writing the code.

This server contributes the vocabulary and the procedure:

  1. cms://tokens names all 60 design tokens (the TOKENS constant in src/Support/DesignTokens.php), with what each controls, its default value, and the token it is contrast-checked against.
  2. cms://theme-wiring states where a generated skin, layout or block view must be written, and lists the two registration steps that cms:make-skin does not perform.
  3. cms_design_from_figma is the procedure. It stops at a written plan in docs/cms-design/{skin}.md for a person to approve before anything is generated.

The server writes no file into the host application. The agent writes files with cms:make-skin, cms:make-layout, cms:make-block and its own file tools.

Limitations of the Figma workflow

Each of the following limitations produces a result that looks finished and is wrong.

Figma cannot represent fluid type or oklch colors. A Figma variable holds one number per mode, and all four --text-cms-* tokens (--text-cms-hero, --text-cms-title, --text-cms-section, --text-cms-lead) are clamp() expressions. An agent copying Figma values fixes them at the desktop size, which renders correctly at a 1440px viewport and incorrectly at 700px. Figma also has no oklch: get_variable_defs returns hex values. Converting hex to oklch is mechanical, but the package uses oklch so that a hue shift preserves perceived lightness, and a palette converted from Figma hex values does not carry that property.

Dark mode is usually missing. get_variable_defs returns a flat map of names to values with no mode dimension. Unless the designer built and published a dark collection and the agent fetched it separately, the two dark-mode blocks in the generated skin keep their placeholder blue-gray values.

Fonts for a host skin fall back to the base pair without an error. Theme::SKIN_FAMILIES in src/Support/Theme.php holds font families for the six skins the package ships. For any other skin, Theme::fontFamilies() uses the families declared for that skin in cms.theme.skin_fonts (or passed as the third argument to SkinRegistry::register()), and when none are declared it returns the base pair (Instrument Sans and Newsreader). A host skin that sets --font-cms-display: 'SΓΆhne' without declaring fonts is rendered in Instrument Sans, and nothing logs an error. cms_theme_state reports a fonts_fall_back_silently finding when the active skin comes from the host application and font loading is on. Resolve it in one of three ways:

  1. List the skin's Bunny Fonts families under cms.theme.skin_fonts in config/cms.php.
  2. Set cms.theme.fonts to false and serve the fonts from the application.
  3. Point the font tokens at families the site already serves.

A Figma frame is not a block. A block is a schema with editable fields that is placed many times; a Figma auto-layout frame is one instance with its content fixed. Figma does not mark which text is editable content and which is fixed decoration, which parts repeat, or what the empty state looks like. The common failure is creating one new block per distinctive frame, each a fork of an existing block that nobody maintains. The cms_design_from_figma prompt instructs the agent to prefer changing a token before creating a block, and that instruction is the only control against it.

Units do not correspond. Figma spacing is in px. --cms-measure is 68ch, and the width steps are in rem. A designer's 720px text column has no exact equivalent, and the agent's conversion changes the line length of every page.

Block containment is not caught. .cms-block applies its own max-width and side gutters. A full-bleed band drawn inside a 1440px frame becomes a block that is width-capped and has gutters applied twice. This is the most likely visual defect, and nothing in the workflow detects it.

Nothing verifies that the result matches the design. Comparing the rendered page with the Figma file requires a browser and a person.

Configuration

The mcp group in config/cms.php:

PHP

'mcp' => [
    'enabled' => true,
    'handle' => 'cms',
    'name' => env('CMS_MCP_NAME'),
    'actor' => null,
    'local' => ['trusted' => true],
    'web' => [
        'enabled' => (bool) env('CMS_MCP_WEB_ENABLED', false),
        'path' => '/mcp/cms',
        'auth' => env('CMS_MCP_WEB_AUTH'),
        'middleware' => [],
    ],
    'blocks' => ['denied' => [], 'gated' => ['html']],
    'settings' => ['denied' => ['analytics']],
    'forms' => ['denied' => ['notification_recipients']],
],

actor is the user recorded as the author of everything the agent writes. Over stdio there is no authenticated user, so without actor every version the agent creates has a null created_by. Set it to a user id or email address:

PHP

'actor' => 'automation@example.com',

web.auth decides the guard on the HTTP route:

web.auth Middleware on the route
null (default) web.middleware exactly as written. Enabling the transport with an empty list throws at boot
sanctum auth:sanctum, followed by web.middleware without its auth entries
oauth auth:api (Passport), followed by web.middleware without its auth entries. Also registers laravel/mcp's OAuth discovery routes, a login route at /oauth/sign-in when the application has none, and the package's consent view cms::mcp.authorize
both auth:sanctum,api, which tries Sanctum first, with the same additions as oauth

The route answers every request with 503 and a JSON error_description that names the fix in three cases:

  1. web.auth names a method whose package is not installed. The description ends with the command to run, for example Run: composer require laravel/sanctum.
  2. web.auth is oauth or both, and config/auth.php has no api guard or has one whose driver is not passport, such as the token driver from older Laravel application skeletons. The description names the driver it found and the guard entry to set: 'api' => ['driver' => 'passport', 'provider' => 'users'].
  3. web.auth is a value not in this table.

The rest of the site keeps working.

Every key is listed with its default in Reference.

Security

The server writes content to the site. Read this section in full before enabling the HTTP transport or changing the authorization settings.

The stdio transport

php artisan mcp:start cms is an Artisan command running inside the application with full database access. There is no authentication boundary to add to it: anyone who can run it can already run php artisan tinker. For this reason cms.mcp.local.trusted defaults to true. The setting records that the person running the command already has full access; it grants nothing beyond that.

Set cms.mcp.local.trusted to false if mcp:start runs somewhere less trusted. Every tool is then hidden from an unauthenticated session.

The HTTP transport

The HTTP endpoint accepts writes from anyone who can reach the site, so every request must be authenticated. cms.mcp.web.enabled (CMS_MCP_WEB_ENABLED) is false by default. The steps to enable it are in Enabling the HTTP endpoint.

The package refuses three unsafe states:

  1. With cms.mcp.web.auth set, the package adds the guard itself. When the guard's package is missing, or oauth or both has no api guard with the passport driver in config/auth.php, the endpoint answers 503 instead of serving requests unauthenticated. A guard with another driver is refused at this point rather than after sign-in and consent, where Passport's token would be refused with 401.
  2. With cms.mcp.web.auth null and cms.mcp.web.middleware empty, the package throws an exception at boot instead of starting:

Plain text

InsecureMcpRouteException: The CMS MCP web transport is enabled at [/mcp/cms]
with no middleware. Set cms.mcp.web.middleware to the guards that should
protect it (for example ["auth:sanctum"]), or set cms.mcp.web.enabled to
false.
  1. With cms.mcp.web.auth set, an auth entry in cms.mcp.web.middleware is dropped, so a hand-written guard cannot replace the one the method needs.

One mistake is not refused: with cms.mcp.web.auth null, attaching ['web'] as the middleware. The web middleware group starts a session but does not require authentication. Set cms.mcp.web.auth, or attach a guard that authenticates the request.

Restricting OAuth client registration

With cms.mcp.web.auth set to oauth or both, the package calls laravel/mcp's Mcp::oauthRoutes(), which registers POST /oauth/register. Any visitor can call it, without signing in, and register a public client with any client_name, including "Claude". laravel/mcp's mcp.redirect_domains setting decides which redirect URIs a registration may use, and laravel/mcp's own default is ['*'] (vendor/laravel/mcp/config/mcp.php), which accepts every http and https URI. With that default, a person who registers a client named "Claude" with a redirect URI on their own domain, and persuades a signed-in user to open its /oauth/authorize link and click Authorize, receives the authorization code and can exchange it for an access token with that user's access.

The package therefore sets mcp.redirect_domains itself whenever OAuth is active (McpHttpAuth::oauthActive() returns true). The value comes from cms.mcp.web.oauth_redirect_domains, which reads the CMS_MCP_OAUTH_REDIRECT_DOMAINS environment variable and defaults to ['https://claude.ai']. With no configuration, a site that uses OAuth accepts registrations only for redirect URIs under https://claude.ai/, which includes the redirect URI Claude Desktop registers, https://claude.ai/api/mcp/auth_callback. A registration with any other redirect URI is refused with 400, the error invalid_redirect_uri and the description "redirect_uris.0 is not a permitted redirect domain."

The package decides between its list and laravel/mcp's list as follows:

  1. When cms.mcp.web.oauth_redirect_domains differs from the default ['https://claude.ai'], the package's list replaces mcp.redirect_domains in every case. Setting CMS_MCP_OAUTH_REDIRECT_DOMAINS, or editing the value in a published config/cms.php, produces such a list.
  2. When cms.mcp.web.oauth_redirect_domains is the default, the package replaces mcp.redirect_domains only while it is still laravel/mcp's own default, ['*']. A site that published config/mcp.php with php artisan vendor:publish --tag=mcp-config and set a different redirect_domains list keeps that list.

The package compares the configured list with the default instead of reading the environment variable, because env() returns null outside the configuration files once php artisan config:cache has run. As a result, CMS_MCP_OAUTH_REDIRECT_DOMAINS=https://claude.ai, the default written out, is treated the same as an unset variable, and a different list published in config/mcp.php stays in effect.

To change the accepted domains:

  1. In the site's .env file, or the live site's environment settings, set CMS_MCP_OAUTH_REDIRECT_DOMAINS to a comma-separated list of domains, each written as a scheme and a host:

INI

CMS_MCP_OAUTH_REDIRECT_DOMAINS=https://claude.ai,https://other-client.example.com

Spaces around each entry are removed and empty entries are dropped. A variable that is empty, or contains only commas and spaces, gives the default ['https://claude.ai']. Keep https://claude.ai in the list while Claude Desktop connects to the site.

  1. In a terminal at the application's root, run php artisan config:clear, or php artisan config:cache on a site that caches its configuration.

Two values have a special meaning in laravel/mcp's OAuthRegisterController:

  1. http://localhost, written with no port and no path, accepts a redirect URI with the http scheme on the host localhost, 127.0.0.1 or [::1], on any port. A desktop or command-line client that receives its code on a local port needs this entry. http://127.0.0.1 and http://[::1] have the same effect. An entry with a port or a path, such as http://localhost:8080 or http://localhost/callback, does not enable this rule; it is matched as an ordinary prefix in step 4 below.
  2. * accepts every http and https redirect URI, which reopens registration to every domain. Every account that can open the CMS panel can write through the MCP server, so do not use * on a site that has OAuth enabled.

OAuthRegisterController applies mcp.redirect_domains to each entry of redirect_uris in these steps. The package requires laravel/mcp 1.0.2 or later, and the steps are the same in 1.0.2 and 1.1.0:

  1. A URI whose scheme is not http or https is not checked against mcp.redirect_domains. It is accepted only when its scheme is listed in mcp.custom_schemes, which is empty by default. An http or https URI that carries a user name or password (https://user@host/) is refused as not a valid URL.
  2. When the list contains '*', every http and https URI is accepted.
  3. When the list contains an entry whose text after ://, with a trailing / removed, is exactly localhost, 127.0.0.1 or [::1], every http URI whose host is one of those three is accepted, on any port.
  4. Otherwise, a / is appended to each entry that does not end with one, and the URI must begin with one of the resulting strings. https://claude.ai becomes https://claude.ai/, so https://claude.ai/api/mcp/auth_callback is accepted and https://claude.ai.example.com/callback is not.
  5. A refused URI ends the registration with 400 and the error invalid_redirect_uri.

The package's composer.json requires laravel/mcp ^1.0.2 because 1.0.0 differs in step 1 and step 3: it does not refuse a URI that carries a user name or password, and its localhost rule accepts any URI that begins with http://localhost:, http://localhost/, http://127.0.0.1:, http://127.0.0.1/, http://[::1]: or http://[::1]/. Under 1.0.0, a URI such as http://localhost:80@attacker.example/ begins with http://localhost: and is accepted while its host is attacker.example.

The setting applies only to new registrations. Clients registered before the change keep their redirect URIs; list them with DB::table('oauth_clients')->get(['id', 'name', 'redirect_uris', 'created_at']) in php artisan tinker.

A token, or an OAuth connection, gives its holder the access of the user it was issued for, through every tool the server exposes (see Authorization). Because canAccessPanel() is the only authorization check, a token issued for any user who can open the panel can call every write tool, so store and rotate it as you would that account's password. Revoke a token by issuing a new one with php artisan cms:mcp-token --revoke under the same name, or delete it on the panel's API Tokens screen. OAuth connections are revoked in oauth_access_tokens and oauth_refresh_tokens; see A live site from Claude Desktop as a custom connector.

The package depends on neither Sanctum nor Passport.

Authorization

Every tool calls $user->canAccessPanel(), the same check the Filament panel performs. A user who cannot open the panel at /cms does not see the tools: they are absent from tools/list, and calling one returns a not-found error. If the panel cannot be resolved, or the user model has no canAccessPanel() method, access is denied.

That check is the entire authorization boundary. The server authorizes once, at connection, and then acts with full authority. It does not consult the per-ability permissions described in Permissions, so an agent connected as a user who may write but not publish can still publish. Treat an MCP credential as equivalent to an administrator's. There are two exceptions: a block's own authorize() method, and the block and setting lists described in the next section.

Block and setting permissions

Blocks are governed by two lists, because refusing a block outright and requiring an explicit confirmation are different controls:

Setting Default Effect
cms.mcp.blocks.denied [] A block key in this list cannot be written over MCP. It is editable only in the admin panel, by a person who can see the content being entered.
cms.mcp.blocks.gated ['html'] A block key in this list is writable only when the call passes confirmrawhtml: true.
cms.mcp.settings.denied ['analytics'] Site setting groups the server refuses to read or write. The analytics group holds script tags injected into every page. webhooks and notifications are always denied in addition, because both hold credentials.
cms.mcp.forms.denied ['notificationrecipients'] Form settings the server refuses to read or write. notificationrecipients is listed because each recipient receives every submission by email. webhook (webhookurl and webhookapikey) is always denied in addition, because the key is a credential and the URL decides where submissions are posted. See Form settings the server does not read or write.

The html block is gated by default because it renders unescaped output, so a mistake in it is a security problem, not a layout problem. The confirm_raw_html flag is not a way to get past an error. The agent sets it after reading the markup, and the person approving the tool call sees it in the call, so the flag is the record of that decision. Reordering and deleting blocks never require the flag.

cms_list_blocks reports each block's tier as writable: true (writable), "confirm" (gated) or false (denied). cms_get_site_settings names the setting groups it withheld. Because both are reported, an agent can tell the user why it cannot perform an operation. Denied blocks and setting groups remain editable in the admin panel.

If a block's own authorize() method denies the current user, the MCP write path refuses the block as well, whether or not the block is in either list.

Disabling the server

To disable the server, set enabled to false in config/cms.php:

PHP

'mcp' => ['enabled' => false],

No server is registered, and php artisan mcp:start cms reports an unknown handle.