The MCP server
This page explains the Model Context Protocol (MCP) server that the package ships: how to connect an AI client to it over stdio or HTTP, the block write tiers, the security model, the agent skills, 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, and its configuration. 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. Every tool calls the same domain services that the Filament panel uses.
The server is enabled by default over the stdio transport and disabled by default over the HTTP transport. Read the Security section below before changing either default.
Connecting over stdio
Claude Code, locally
- In a terminal at the application's root (the directory that contains
artisan), register the server with Claude Code:
claude mcp add my-site -- php artisan mcp:start cms
To register the server from any other directory, give the absolute path to artisan instead. mcp:start boots the Laravel application, which requires the directory that holds artisan:
claude mcp add my-site -- php /full/path/to/your-app/artisan mcp:start cms
- In Claude Code, type
/mcpto confirm that the server connected.
The two names in the command have different roles:
my-siteis the name Claude Code shows for this server. It prefixes every tool name, for examplemcp__my-site__cms_get_content. Replace it with the site's own name. See Naming each installation below.cmsis the handle this application registered for the server, set bycms.mcp.handle. It is the same on every installation that keeps the default.
Claude Desktop
- In Claude Desktop, open Settings β Developer β Edit Config. This opens
claude_desktop_config.json. - In
claude_desktop_config.json, add the server undermcpServers:
{
"mcpServers": {
"my-site": {
"command": "php",
"args": ["/full/path/to/your-app/artisan", "mcp:start", "cms"]
}
}
}
- Restart Claude Desktop.
The path to artisan must be absolute. Claude Desktop does not inherit the shell's working directory or PATH.
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:
- The client's name for the server. This is the name given to
claude mcp add, or the key undermcpServersin a JSON config. It prefixes every tool the agent calls, so two servers both registered ascmscannot be told apart by the person or the agent using them. Give each site its own name.cms:installprints theclaude mcp addcommand with a name built from the project directory's name (Str::slug()of the base path's last segment). - The name the server reports. The server sends
cms.mcp.namein the MCP handshake, read from theCMS_MCP_NAMEenvironment variable. When it is not set, the server reportsFilament CMS:followed byapp.name. The server's instructions also open with the site'sapp.nameandapp.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:
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.
Connecting over HTTP
The HTTP transport serves an agent that does not run on the same machine as the site, such as a client connected to a deployed installation. It is disabled unless the environment enables it, because the endpoint accepts writes and whether to expose it is decided per environment. To enable it with Sanctum, set the web keys in config/cms.php:
'web' => [
'enabled' => true,
'path' => '/mcp/cms',
'middleware' => ['auth:sanctum'],
],
The middleware list is required. With an empty list, the application throws InsecureMcpRouteException at boot, because an unauthenticated endpoint that accepts writes would let anyone edit content. With auth:sanctum, a caller presents a personal access token as a bearer token (Authorization: Bearer β¦), and every write is attributed to the user the token belongs to.
Issue a token in the admin panel under Content Manager β API Tokens. On a production server's console, use the cms:mcp-token command instead of tinker, in a terminal at the application's root:
php artisan cms:mcp-token you@example.com --name=laptop
Passport and OAuth
Claude Desktop's remote-server connection expects OAuth, including dynamic client registration. With Laravel Passport, register the MCP OAuth routes in a service provider's boot() method:
// A service provider's boot():
Laravel\Mcp\Facades\Mcp::oauthRoutes();
Then set the transport's middleware in config/cms.php to Passport's api guard and the mcp:use scope:
'web' => [
'enabled' => true,
'path' => '/mcp/cms',
'middleware' => ['auth:api', 'scopes:mcp:use'],
],
The package depends on neither Sanctum nor Passport. Use the one the application already uses.
cms.mcp.actor is read only by the stdio transport, which has no authenticated user to record as the author of a write. Over HTTP, the token identifies the user, and actor is not used. Without actor, every version a stdio agent creates has a null created_by.
Block write tiers
cms.mcp.blocks assigns blocks to tiers, so that a block written by an agent can be made harder to place over MCP than through the panel without removing it from either:
'mcp' => [
'blocks' => [
'denied' => [], // refused outright
'gated' => ['html'], // requires explicit confirmation
], // everything else: writable
],
denied: the server never writes a block whose key is in this list. The admin panel is the only way to add it, by a person who can see the content being entered. The list is empty by default.
gated: the server refuses a block whose key is in this list until the call is sent again with confirm_raw_html: true. The default is ['html']. In a client that asks before running a tool, the person approving the call sees the flag.
Every other block is writable, subject to the block's own authorize() method. The MCP write path applies the same check that BlockRegistry::for() applies to the panel's block picker, so a block a user cannot place in the panel cannot be placed over MCP either, whether or not it is in a list.
The html block is gated, not denied, because it renders unescaped output, so a mistake in it is a security problem, not a layout problem. Denying it outright made the block unreachable over MCP without telling the agent why. The confirm_raw_html flag is not a way to get past an error: the agent sets it after reading the markup, and the flag in the approved call is the record of that decision. Move html to denied to refuse it entirely.
Reordering and deleting blocks are never gated. Those tools cannot introduce markup, and gating them would make a page that contains an html block impossible to rearrange.
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.
cms.mcp.settings.denied defaults to ['analytics']; the analytics group holds script tags injected into every page. The webhooks and notifications groups are always denied in addition, because both hold credentials. Denied blocks and setting groups remain editable in the admin panel.
Security
- The stdio transport has the privileges of a shell.
php artisan mcp:start cmsis an Artisan command running inside the application with full database access, and anyone who can run it can already runphp artisan tinker. For this reasoncms.mcp.local.trusteddefaults totrue; the setting records that the person running the command already has full access and grants nothing beyond that. Set it tofalseifmcp:startruns somewhere less trusted. Every tool is then hidden from an unauthenticated session. - The HTTP transport has no authentication of its own. Enabling
webwith an empty middleware list throwsInsecureMcpRouteExceptionat boot instead of publishing an unauthenticated content-editing endpoint. The exception does not catch a less obvious mistake: attaching['web']as the middleware. Thewebmiddleware group starts a session but does not require authentication. Attach a guard that authenticates the request. - Authorization uses the panel's own check,
$user->canAccessPanel(), against the panel the CMS is installed on. A user who cannot open the panel does not see the tools: they are absent fromtools/list, and calling one returns a not-found error. If the panel cannot be resolved, or the user model has nocanAccessPanel()method, access is denied. - The server authorizes once, at connection, and then acts with full authority. That check is the entire authorization boundary. The server 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 tiers above.
What MCP requires from a block author
MCP requires only rules() and defaults() on a block class. When both are declared, cms_describe_block returns an accurate contract. When they are omitted, it infers a contract from PHP types and reports that the contract is inferred.
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 cms_update_content replaces the block list, snippets and shared regions, translating with cms_translate_content, 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 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:
- In a terminal at the application's root, run
php artisan boost:update. - 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.mdfor Claude Code, andmcg/filament-cmsis added to thepackagesarray inboost.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:
- In a terminal at the application's root, create the directory
.claude/skillsif it does not exist. - In the same terminal, copy both skill directories into it with the commands below.
- After every package update, repeat step 2. A copied skill does not change when the package does.
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/
What the server exposes
The server exposes 37 tools, 4 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 |
|---|---|
| cms_list_content_types | Lists the content types this site has, and what each supports |
| cms_list_blocks | Lists the available blocks, grouped by category, filtered by content type |
| cms_describe_block | Returns one block's exact field contract |
| cms_list_layouts | 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_blocksreportssource, which ispackagefor a block the CMS ships andhostfor a block this application defines. It also reportsview_overridden, which istruewhen a published copy of the block's Blade view exists underresources/views/vendor/cms/blocks; that copy is the view that renders.cms_list_layoutsreportspublished, which istruewhen the host application has its own copy of the layout underresources/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 |
|---|---|
| cms_list_content | Lists pages with path and status; filters by type, status, parent and search text |
| cms_get_content | Returns one page in full, including its blocks |
| cms_list_versions | Lists a page's version history |
| cms_diff_versions | Reports what changed between two versions |
| cms_preview_content | Returns a signed URL for an unpublished draft |
Writing tools
| Tool | What it does |
|---|---|
| cms_create_content | Creates a page as a draft |
| cms_update_content | Saves a new draft version |
| cms_translate_content | Creates a page's translation into another language, with its translated title, slug, blocks and SEO, as a draft |
| cms_append_block, cms_replace_block, cms_remove_block, cms_move_block | Edit one block without resending the rest of the block list |
| cms_publish_content, cms_unpublish_content | Make a draft live, or take a page off the site |
| cms_restore_version | Copies an old version forward as a new draft |
| cms_delete_content, cms_restore_content | Soft-delete a page, and undo the soft delete |
Media tools
| Tool | What it does |
|---|---|
| cms_list_media | Lists library files with their media ids and conversion URLs |
| cms_upload_media | Adds a file, either from an absolute server path (honored only for a trusted local stdio session) or from base64-encoded contents |
| cms_update_media | Renames a file, or changes its alt text |
| cms_delete_media | Removes a file; refuses, naming the pages, when a live page references it, unless force: true is passed |
Site-level tools
| Tool | What it does |
|---|---|
| cms_list_redirects, cms_create_redirect, cms_update_redirect, cms_delete_redirect | Manage redirects |
| cms_get_site_settings, cms_update_site_settings | Read and change site-wide settings |
| cms_clear_cache | Clears cached pages |
| cms_prune_versions | Deletes old version history |
| cms_publish_scheduled | 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 |
|---|---|
| cms_theme_state | Reports which skins are registered, which one is active, and whether each is wired up: stylesheet present, stylesheet imported, Blade partial, fonts |
| cms_validate_skin | 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 |
| cms_match_block | Given a described component, returns the five closest registered blocks with their full schemas and a recommendation of reuse, retoken, override_view or new_block |
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
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://theme-wiring(resource, Markdown): where generated files are written and what registers them.cms_author_page(prompt): guides a model through drafting a page.cms_design_from_figma(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 and cms://theme-wiring are for building the site's theme. The two pairs 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 and identifiers. Three of its rules govern every write:
- 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 withcms_list_versionsandcms_restore_version. - Block shapes are discovered, not guessed.
cms_describe_blockreturns the block's validation contract, derived from the block class'srules()anddefaults(). Block data that does not match is rejected; it is not stored partially. cms_update_contentreplaces 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:
"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_blocktakes aregion, and itspositioncounts 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_blockalso takes aregion, which moves a block from one region to another.cms_list_layoutsreports 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:
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 | cms_append_block with into: "2.tabs.0.blocks" |
| Edit a nested block | cms_replace_block with block: "2.tabs.0.blocks.1", or the block's id |
| Delete a nested block | cms_remove_block, with the same addressing |
| Reorder a block within its container | cms_move_block, with the same addressing |
| Move a block between containers | cms_remove_block, then cms_append_block |
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:
cms://tokensnames all 50 design tokens (theTOKENSconstant insrc/Support/DesignTokens.php), with what each controls, its default value, and the token it is contrast-checked against.cms://theme-wiringstates where a generated skin, layout or block view must be written, and lists the two registration steps thatcms:make-skindoes not perform.cms_design_from_figmais the procedure. It stops at a written plan indocs/cms-design/{skin}.mdfor 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:
- List the skin's Bunny Fonts families under
cms.theme.skin_fontsinconfig/cms.php. - Set
cms.theme.fontstofalseand serve the fonts from the application. - 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. Every key is listed with its default in Reference.
'mcp' => [
'enabled' => true,
'handle' => 'cms',
'name' => env('CMS_MCP_NAME'),
'actor' => null,
'local' => ['trusted' => true],
'web' => ['enabled' => false, 'path' => '/mcp/cms', 'middleware' => []],
'blocks' => ['denied' => [], 'gated' => ['html']],
'settings' => ['denied' => ['analytics']],
],
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:
'actor' => 'automation@example.com',
Disabling the server
To disable the server, set enabled to false in config/cms.php:
'mcp' => ['enabled' => false],
No server is registered, and php artisan mcp:start cms reports an unknown handle.