Skip to content

[5.x] Reload project config changed by other processes in the stdio MCP server - #20597

Open
riasvdv wants to merge 4 commits into
5.xfrom
bugfix/stdio-mcp-stale-project-config
Open

riasvdv wants to merge 4 commits into
5.xfrom
bugfix/stdio-mcp-stale-project-config

Conversation

@riasvdv

@riasvdv riasvdv commented Nov 11, 2026

Copy link
Copy Markdown
Contributor

Summary

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.

Related issues

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>
Copilot AI balanced review requested due to automatic review settings November 11, 2026 07:32
@riasvdv 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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

refreshSites() leaves _currentSite cached, so a primary-site change or deletion can cause later requests to filter routes using a stale site UID.

1 open issue
What changed in this PR

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.
CHANGELOG.md Documents the stdio MCP synchronization fix.

Review effort: Balanced

Comment thread src/services/Sites.php Resolved
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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.

1 open issue
1 previously missed issue

In code that hasn't changed since last review

Medium severity 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.

1 resolved since last review

Review effort: Balanced

Comment thread src/mcp/StdioEventDispatcher.php Resolved
riasvdv and others added 2 commits November 13, 2026 07:13
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>
…-project-config

Amp-Thread-ID: https://ampcode.com/threads/T-01a1c91e-60bb-7296-ac98-121cdd854775
Co-authored-by: Amp <amp@ampcode.com>

# Conflicts:
#	CHANGELOG.md

This branch has not been deployed

No deployments
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.

2 participants