Skip to content

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

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

uldisrudzitis wants to merge 16 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 and font 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.
    • 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.
  • 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: 612 tests, none skipped
  • Without plugins (plugin folders moved out): 170 passed, none skipped (the shop's 212 tests now run from its repository)
  • Editor: 203 specs pass (ng test), 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, and deletes keeping shared folders); 8 of its 10 tests fail 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
  • 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

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

Comment @coderabbitai help to get the list of available commands.

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 5 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>
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