You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[5.x] Reload project config changed by other processes in the stdio MCP server - #20597
mcp/serve is a single long-running command. It loaded the project config, the info row's configVersion, and the records cached from them once, then never reloaded them. Changes made in the control panel or CLI weren't visible to it. And because acquireLock() compares the database configVersion with the stale cached one, every project config write after an outside change failed with a StaleResourceException until the server was restarted.
StdioEventDispatcher::dispatch($event)
+ on RequestEvent+ if info.configVersion in the DB != Craft::$app->getInfo()->configVersion+ getIsInstalled(true) # reload the info row+ projectConfig->forgetLoadedConfig() # new, unlike reset() it doesn't re-run init()+ fields / entries / sites / volumes / fs / imageTransforms->reset() # new @internal methods+ elementSources->refreshSources()
on ResponseEvent | ErrorEvent
projectConfig->flush()
The reload runs only when another process has changed configVersion. When nothing has changed, the cost is one query per request. This is the 5.x counterpart to #20583.
Evidence
Reproduced the #20568 steps against a real php craft mcp/serve process, with separate processes (project-config/set and a Fields::saveField() call standing in for the CP) changing the field between messages.
Before:configuration_get returned the stale instructions and revision. Updates with or without expectedRevision, including one to an unrelated site group, failed with "Error while executing tool" (StaleResourceException: The loaded project config is out-of-date) until the server was restarted. After: reads return the outside change and a new revision. An update with the old revision is rejected with the normal "record has changed" message. Updates with the current revision, or without one, save and keep the outside change, and site groups created elsewhere appear in the list.
Regression test in StdioTransportTest:
serve: configuration_get → (another process changes fields.<uid>.instructions + configVersion) → configuration_get → configuration_update
expect second get to show the new instructions and a new revision
expect update to succeed and keep the outside instructions
Without the fix it fails: Failed asserting that null is identical to 'Changed elsewhere'. With the fix, the unit/mcp suite (76 tests) and ProjectConfigTest pass.
mcp/serve is one long-running command, so it loaded the project config,
the info row's configVersion, and the records cached from them once and
never again. It didn't see changes made in the control panel or CLI, and
since acquireLock() compared the database configVersion against the
stale one, every project config change failed with a
StaleResourceException until the server was restarted.
Before each request, the stdio server now checks configVersion, and if
another process changed it, reloads the info row, the project config,
and the cached fields, sections, entry types, sites, site groups,
volumes, filesystems, image transforms, and element sources.
Amp-Thread-ID: https://ampcode.com/threads/T-01a1c91e-60bb-7296-ac98-121cdd854775
Co-authored-by: Amp <amp@ampcode.com>
riasvdv
changed the title
Reload project config changed by other processes in the stdio MCP server
[5.x] Reload project config changed by other processes in the stdio MCP server
Nov 11, 2026
Updates Craft 5’s long-running stdio MCP server to detect external project config changes between requests, addressing stale reads and write failures without requiring a restart.
Changes:
Checks configVersion before each request and refreshes configuration and related caches when it changes.
Adds cache-reset methods without re-registering project config event handlers.
Adds a regression test for reading and updating a field after an external change.
File
Description
tests/unit/mcp/StdioTransportTest.php
Tests external changes between stdio messages and preservation during updates.
src/services/Volumes.php
Adds volume cache reset.
src/services/Sites.php
Adds site-group reset and site reload.
src/services/ProjectConfig.php
Clears loaded config without repeating initialization.
src/services/ImageTransforms.php
Adds image-transform cache reset.
src/services/Fs.php
Adds filesystem cache reset.
src/services/Fields.php
Clears field and layout caches without changing the field version.
src/services/Entries.php
Clears section and entry-type caches.
src/mcp/StdioEventDispatcher.php
Detects external config changes and refreshes request state.
Sites::reset() kept the cached current site, so after another process
changed the primary site or deleted the current one, later requests kept
using the old site, such as when filtering routes by site.
Amp-Thread-ID: https://ampcode.com/threads/T-01a1c91e-60bb-7296-ac98-121cdd854775
Co-authored-by: Amp <amp@ampcode.com>
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
Stale permission caches can overwrite externally updated grants, while an unrefreshed field-handle registry can cause element serialization to throw InvalidFieldException.
Refresh CustomFieldBehavior handles after field or layout changes
src/services/Fields.php:835
Once CustomFieldBehavior has loaded, clearing these caches leaves its static handle table stale when another process adds or renames a field or layout-specific handle. Serializing an element with that handle can then throw InvalidFieldException in getFieldValue(), despite the refreshed layout. Refresh the runtime handle registry alongside these caches; changing fieldVersion alone cannot update an already loaded class.
The stdio server never cleared UserPermissions' caches, so it could
merge permission changes against grants another process had since
changed. Direct user permissions aren't in the project config, so the
caches are now reset before every request rather than only when the
config version changes.
When fields or layouts change elsewhere, Fields::reset() now also adds
their handles to the already-loaded CustomFieldBehavior class, so
elements with fields added by other processes can be read.
Amp-Thread-ID: https://ampcode.com/threads/T-01a1c91e-60bb-7296-ac98-121cdd854775
Co-authored-by: Amp <amp@ampcode.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
mcp/serveis a single long-running command. It loaded the project config, the info row'sconfigVersion, and the records cached from them once, then never reloaded them. Changes made in the control panel or CLI weren't visible to it. And becauseacquireLock()compares the databaseconfigVersionwith the stale cached one, every project config write after an outside change failed with aStaleResourceExceptionuntil the server was restarted.The reload runs only when another process has changed
configVersion. When nothing has changed, the cost is one query per request. This is the 5.x counterpart to #20583.Evidence
Reproduced the #20568 steps against a real
php craft mcp/serveprocess, with separate processes (project-config/setand aFields::saveField()call standing in for the CP) changing the field between messages.configuration_getreturned the stale instructions and revision. Updates with or withoutexpectedRevision, including one to an unrelated site group, failed with "Error while executing tool" (StaleResourceException: The loaded project config is out-of-date) until the server was restarted.After: reads return the outside change and a new revision. An update with the old revision is rejected with the normal "record has changed" message. Updates with the current revision, or without one, save and keep the outside change, and site groups created elsewhere appear in the list.
Regression test in
StdioTransportTest:Without the fix it fails:
Failed asserting that null is identical to 'Changed elsewhere'. With the fix, theunit/mcpsuite (76 tests) andProjectConfigTestpass.Related issues