Skip to content

Fix: resolve bundled dependencies from disk when the Composer map is outdated - #8657

Open
Miraeld wants to merge 3 commits into
developfrom
fix/8652-vendor-autoload-fallback
Open

Fix: resolve bundled dependencies from disk when the Composer map is outdated#8657
Miraeld wants to merge 3 commits into
developfrom
fix/8652-vendor-autoload-fallback

Conversation

@Miraeld

@Miraeld Miraeld commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Description

Fixes #8652

Some users hit a fatal error right after updating to 3.23.1. WordPress' fatal error protection then pauses WP Rocket and emails the site owner, who reads it as WP Rocket having been uninstalled by WordPress. The error is:

PHP Fatal error: Could not check compatibility between
WP\MCP\Domain\Tools\McpTool::get_protocol_dto(): WP\McpSchema\Server\Tools\DTO\Tool and
WP\MCP\Domain\Contracts\McpComponentInterface::get_protocol_dto(): WP\McpSchema\Common\AbstractDataTransferObject,
because class WP\McpSchema\Server\Tools\DTO\Tool is not available in
.../wp-rocket/vendor/wordpress/mcp-adapter/includes/Domain/Tools/McpTool.php on line 251

Every file is present and correct on disk the whole time, which is why reinstalling changes nothing and why it looks impossible to reproduce. After this change the same conditions produce no fatal error, the plugin stays active, and MCP keeps working.

Type of change

  • New feature (non-breaking change which adds functionality).
  • Bug fix (non-breaking change which fixes an issue).
  • Enhancement (non-breaking change which improves an existing functionality).
  • Breaking change (fix or feature that would cause existing functionality to not work as before).
  • Sub-task of #(issue number)
  • Chore
  • Release

Detailed scenario

What was tested

Automated. New unit test covering rocket_get_vendor_class_relative_path(), 8 cases: both bundled prefixes, a class at the prefix root, and the cases that must be ignored (WP Rocket classes, Mozart prefixed classes, a namespace that merely shares the start of a prefix, an unrelated class, an empty class name).

vendor/bin/phpunit --testsuite unit --configuration tests/Unit/phpunit.xml.dist \
  --filter Test_RocketGetVendorClassRelativePath
OK (8 tests, 8 assertions)

Manual. Reproduced the reported fatal error against a real 3.23.1 install, by registering a namespace prefix table that knows WP\MCP\ but not WP\McpSchema\, which is exactly what 3.23.0 shipped, with 3.23.1's files on disk, then linking McpTool:

  • Without the fallback: the fatal error above, byte for byte, same file, same line 251.
  • With the fallback registered: WP\McpSchema\Server\Tools\DTO\Tool resolves and McpTool links with no error.

PHPCS and PHPStan are clean on both changed source files.

How to test

MCP only initialises on REST requests (rest_api_init priority 15), on WP 6.9+ with the Abilities API available, so a plain page view is not enough.

  1. On WP 7.0 with 3.23.1 installed, confirm vendor/wordpress/php-mcp-schema/ is present and GET /wp-json/ returns normally.
  2. Reproduce the broken state in one request, which is what the update window creates: register an autoloader that resolves WP\MCP\ from vendor/wordpress/mcp-adapter/includes/ and nothing else, then call class_exists( 'WP\MCP\Domain\Tools\McpTool' ). On develop this fatals with the error above. On this branch it does not.
  3. Regression: with everything normal, check that GET /wp-json/ and the MCP route still respond, and that no extra file is loaded on a front end page view (the fallback only runs on an autoload miss).

Affected Features & Quality Assurance Scope

  • Plugin bootstrap, inc/main.php, so smoke testing the whole plugin is relevant: activation, deactivation, admin pages, cache clearing.
  • MCP and the OAuth MCP server, since those are the bundled dependencies the fallback covers.
  • Plugin update path, updating from 3.23.x to this build, and the auto update path.

Technical description

Documentation

Composer builds its namespace prefix table once, when vendor/autoload.php is required, and the ClassLoader object keeps it in memory for the rest of the request. WordPress replaces the plugin directory in place during an update, so a request that started on the previous version keeps that version's table while the files on disk are already the new ones. Any namespace the previous version did not know about is then unresolvable, even though its files are there.

3.23.1 is where this became visible: 3.23.0 shipped mcp-adapter 0.4.1, whose table contains a single prefix, WP\MCP\. 3.23.1 ships 0.5.0 plus the new wordpress/php-mcp-schema package, and McpTool::get_protocol_dto() narrows its return type to a class in the new WP\McpSchema\ namespace. PHP resolves that class when it links McpTool, and a table without the prefix cannot provide it, so the request dies with E_COMPILE_ERROR. That error type cannot be caught, so it has to be prevented rather than handled.

This adds a last resort autoloader, appended to the SPL stack, that maps the bundled dependency prefixes to their directory and resolves them straight from disk:

  • WP\MCP\ to vendor/wordpress/mcp-adapter/includes/
  • WP\McpSchema\ to vendor/wordpress/php-mcp-schema/src/

Three properties matter:

  • It is appended, so it only runs after every other autoloader has missed. A copy of the same dependency provided by another plugin is never overridden, since that plugin's autoloader resolves the class first.
  • It names no class and no version, so it also covers whatever changes in a future version. This is deliberately not the narrower guard of checking that one specific DTO or method exists.
  • The prefix mapping is repeated in our own code rather than read back from vendor/composer/autoload_psr4.php, because that file is precisely what can be outdated in memory when the fallback is needed.

Only the dependencies that keep their upstream namespace are listed. The Mozart prefixed ones live under WP_Rocket\Dependencies\, a prefix present in every version of the table, so they are not exposed to this.

Same approach as WooCommerce, which added an appended PSR-4 fallback for the same failure, see plugins/woocommerce/woocommerce.php and src/Autoloader.php in their repository.

New dependencies

None.

Risks

  • Performance. The closure only runs on an autoload miss for one of two namespace prefixes, and returns immediately on a prefix mismatch. In a healthy request Composer resolves those classes first, so the fallback is never reached. Worst case is one is_readable() call.
  • Loading the wrong copy of a dependency. Prevented by appending instead of prepending: if another plugin provides the class, its autoloader answers before ours.
  • Path traversal through the class name. The class name is only used after a strict prefix match, and \ becomes / with .php appended, so the result stays under the plugin directory. is_readable() guards the include.
  • Masking a genuinely broken install. If the files really are missing, is_readable() fails and the fallback stays silent, exactly like Composer would.

Mandatory Checklist

Code validation

  • I validated all the Acceptance Criteria. If possible, provide screenshots or videos.
  • I triggered all changed lines of code at least once without new errors/warnings/notices.
  • I implemented built-in tests to cover the new/changed code.

Code style

  • I wrote a self-explanatory code about what it does.
  • I protected entry points against unexpected inputs.
  • I did not introduce unnecessary complexity.
  • Output messages (errors, notices, logs) are explicit enough for users to understand the issue and are actionnable.

Unticked items justification

The autoloader is deliberately silent. It runs during class linking, before the plugin is in a state where logging is safe, and anything it cannot resolve is left to the other autoloaders exactly as before. There is no user facing message to make actionable.

Additional Checks

  • In the case of complex code, I wrote comments to explain it.
  • When possible, I prepared ways to observe the implemented system (logs, data, etc.).
  • I added error handling logic when using functions that could throw errors (HTTP/API request, filesystem, etc.)

Observability was left out on purpose, for the reason above. The fix is verifiable from the outside instead: the fatal error stops appearing in debug.log, and WordPress stops pausing the plugin after an update.

@Miraeld
Miraeld marked this pull request as ready for review July 29, 2026 10:14
@codacy-production

codacy-production Bot commented Jul 29, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 8 complexity

Metric Results
Complexity 8

View in Codacy

🟢 Coverage 70.37% diff coverage

Metric Results
Coverage variation Report missing for acb02601
Diff coverage 70.37% diff coverage (50.00%)

View coverage diff in Codacy

Coverage variation details
Coverable lines Covered lines Coverage
Common ancestor commit (acb0260) Report Missing Report Missing Report Missing
Head commit (927e789) 46205 22071 47.77%

Coverage variation is the difference between the coverage for the head and common ancestor commits of the pull request branch: <coverage of head commit> - <coverage of common ancestor commit>

Diff coverage details
Coverable lines Covered lines Diff coverage
Pull request (#8657) 27 19 70.37%

Diff coverage is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified: <covered lines added or modified>/<coverable lines added or modified> * 100%

1 Codacy didn't receive coverage data for the commit, or there was an error processing the received data. Check your integration for errors and validate that your coverage setup is correct.

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@MathieuLamiot

Copy link
Copy Markdown
Contributor

@Miraeld Thanks for the PR. I have a few questions:

  1. Shouldn't this be part of the OAuth MCP library so all plugins using the MCP adapter would benefit from it? Or should we consider this covering a larger scope than the MCP Adapter?
  2. In "What was tested - Manual", you mention that you reproduced the issue, but using a manually altered namespace, correct? Or do we have a way to reproduce possibly recurring issues even after an update, as potentially happening?

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.

3.23.1: PHP fatal error in MCP adapter due to missing WP\McpSchema\Server\Tools\DTO\Tool class

2 participants