Skip to content

MCP integration: laravel/mcp, shared entry title rule and AI assistant MCP tests - #668

Draft
uldisrudzitis wants to merge 25 commits into
masterfrom
mcp-integration
Draft

uldisrudzitis wants to merge 25 commits into
masterfrom
mcp-integration

Conversation

@uldisrudzitis

@uldisrudzitis uldisrudzitis commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

Part of the berta.me MCP integration. AI agents (Claude, ChatGPT, Cursor…) connect to the hosting app's MCP gateway (berta-cms/berta-hosting#42), which forwards site requests to each site's own MCP server. That server lives in the AI assistant plugin (companion PR in berta-cms/ai-assistant-plugin, branch mcp-integration).

Draft: the mcp-integration branch keeps growing until the integration is finished.

Changes

  • laravel/mcp ^1.0 moves to require (v1.1.1). It was only a dev dependency, so production installs couldn't serve the plugin's MCP endpoint.
  • Shared entry title rule: new SectionEntryRenderService::showsEntryTitles($templateName, $sectionType). Titles show on the default template and in portfolio sections. The renderer now uses it (rendering is unchanged), and so does the plugin's list-entries tool.
  • Shared rule for sections that hold entries: new SectionEntryRenderService::acceptsEntries($sectionType): every section type except external_link and shopping_cart. The editor's "Move entry to" list now uses it (the list is unchanged), and so do the plugin's create-entry and move-entry tools.
  • Plugin tests run from the plugins' own repositories. phpunit.xml has a Plugins test suite for app/Plugins/*/tests, and tests/Pest.php binds TestCase there too. So each plugin ships its tests, and an install without plugins simply has none (checked: the core suites run on their own without warnings).
    • The AI assistant plugin's tests (MCP tools, SiteContentReader, chat tools and prompt, quota) moved to its repository, together with the chat's settings prompt snapshot.
    • givePlanFeatures() (in tests/Pest.php) puts a test website on a hosting plan with given features and limits, as UserModel reads them. The plugin's multisite and AI chat tests use it.
    • linkBertaEngine() and writeTemplate() stay in tests/Pest.php: they set up a temporary Berta root, and plugin tests use them. setUpShopTestRoot() now uses linkBertaEngine().
    • The shop tests moved to the shop plugin's repository on master (Run plugin tests from the plugins' own repositories (shop) #671), merged in here. The shop plugin's mcp-integration branch makes its setUpShopTestRoot() use this branch's linkBertaEngine(), so it must be merged together with this PR.
  • Setting values are validated on the server. The editor's PATCH endpoints for settings and design settings (SiteSettingsController::update(), SiteTemplateSettingsController::update()) saved any value they were sent, so a crafted request could store a value outside a select's list, or a setting that needs a plan feature the website doesn't have (e.g. the custom_javascript HTML fields).
    • New App\Configuration\SettingValueValidator checks a value against its definition with the editor's own rules (validation.service.ts, setting.component.ts): allowed values of select, font and toggle settings, colors (hex, rgb(), and the rgba() the color picker saves), blank values unless allow_blank, CSS units and the validation kinds, plus requires_feature. Formats the editor doesn't validate (url, longtext, richtext, files, editor actions) take any value.
    • An invalid value gets 422 with error_message, the editor's existing error shape: it logs the error and keeps the field as it was. Entries of list settings and paths without a definition are saved as before.
    • Checked against the real definitions: every select lists its allowed values (including the template switch), so the editor's normal saves pass.
    • The AI plugin's write tools use the same validator, adding only agent-only refusals on top.
  • Open editors stay in sync with changes made elsewhere (another editor window, tab, browser or device, or an AI agent through the plugin's write tools). Without this, an editor showing old values silently overwrote the other change on its next save.
    • App\Shared\ChangeMarker keeps the website's latest change (a token, the editor window that made it, and its source) in Laravel's cache, not in the site's storage/ folder that backups and transfers copy. It never expires. A cache that doesn't work (e.g. the first request after updating an install from before CACHE_STORE) turns the checks off instead of breaking the editor.
    • EditorSync middleware on the editor's route groups: every response carries the latest change (X-Berta-Change, -Writer, -Source). A save from a window (X-Berta-Editor, hex id) that hasn't seen a change someone else made since is refused with 409. A window's own quick saves pass. Successful saves mark a change; responses with error_message don't. Password change and theme preview opt out, since they change no site data. When someone else marks a change while a save runs, the window is told about it, so it reloads instead of missing it.
    • GET v1/change-marker for the editor's check when a tab comes back into view.
    • Routes whose handler marks its own changes (the AI chat) use EditorSync::MARKED_BY_HANDLER: a window that missed a change is still refused, but the route marks no change of its own, and the answer carries the latest marker. The state lists the chat's undo address (aiUndo) when the plugin offers it.
    • Editor (shared/editor-sync.ts): an interceptor sends the window id and the last seen change to the editor API only (same origin), and reads the answers. A change by someone else shows a modal warn popup, "This website was changed by an AI agent / in another editor window. Reload to see the changes.", that comes back if another popup replaces it. Login and logout start over (the login loads all data fresh), and an empty marker (e.g. a cleared cache) never asks. The 409 skips the generic error toast.
    • Requests without the window header (older editors, other clients) pass unchecked, as before.
  • The editor session renews while it's used. It used to end exactly 24 hours after login.
    • App\User\EditorToken issues the token (used by AuthController). RenewEditorToken middleware renews a valid token older than an hour, at most hourly, and returns it in X-Berta-Token (with Cache-Control: no-store) and in the token cookie. The editor stores it (UserTokenRenewedAction).
    • Renewed tokens keep every claim, including hosting's uid, and are signed with the same key, so hosting-issued and renewed tokens stay interchangeable.
    • Limits: no renewal more than 30 days after login (auth_time), and none for sessions that started before the account last changed (engine/config/inc.conf.php is rewritten by a password change, and by hosting on a website transfer or email change). So a previous owner's or a stolen session still ends within 24 hours, as before.
  • HTML cleaning on the server: new App\Shared\HtmlCleaner removes what can run scripts: script elements (also in SVG), on… event attributes, iframe srcdoc and javascript:/vbscript: links (also disguised with tabs or entities). It keeps everything else, as the editor's rich text field (TinyMCE) does: forms and PayPal buttons, iframe embeds, styling. HTML with nothing to remove is returned unchanged. Built on PHP 8.4's own HTML5 parser, no new dependency. The AI plugin cleans the HTML agents write with it; the editor's own endpoints still trust the editor's client-side cleaning and could adopt it later.
  • Gallery files stay safe when entries and sections are copied, moved, renamed or deleted. Found while planning the agents' section tools, whose undo and redo run these paths more often than the editor does; the editor had the same bugs.
    • Copying a section gives each copied entry its own new media folder. Before, the folder name was the original's with the section name replaced, so an entry whose folder isn't named after its section (common on sites from older Berta versions) shared the original's folder, and deleting the copy deleted the original's gallery.
    • Moving an entry deletes the original only after its copy in the other section succeeded. Before, a media folder that couldn't be created (permissions, full disk) still deleted the original and its files.
    • Renaming a section names its entries' folders <name><id>; before, the names grew with every entry (works1, works12, works123…). A name that exists or another gallery refers to is skipped, so two entries never end up sharing a folder (renaming onto an empty folder used to replace it). The background folder keeps its old name when it can't be renamed, instead of the section pointing at a folder that doesn't exist.
    • Changing only the case of a section title ("Works" → "WORKS") keeps the section's name. Before, the name counted as taken by the section itself, so it was renamed to works-1 with all its folders.
    • Deleting an entry or a section keeps a media folder that another entry gallery or section background still refers to (Storage::mediaFoldersReferencedOutside()), so folders that older sites already share never lose files.
    • New entry folders also skip names another gallery refers to, even when the folder is missing. copyFolder() reports failures. An empty mediafolder no longer makes a move or a section copy duplicate the whole media folder. A failed section copy returns the editor's usual error_message instead of a server error.
    • Deleting a gallery file or a background image keeps the file when another gallery refers to its folder, so that gallery doesn't lose it.
    • Deleting an image removes only its own resized copies (one rule, Storage::resizedCopies(): _smallthumb_, _bg_, _grid_ and _<w>x<h>_ plus the exact file name). Before, deleting 1.jpg also deleted the thumbnails and sizes of 11.jpg, and replacing a.jpg those of banana.jpg, in galleries, backgrounds, crops, video posters and settings images, so images lost their editor thumbnails or backgrounds. The two copies of deleteMedia() are gone; everything uses removeImageWithThumbnails().
    • Deleting a video also deletes its poster image (the old @todo), unless another file of the gallery uses it.
  • Deleted files kept for undo (used by the AI plugin's delete tools): new App\Shared\UndoFiles, one folder per change in storage/_undo_files.
    • Not reachable on the web: .htaccess and LocalValetDriver.php refuse /storage/_undo_files/, so a deleted image is gone from the site. Site clones and previews skip the folder, as they skip _shop_files.
    • Removed after 30 days without a scheduler (hosted sites run none): a shutdown function on every web request (public pages, editor and API) removes kept files older than 30 days, at most once a day. Without kept files, which is every site without the plugin, the check is a single stat call.
    • Hosted sites need this release's .htaccess before the plugin's delete tools are used.
  • Entry gallery settings on the server: new App\Sites\Sections\Entries\Galleries\EntryGallerySettings mirrors the editor's gallery editor (entry-gallery-editor.component.ts): each setting's values and input rules, the gallery types and templates it's shown for, and the file fields (caption, video autoplay). The AI plugin's gallery tools check against it; the editor keeps its own copy for now.
  • Background gallery settings on the server: new App\Sites\Sections\SectionBackgroundSettings mirrors the editor's background gallery editor (background-gallery-editor.component.ts), with the templates that show backgrounds (messy); SectionBackgroundGalleryRenderService uses it instead of its own list.
  • Subsites: SitesDataService::restore() adds back the record of a deleted subsite whose folder is in place again, the counterpart of delete(), so the AI plugin undoes a subsite delete through core. delete() refuses the main site, whose empty name would point at the folder of all subsites. A new, empty site gets settings.xml with the default template when it's created (SiteSettingsDataService::createDefaultFile()), as the engine wrote it on first load, so its setup can be finished without opening it first.
  • The editor's AI chat, rebuilt (editor/src/app/ai-assistant/): it sends the message, the conversation and the site to the plugin's new chat, which works through the MCP tools. Each reply keeps the changes it made, with an Undo button that undoes them on the server without the AI (and shows why, when they were changed since). AiChangesMirrorService swaps the fresh data the server sends into the store and re-renders the preview with the same triggers the editor's own edits use (HandleSiteSettingsChildrenChangesAction, HandleSiteTemplateSettingsAction, the new SiteContentRefreshedAction in all four templates), reloading it only where the editor's own edits do. New store actions replace a site's sections (ReplaceSiteSectionsAction) and section entries (ReplaceSectionEntriesAction). A reply that wants to delete something shows its summary with Approve and Reject buttons; approving runs the delete on the server without the AI or quota, mirrors it like any change, and the reply's Undo can restore it. The state lists aiApprove next to aiUndo. The old client-side applying of AI change lists is removed.
  • Packages: laravel/mcp 1.1.2 and laravel/ai 1.3.0.
  • Sortable specs: the synthetic drags use a pointer id the real mouse doesn't have, so moving the mouse over a visible test page can't make them fail.
  • Core tests for the entry rules: tests/Unit/SectionEntryRenderServiceTest.php covers showsEntryTitles(), which only the plugin's tests covered before, and acceptsEntries().

Merge order

Merge this first, then the plugin PR, because the plugin needs laravel/mcp installed and its tests need the Plugins test suite. Merge the shop plugin's mcp-integration branch (Bitbucket berta-shop-laravel-plugin) at the same time, since its tests call this branch's linkBertaEngine().

Notes

  • The MCP tools only read site data through the existing data services. They never call an LLM, so external agents don't use berta.me's AI tokens.

Test plan

  • php artisan test --compact passes with the plugins installed: 725 tests, none skipped
  • Without plugins (plugin folders moved out): 191 passed, none skipped (the shop's 212 tests now run from its repository)
  • Editor: 229 specs pass (ng test, incl. the chat state, the change mirroring, the replace actions and the content re-render), production build succeeds
  • Editor sync and renewal: tests/Feature/EditorSyncTest.php (checks, own saves, agent and other-window changes, no marker, failing cache, invalid window ids, refused saves, opted-out routes, setup wizard, changes during a save), tests/Feature/RenewEditorTokenTest.php (renewal, claims kept, 30-day limit, account change, no-store), shared/editor-sync.spec.ts
  • Manual check: an agent change and a second editor window show the popup, and a stale save is refused; logging in after an outside change shows no popup; the session token renews after an hour
  • Media folders: tests/Feature/MediaFoldersTest.php (copying a section with a folder not named after it, moving, a failed move keeping the entry, renaming, taken names, a title case change, the background, deletes keeping shared folders, gallery and background files kept when their folder is shared, an image's own copies deleted but not those of similar names, and a video deleted with its poster); 8 of its first 10 tests failed on the code before the fix
  • Manual check: copy a section whose entry folder isn't named after it, then delete the copy; the original's gallery stays
  • Sites: tests/Feature/SitesTest.php (restoring a deleted subsite, refused for a taken name or missing folder, the main site never deleted, a new site's settings file)
  • HTML cleaning: tests/Unit/HtmlCleanerTest.php (what's removed, and real entry HTML with embeds and shop forms kept unchanged)
  • Setting validation: tests/Unit/SettingValueValidatorTest.php (rules per format) and tests/Feature/SettingsValidationTest.php (both endpoints save valid values, refuse invalid and missing-feature values with 422 without writing, and still save list entries)
  • Manual check in the editor: a font, a color, a text setting and a social link still save
  • Without plugin tests the core suites run on their own (294 tests)
  • Manual check: hosting gateway → local http://berta.test → list-sections, list-entries, get-entry-gallery, get-settings and get-design-settings return the website's content. On the messy template, entries come back without title and with entry_titles: false.

🤖 Generated with Claude Code

laravel/mcp moves from a dev-only dependency to require, so production
installs can serve the AI assistant plugin's MCP endpoint. Adds tests
for the MCP endpoint auth (site JWT in X-Authorization), the
list-sections tool, SiteContentReader and the chat prompt's sections
text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Tip

🎯 Customize your risk assessments

Guide how CodeRabbit assesses merge risk and security architecture risk. Highlight sensitive changes and explain your security boundaries using Merge Risk Instructions and Security Risk Instructions in your review settings. Security architecture risk assessments require the Advanced plan or above.

✅ Automatic approvals, with your rules

Let CodeRabbit approve pull requests once required threads are resolved, pre-merge checks pass, and your risk limits are met. Enable Automatic approvals in review settings, with overrides for teams or file paths.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

SectionEntryRenderService::showsEntryTitles() holds the rule "entry
titles are shown on the default template and in portfolio sections",
used by the renderer and the AI assistant plugin.

Tests cover the chat's entries tool output, SiteContentReader entries
(title rule, unknown and ../ section names) and the list-entries tool.
New linkBertaEngine() and writeTemplate() test helpers; the shop test
root now uses linkBertaEngine() too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@uldisrudzitis uldisrudzitis changed the title MCP integration: laravel/mcp dependency and AI assistant MCP tests MCP integration: laravel/mcp, shared entry title rule and AI assistant MCP tests Oct 23, 2026
uldisrudzitis and others added 9 commits October 23, 2026 15:38
Pins the chat's GetEntryGalleryTool output, covers
SiteContentReader::entryGallery() (untyped files, entries without a
gallery, unknown or ../ sections and entries) and the
get-entry-gallery tool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pins the GetEntryContentTool output on the default template, checks
that titles are left out on templates without entry titles, and that
entryContent() matches the entry list and rejects unknown or ../
sections and entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…text

Covers SiteContentReader::siteSettings() (titles, unknown keys, list
items, internal group left out, siteTexts kept) and the
get-site-settings tool. A snapshot pins the AI chat's current site
settings prompt text ahead of moving it onto the shared reader.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
phpunit.xml and Pest pick up app/Plugins/*/tests, so the AI assistant
plugin's tests move to its repository. Core gets its own test for the
entry title rule, which only the plugin's tests covered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
givePlanFeatures() puts a test website on a hosting plan with the given
features and limits, as UserModel reads them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The editor's settings and design endpoints saved any value they were sent.
They now check it against the setting's definition with the editor's own
rules (allowed values, colors, CSS units, blank values, validation kinds)
and the plan feature a setting needs, and answer 422 with error_message
otherwise. Entries of list settings are left as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The specs used pointerId 1, the real mouse's id in Chrome, so moving the
mouse over a visible test page could join a synthetic drag and, rarely,
stop the auto-scroll spec from scrolling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every editor API response carries the website's latest change
(ChangeMarker, in Laravel's cache). A save from an editor window that
missed a change made elsewhere - in another window or by an AI agent -
is refused with 409, and the editor asks to reload, also when the tab
comes back into view. Login, logout and an empty marker never ask.

The session token is renewed at most hourly while the editor is used,
keeping its claims, for up to 30 days after login and only while the
account hasn't changed since (password change, website transfer).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
uldisrudzitis and others added 14 commits October 28, 2026 11:10
Removes script elements, event handler attributes, iframe documents and
javascript:/vbscript: links, as the editor's rich text field does, and keeps
everything else (forms, embeds, styling). HTML with nothing to remove is
returned unchanged. Used for HTML that AI agents write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…renamed or deleted

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- UndoFiles: one folder per change in storage/_undo_files, where the AI plugin keeps what an agent deletes
- Every web request removes kept files older than 30 days, at most once a day; without kept files the check is
  a single stat call
- .htaccess and LocalValetDriver.php refuse the folder, and site clones and previews skip it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…files when one is deleted

- EntryGallerySettings mirrors the editor's gallery editor: each setting's values, input rules and the gallery
  types and templates it's shown for, plus the file fields (caption, video autoplay)
- SettingValueValidator checks toggles against their values, like selects
- Deleting a gallery file whose folder another gallery refers to keeps the file, so that gallery doesn't lose it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…image's own copies

- SectionBackgroundSettings mirrors the editor's background gallery editor: each setting's values, input rules
  and the templates that show backgrounds (messy), which the background renderer now uses too
- Deleting a background image whose folder another gallery refers to keeps the file, as for entry galleries
- One exact rule for an image's resized copies (Storage::resizedCopies()): deleting 1.jpg no longer deletes the
  thumbnails and sizes of 11.jpg, nor a.jpg those of banana.jpg, in galleries, backgrounds and settings images
- Deleting a video also deletes its poster image, unless another file of the gallery uses it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ngs file right away

- SitesDataService::restore() adds back the record of a deleted subsite whose folder is in place again, the
  counterpart of delete(), so undoing a subsite delete is a core operation
- delete() refuses the main site, whose empty name would point at the folder of all subsites
- A new, empty site gets settings.xml with the default template when it is created, as the engine wrote it on
  first load, so its setup can be finished without opening it first

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…or the chat's undo address

- EditorSync takes a MARKED_BY_HANDLER parameter for routes whose handler marks the changes it makes, e.g. the AI
  chat: a window that missed a change is still refused, but the route marks no change of its own, and the answer
  carries the latest marker
- The state lists the AI chat's undo address (aiUndo) when the plugin offers it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts changes natively

- The chat sends the message, the conversation and the site; each reply keeps the changes it made, with an Undo
  button that undoes them on the server without the AI, and says why when they were changed since
- AiChangesMirrorService swaps the fresh data the server sends into the store and re-renders the preview with the
  triggers the editor's own edits use, reloading it only where they do; the old client-side applying is removed
- New store actions replace a site's sections and a site's section entries; SiteContentRefreshedAction re-renders
  the menus and entries in all four templates

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A reply that wants to delete something shows its summary with Approve and Reject buttons; approving runs the
  delete on the server without the AI or quota, shows it in the editor and preview like any chat change, and
  lets the reply's Undo restore it; a request that expired says so
- The state lists the chat's approve address (aiApprove) when the plugin offers it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant