Skip to content

fix: survive a second active copy, and document migrating off Composer bundling - #288

Open
matiasbenedetto wants to merge 7 commits into
WordPress:trunkfrom
matiasbenedetto:fix/composer-library-dependency-copy
Open

fix: survive a second active copy, and document migrating off Composer bundling#288
matiasbenedetto wants to merge 7 commits into
WordPress:trunkfrom
matiasbenedetto:fix/composer-library-dependency-copy

Conversation

@matiasbenedetto

@matiasbenedetto matiasbenedetto commented Aug 16, 2026

Copy link
Copy Markdown

Part of #178 — the "make plugin backwards-compatible with Composer Library + migration docs" item.

This takes the code and migration-guide half. The docs pass that @abhi3315 picked up (readme.txt, and restating Jetpack Autoloader as the support boundary per #172) is untouched here apart from two one-line pointers.

Updated after review. The guide originally presented Composer bundling as a supported route with setup instructions. Per @stormrockwell it now leads with migrating away from it. See below.

The problem

wordpress/mcp-adapter is published as "type": "wordpress-plugin" (#44), so Composer places it in wp-content/plugins/ rather than vendor/ whenever composer/installers is in the dependency graph:

$ composer require wordpress/mcp-adapter
$ grep MCP vendor/composer/autoload_psr4.php
'WP\\MCP\\' => array($baseDir . '/wp-content/plugins/mcp-adapter/includes'),

For the plugin install method that placement is exactly right. But it also means the copy lands in a real plugin directory with a real plugin header, so it can be activated next to the canonical plugin. Two things went wrong when it was.

1. Two active copies fatal the site

WordPress loads mcp-adapter.php twice from two different paths:

Fatal error: Cannot redeclare WP\MCP\constants() (previously declared in
/var/www/html/wp-content/plugins/mcp-adapter/mcp-adapter.php:35) in
/tmp/mcp-adapter-copy-6a81ff50aa1b86.78989949/mcp-adapter.php on line 35

Worth being explicit about why a defined( 'WP_MCP_DIR' ) || return; at the top of the file would not have fixed this: PHP binds unconditional top-level function declarations while the file is compiled, so the redeclaration fatals before any runtime guard executes. The constants have to move out of the named function, which is what this PR does.

2. A dependency copy is told to run composer install

Composer flattens dependencies into the root project's vendor/, so a dependency copy legitimately has no vendor/ of its own:

$ ls wp-content/plugins/mcp-adapter/
LICENSE.md  README.md  composer.json  includes  mcp-adapter.php  readme.txt

Autoloader::autoload() read that as an incomplete source checkout and surfaced "The Composer autoloader was not found… make sure to run composer install" — pointing users at a file that is not supposed to exist. Meanwhile the classes were already loaded via the consumer's autoloader, so the state was: classes present, plugin active, Plugin::instance() never called.

Both of these get more likely while people are migrating off bundled copies, not less, which is the backwards-compatibility part of the checklist item.

The changes

mcp-adapter.php — constants defined inline instead of from constants(), and the file bails when WP_MCP_DIR is already defined. The require_once of Autoloader.php is guarded too, for the case where another copy's WP\MCP\Autoloader was already resolved by a consumer's autoloader.

includes/Autoloader.php — when there is no local vendor/autoload_packages.php, check whether WP\MCP\ is already resolvable through another autoloader before falling back to the notice. A genuine source checkout with no vendor/ and nothing else providing the classes still gets the notice, unchanged.

docs/migration/composer-to-plugin.md — the migration guidance the checklist item asks for. Leads with migrating off a bundled copy, documents the sanctioned Composer route (wp-plugin/*, wpackagist-plugin/*) and that the wp-content/plugins/ placement is intended, then keeps Jetpack Autoloader and the library-vs-plugin behavior differences (WP_MCP_DIR/WP_MCP_VERSION undefined, dependency check skipped, wp_mcp_init never fires, McpAdapter::instance() is yours to call) as a transitional stopgap rather than a setup guide.

Tests

tests/phpunit/Unit/BootstrapTest.php copies mcp-adapter.php to a second path and requires it, which is the faithful reproduction of two installed copies. It fatals on trunk and passes here.

$ git stash push mcp-adapter.php && npm run test:php -- --filter BootstrapTest
Fatal error: Cannot redeclare WP\MCP\constants() …
✖ Command failed with exit code 255

$ git stash pop && npm run test:php -- --filter BootstrapTest
OK (3 tests, 5 assertions)

Full suite, phpcs, and phpstan are all green.

On not recommending Composer bundling

The first version of this guide had a "keep bundling it with Composer" section, including an installer-paths override to pin the package back into vendor/. That was wrong, and I've dropped it.

The line in #178 about prioritizing "usage via plugin (and wpackagist/wp-packages for Composer)" is what settled it: wp-plugin/* and wpackagist-plugin/* both install the plugin from .org into wp-content/plugins/, which is precisely what the wordpress-plugin package type exists to do. Pinning the package back into vendor/ fights that. The guide now treats the relocation as a signal to migrate.

I'd also read this as closing the question of whether the package type should revert to library — it shouldn't.

One thing I did not touch

README.md still has an "As a Composer Library (for plugin developers)" section that opens with "Plugin developers may wish to install MCP Adapter as a Composer dependency to integrate MCP functionality into their own plugins." docs/getting-started/installation.md has the equivalent "Method 2: Composer Package". Both now read as endorsements of the thing we're steering people away from, and both should probably be reframed around installing the plugin via wpackagist/wp-packages.

I only added a one-line pointer to each rather than rewriting them, since that overlaps @abhi3315's install-docs pass and seemed like a call for maintainers. Happy to take it in this PR or a follow-up.

🤖 Generated with Claude Code

Copilot AI lite review requested due to automatic review settings August 16, 2026 18:26
@github-actions

github-actions Bot commented Aug 16, 2026

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: matiasbenedetto <mmaattiiaass@git.wordpress.org>
Co-authored-by: stormrockwell <stormrockwell@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@codecov

codecov Bot commented Aug 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 37.50000% with 15 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.11%. Comparing base (c2c6e9d) to head (f568a5d).

Files with missing lines Patch % Lines
includes/Autoloader.php 18.18% 9 Missing ⚠️
includes/Core/McpAdapter.php 53.84% 6 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##              trunk     #288      +/-   ##
============================================
- Coverage     88.17%   88.11%   -0.07%     
- Complexity     1259     1265       +6     
============================================
  Files            54       54              
  Lines          4120     4138      +18     
============================================
+ Hits           3633     3646      +13     
- Misses          487      492       +5     
Flag Coverage Δ
unit 88.11% <37.50%> (-0.07%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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.

Pull request overview

Updates the plugin bootstrap and autoloader behavior so a Composer-relocated, Composer-installed copy can coexist safely next to the canonical WordPress plugin without fatals or misleading “run composer install” notices, and adds migration guidance for Composer consumers.

Changes:

  • Makes mcp-adapter.php idempotent across multiple loaded copies by inlining constant definitions and bailing early if already bootstrapped.
  • Updates includes/Autoloader.php to treat a missing local vendor/ as acceptable when WP\MCP\ classes are already resolvable via another autoloader.
  • Adds a PHPUnit regression test and a new migration guide, with brief doc pointers from existing entry points.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated no comments.

Show a summary per file
File Description
tests/phpunit/Unit/BootstrapTest.php Adds coverage for loading a second bootstrap copy and for “autoloaded elsewhere” detection.
README.md Adds pointers explaining Composer relocation behavior and links to the migration guide.
mcp-adapter.php Removes the redeclare-prone constants() function; adds early guard and guarded autoloader include.
includes/Autoloader.php Suppresses the missing-autoloader notice when classes are already resolvable via another autoloader.
docs/README.md Adds the new migration guide to the docs index.
docs/migration/composer-to-plugin.md Introduces migration guidance for Composer users, including installer-paths and bootstrap expectations.
docs/getting-started/installation.md Adds a brief pointer to the migration guide for Composer installs.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@stormrockwell

stormrockwell commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

From my understanding, we don't want to recommend packaging the MCP adapter in composer require, regardless of the method since support may drop in the future

matiasbenedetto added a commit to matiasbenedetto/mcp-adapter that referenced this pull request Aug 16, 2026
Per review on WordPress#288: bundling MCP Adapter into a consuming plugin's vendor/
should not be presented as a supported route, since support for it may be
dropped.

The WordPress#178 checklist item asks install docs to prioritize "usage via plugin (and
wpackagist/wp-packages for Composer)". Both of those install the plugin from
.org into wp-content/plugins/, which is exactly what the `wordpress-plugin`
package type does. So the relocation out of vendor/ is the intended behavior,
not a problem to work around.

Reworked the guide accordingly:

- Leads with "migrate off bundling", not with two co-equal paths.
- Documents the sanctioned Composer route (wp-plugin/*, wpackagist-plugin/*)
  and states that the wp-content/plugins/ placement is intended.
- Keeps Jetpack Autoloader and the self-bootstrap differences, but as a
  transitional stopgap rather than a setup guide.
- Drops the `installer-paths` pin as advice; it only existed to defeat the
  intended install location, and now appears solely in the list of things to
  delete when migrating.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@matiasbenedetto matiasbenedetto changed the title fix: make a Composer-installed copy safe next to the canonical plugin fix: survive a second active copy, and document migrating off Composer bundling Aug 16, 2026
@matiasbenedetto

Copy link
Copy Markdown
Author

Thanks @stormrockwell — you were right, and #289 made it clearer what the guide should have said. I've updated it.

What changed

Two things in the migration guide were still teaching the old way of doing things:

  1. Requires Plugins is now the answer. The guide used to say "check class_exists() on plugins_loaded". It now leads with adding Requires Plugins: mcp-adapter to your plugin header, like docs: change recommendation to be installed as a canonical plugin #289 does. WordPress won't let your plugin activate without MCP Adapter, and it loads MCP Adapter first, so there's nothing to wire up. The class_exists() check is still there, but only as a safety net for older WordPress or if you'd rather fail softly than block activation.

  2. The guide now helps you get rid of Jetpack Autoloader. It used to explain how to set it up. There's now a step for removing it: check whether any other package you bundle still needs it, remove it, switch back to vendor/autoload.php, and delete the leftover generated files (composer remove doesn't always clean up vendor/autoload_packages.php, and a stale one works locally then breaks in production).

    One ordering note: removing Jetpack Autoloader comes after dropping the bundled copy, not before. If you pull it while you're still shipping a copy, a stale copy can win against the active plugin.

I also fixed a spot where this guide contradicted #289 about WP_MCP_VERSION. Now it matches: check the class to see if MCP Adapter is there, check the constant if you need a minimum version. Requires Plugins can't express a version constraint.

About #289

Same direction, and yours goes further — good. Two practical notes:

One thing I'd like a call on

My guide documents installing via wp-plugin/mcp-adapter and wpackagist-plugin/mcp-adapter. #289 leaves Composer out of the install docs entirely, and its testing steps expect zero wpackagist hits.

I added it because the #178 checklist says "prioritize usage via plugin (and wpackagist/wp-packages for Composer)". But those only work once we're listed on .org, so leaving them out for now is also reasonable. Happy to drop that section if you'd rather keep Composer out of the docs completely.

Last thing: if it's easier to review, I can split this into a code-only PR (the crash fix, conflicts with nothing) and a docs follow-up rebased on #289. Just say the word.

matiasbenedetto and others added 2 commits August 16, 2026 14:18
`wordpress/mcp-adapter` is published as a `wordpress-plugin`, so any project
with `composer/installers` in its dependency graph gets the package relocated
from `vendor/wordpress/mcp-adapter/` to `wp-content/plugins/mcp-adapter/`. That
copy carries a plugin header, so it can be activated alongside the canonical
plugin. Two things went wrong when it was:

- WordPress loaded `mcp-adapter.php` twice from two different paths, which
  fatals with "Cannot redeclare WP\MCP\constants()". PHP binds unconditional
  top-level function declarations while the file is compiled, so a runtime
  guard could not have caught this — the constants are now defined inline and
  the file bails when `WP_MCP_DIR` is already set.

- A dependency copy has no `vendor/` of its own, because Composer flattens
  dependencies into the root project's `vendor/`. `Autoloader::autoload()` read
  that as a broken install and told the user to run `composer install`, chasing
  a file that is not supposed to exist. It now checks whether the `WP\MCP\`
  classes are already resolvable through another autoloader first, and only
  falls back to the notice when they are not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rebased on WordPress#289, which removed Composer and Jetpack Autoloader from the
install docs. This adds the migration half that WordPress#178 also asks for, aimed only
at plugins that already bundle MCP Adapter in their own `vendor/`.

- Leads with the `Requires Plugins` header, matching WordPress#289.
- Walks through removing the Jetpack Autoloader, including the generated files
  `composer remove` leaves behind. Ordered after the bundled copy is gone,
  since removing it earlier lets a stale copy win against the active plugin.
- Documents what a bundled copy still needs until migration finishes, without
  teaching how to set any of it up.
- Drops the wpackagist/wp-packages install section, so this guide does not
  reintroduce Composer install advice that WordPress#289 deliberately removed.

Also adds the CHANGELOG entries for this branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@matiasbenedetto
matiasbenedetto force-pushed the fix/composer-library-dependency-copy branch from 58c5d8a to b2ce352 Compare August 16, 2026 21:18
@matiasbenedetto

Copy link
Copy Markdown
Author

Rebased on trunk now that #289 has merged. Conflicts are gone.

What I changed to fit the new docs

  • Dropped the wpackagist / wp-packages section. This was the open question from my last comment. docs: change recommendation to be installed as a canonical plugin #289 kept Composer out of the install docs, so leaving it in here would have made this guide the only place still suggesting a Composer install. The guide now just links to the Installation Guide.
  • Moved the pointer links. They used to sit in the Composer sections docs: change recommendation to be installed as a canonical plugin #289 deleted. They're now one line each in "As a dependency of your plugin" (README) and "Depending on MCP Adapter from your own plugin" (installation.md), aimed only at people who already bundle a copy.
  • Added a CHANGELOG entry, since CHANGELOG.md landed while this PR was open.

The guide is now purely about moving off a bundled copy. It doesn't teach installing via Composer, and it doesn't teach setting up the Jetpack Autoloader — it explains how to remove it.

Check that this still holds

Your verification grep from #289 passes:

$ grep -rn -i "composer require\|wpackagist\|jetpack" README.md readme.txt docs/ \
    | grep -v "docs/guides/testing.md" | grep -v "docs/migration/composer-to-plugin.md"
docs/troubleshooting/common-issues.md:84:# Check the autoloader exists (Jetpack Autoloader, not autoload.php)
readme.txt:72: ... (0.6.1 changelog entry)
readme.txt:93: ... (0.6.0 changelog entry)

The common-issues.md line is the one you expected. The two readme.txt hits are changelog entries from #285, not install advice. Zero wpackagist anywhere.

Tests, phpcs, and phpstan are green against the new trunk.

The code changes are unchanged from before, and still don't overlap anything in #289. Offer stands to split them into their own PR if that's easier to review.

matiasbenedetto and others added 2 commits August 16, 2026 14:26
`WP_MCP_DIR` is defined by the plugin bootstrap, so if the adapter is
instantiated without it the `WP\MCP\` classes came from somewhere else — in
practice a copy bundled into another plugin via `composer require
wordpress/mcp-adapter`. That still runs, but the hooks, REST routes, and
default server ID are global, so a bundled copy silently competes with every
other copy on the site.

`McpAdapter::instance()` now raises `_doing_it_wrong()` in that case, pointing
developers at the `Requires Plugins` header. Visible only with `WP_DEBUG`;
runtime behavior is unchanged.

The check is deferred to `init` so it cannot produce false positives. Every
plugin file has been loaded by then, so an active canonical plugin has
certainly defined its constants and will not be mistaken for a bundled copy.
It also keeps `_doing_it_wrong()` from translating its message before `init`.
When the adapter is instantiated after `init` has already fired, the check runs
immediately rather than hooking an action that will never run again.

Verified both paths against wp-env: a normal plugin install stays silent, and
loading the classes through `vendor/autoload_packages.php` with the plugin
deactivated produces the notice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous check only asked whether `WP_MCP_DIR` was defined. That answers
"did the plugin bootstrap run", which is not the same question. The bootstrap
defines the constant whether or not its own classes are the ones executing, so
the check was silent in the case that matters most: the plugin is active, but
Jetpack Autoloader resolved `WP\MCP\` to a newer copy bundled inside some other
plugin, which then replaces the installed plugin site-wide.

Verified against wp-env before the change — with the constant pointing at one
directory and the class loaded from another, the old check reported canonical.

It now compares where the class was actually loaded from against the plugin
directory, so both situations are caught, and each gets its own message because
the fix differs:

- Plugin not active, classes supplied by something else.
- Plugin active, but a copy elsewhere is running. The message names the file,
  so it is possible to tell which plugin is shipping it.

Both paths are resolved with `realpath()` before comparison, since symlinked
plugin directories are normal in local and Bedrock-style setups and would
otherwise look like a foreign copy. The directory is compared with a trailing
slash so a sibling like `mcp-adapter-fork/` cannot match on prefix alone.

This also drops the old message's claim that the code came from "a copy bundled
inside another plugin", which was asserting a cause that had never been
established.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@matiasbenedetto

Copy link
Copy Markdown
Author

Added a _doing_it_wrong() notice for non-canonical usage, and reworked how it detects that.

What it does

The MCP Adapter plugin is the canonical copy. It owns the WP\MCP\ namespace, the hooks, the REST routes, and the default server ID — all global, so a second copy doesn't coexist with the plugin, it replaces it. McpAdapter::instance() now says so when the code running isn't the plugin's own.

Developer-facing only: it needs WP_DEBUG, and nothing about runtime behavior changes.

How it detects it

My first attempt just checked whether WP_MCP_DIR was defined. That turned out to answer the wrong question — the bootstrap defines that constant whether or not its own classes are the ones executing. So it was silent in the case that matters most: the plugin is active, but Jetpack Autoloader resolved WP\MCP\ to a newer copy bundled inside some other plugin, which then takes over site-wide.

I confirmed that against wp-env before changing it. With the constant pointing at one directory and the class loaded from another, the old check reported "canonical".

It now compares where the class was actually loaded from against the plugin directory, via ReflectionClass::getFileName(). Both paths go through realpath() first, since symlinked plugin directories are normal in local and Bedrock-style setups and would otherwise look foreign. The directory is compared with a trailing slash so a sibling like mcp-adapter-fork/ can't match on prefix alone.

That also let me drop the old message's claim that the code came from "a copy bundled inside another plugin" — we'd only established the bootstrap hadn't run. It could equally have been an mu-plugin or a custom loader.

Evidence

Front end of a wp-env site with WP_DEBUG on. Both scenarios use a fake acme-widgets plugin that bundles MCP Adapter.

The plugin is not active, and something else supplied the classes.

Notice: the MCP Adapter plugin is not active

The plugin is active, but a bundled copy is what's running. This is the case the constant check missed. Note that the message names the file, so you can tell which plugin is shipping it:

Notice: code loaded from a bundled copy instead

Control — plugin active, no bundled copy. Clean page, no notice:

$ wp plugin activate mcp-adapter
Success: Activated 1 of 1 plugins.
$ curl -s http://localhost:8889/ | grep -c "called <strong>incorrectly"
0

There's also a unit test for this. WP_UnitTestCase fails on an unexpected _doing_it_wrong(), so a regression that made normal installs noisy would break the suite.

is_loaded_from() is unit-tested directly for the plugin directory, a foreign directory, and the shared-prefix case. 1042 tests, phpcs and phpstan clean.

Two things for reviewers

The version string is a guess. _doing_it_wrong() needs one and I used 0.6.2. Wants changing if the next release isn't a patch.

This is firmer than anything currently written down. #172 called bundling-through-Jetpack-Autoloader supported, and this now nags about it in every debug environment — including for a site owner running someone else's plugin that bundles a copy. It matches where #289 and this PR's review landed, but it is a policy call rather than a bug fix, so I'd rather it be reviewed as one than slip through. Happy to drop it or soften it to the inactive-plugin case only.

Screenshots are hosted on an evidence/pr-288 branch of my fork, since GitHub has no API for attaching images to comments. Nothing in this PR's diff; delete any time.

@matiasbenedetto

Copy link
Copy Markdown
Author

Fixed the Requires Plugins documentation in 3336d26.

The migration guide no longer says that WordPress installs, activates, or load-orders MCP Adapter. It now describes the actual behavior: WordPress surfaces the dependency relationship and prevents the dependent plugin from being activated while MCP Adapter is missing or inactive.

The migration step also now says explicitly that Requires Plugins does not control plugin load order, so integrations should wait until plugins_loaded before checking for or using the WP\MCP\ classes. The existing example already uses that safe timing and remains unchanged.

…replaced

The takeover check could not fire in the case it was written for. It lived in
`McpAdapter::instance()`, but when a copy bundled inside another plugin wins the
autoload race, `McpAdapter` *is* that copy — and a release older than this check
has no way to report its own takeover.

Verified against a real site: WordPress 6.9 with this plugin at 0.6.1 and
seo-by-rank-math 1.0.276, which vendors wordpress/mcp-adapter v0.4.1 through the
stock Composer autoloader and calls `McpAdapter::instance()` from its bootstrap.
Today this only works because `active_plugins` happens to be alphabetical, so
`mcp-adapter` loads first and wins. Reordering the option is enough to flip it:
0.4.1 then runs against the 0.6.1 plugin, and nothing reported it.

The check moves to `mcp-adapter.php`, which always runs while the plugin is
active no matter which copy the autoloader resolved, and is therefore the one
place it can be relied on. It is self-contained on purpose: calling into the
loaded classes would call a method that does not exist in whichever version won,
turning a notice into a fatal on exactly the sites being warned. It is a closure
for the same reason the constants are inline — a named function there would
fatal on redeclaration.

`McpAdapter::instance()` keeps the complementary case, where no plugin is active
and something else supplied the classes. The two are mutually exclusive, so only
one notice is ever raised.

The message now names both versions, not just the path, since "0.4.1 loaded from
seo-by-rank-math while the plugin is 0.6.1" is the whole diagnosis in one line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
matiasbenedetto added a commit to matiasbenedetto/mcp-adapter that referenced this pull request Aug 16, 2026
@matiasbenedetto

Copy link
Copy Markdown
Author

Tested this against a real bundler — seo-by-rank-math, which vendors wordpress/mcp-adapter — and found the check couldn't fire in the case it was written for. Fixed, and the fix is now verified end to end against the unmodified plugin.

The gap

Rank Math 1.0.276 bundles v0.4.1, declared type: library, loaded through the stock Composer autoloader — no Jetpack Autoloader anywhere in its vendor/. From rank-math.php:262:

include __DIR__ . '/vendor/autoload.php';
if ( class_exists( 'WP\MCP\Core\McpAdapter' ) && function_exists( 'wp_get_abilities' ) ) {
	\WP\MCP\Core\McpAdapter::instance();
}

With both plugins active it works — but only because active_plugins happens to be alphabetical, so mcp-adapter loads first and its classes win. Reordering that option is enough to flip it:

WP_MCP_DIR            = .../plugins/mcp-adapter/     ← plugin bootstrap ran
WP_MCP_VERSION        = 0.6.1                         ← installed plugin
running class         = .../seo-by-rank-math/vendor/wordpress/mcp-adapter/includes/Core/McpAdapter.php
running VERSION const = 0.4.1                         ← two minor versions old

Two minor versions of adapter code running against a 0.6.1 plugin, and nothing reported it. The check lived in McpAdapter::instance(), and when a bundled copy wins the race, McpAdapter is that copy — v0.4.1 has no such method, so it cannot report its own takeover.

The fix

The takeover check moves to mcp-adapter.php. That file always runs while the plugin is active, whichever copy the autoloader resolved, so it is the one place the check can be relied on.

Three things it has to respect:

  • Self-contained. It must not call into the loaded classes. The method it would call does not exist in whichever version won, which would turn a notice into a fatal on exactly the sites being warned.
  • A closure, not a named function. A named function in that file reintroduces the compile-time redeclare fatal this PR opens by fixing.
  • Disjoint from the other check. McpAdapter::instance() now only handles "no plugin active, something else supplied the classes". The two conditions are mutually exclusive, so exactly one notice is ever raised.

The message also names both versions now, since that is the whole diagnosis in one line.

Evidence

Unmodified Rank Math 1.0.276 + this branch, Rank Math loading first. No patching, no simulation — the stock plugin from the .org directory:

Notice naming Rank Math's bundled v0.4.1 taking over from the 0.6.1 plugin

Notice: Function WP\MCP\Core\McpAdapter was called incorrectly. MCP Adapter 0.4.1 was loaded from /var/www/html/wp-content/plugins/seo-by-rank-math/vendor/wordpress/mcp-adapter/includes/Core/McpAdapter.php, so it is running instead of the MCP Adapter plugin (version 0.6.1) installed on this site. Whichever plugin ships that copy should depend on the MCP Adapter plugin with the "Requires Plugins" header rather than bundling its own, since the bundled copy replaces the installed plugin site-wide.

Default plugin order — the plugin's own copy wins, no notice:

$ curl -s http://localhost:8888/ | grep -c "called <strong>incorrectly"
0

That second result matters as much as the first: a real third-party bundler on a normally-ordered site produces no false positive.

1040 tests, phpcs and phpstan clean.

Notes

Coverage changed shape. Rather than duplicate the path logic into the bootstrap, I removed it from McpAdapter, so it now exists in one place. The three unit tests for the old private helpers are replaced by one asserting a normal install resolves inside WP_MCP_DIR — the false-positive guard. The Rank Math check above is the real coverage for the takeover path.

One inherent limit, worth stating. This cannot fire when an old bundled copy wins and the plugin is not installed, because then no current code runs at all. It protects sites that have the plugin, which is the population we can actually reach.

Possibly worth raising with Rank Math. Their bundled copy is two minor versions behind and they are not using Jetpack Autoloader, so which copy wins is decided by plugin load order. Happy to open something on their side if that is useful.

The earlier caveats still stand: the 0.6.2 version string in _doing_it_wrong() is a guess, and the notice as a whole is a policy call rather than a bug fix.

Screenshot hosted on an evidence/pr-288 branch of my fork, since GitHub has no API for attaching images to comments. Not part of this PR's diff.

… running

Which copy of MCP Adapter runs is decided by autoloader registration order,
which follows plugin load order — not by anything either plugin controls. On a
site with seo-by-rank-math the installed plugin wins only because `active_plugins`
happens to be alphabetical. Deactivating and reactivating a plugin is enough to
reorder it, at which point the bundled v0.4.1 takes over silently.

Reporting only the takeover means staying quiet right up until that happens,
which is the wrong moment to start telling someone.

The bootstrap check now also looks for copies that exist but lost the race.
Registered Composer autoloaders can be asked where they *would* resolve a class
without loading it, so a dormant copy is found with no filesystem scanning:

    $loader->findFile( 'WP\MCP\Core\McpAdapter' )

`findFile()` is duck-typed rather than checked against
`Composer\Autoload\ClassLoader`, so scoped and prefixed builds of Composer are
covered too.

The two cases carry different messages, since one is happening and the other
could happen, and they are mutually exclusive — a takeover returns before the
dormant scan, so at most one notice is raised per request.

Verified against stock seo-by-rank-math 1.0.276:

- default order: reports the dormant copy (previously silent)
- Rank Math first: reports the takeover, exactly one notice
- Rank Math deactivated: silent

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
matiasbenedetto added a commit to matiasbenedetto/mcp-adapter that referenced this pull request Aug 16, 2026
@matiasbenedetto

Copy link
Copy Markdown
Author

Follow-up: the check now also reports a bundled copy that is installed but not currently running.

Why

My last comment noted that Rank Math works today only because active_plugins happens to be alphabetical, so mcp-adapter loads first and wins. That is not a property either plugin controls — deactivating and reactivating a plugin is enough to reorder autoloader registration, and then the bundled v0.4.1 takes over.

Reporting only the takeover means staying silent right up until the moment it happens. That is the worst possible time to start telling someone.

How

Registered Composer autoloaders can be asked where they would resolve a class, without loading it:

$loader->findFile( 'WP\MCP\Core\McpAdapter' )

Rank Math's ClassLoader is registered even when its copy loses the race, so this finds the dormant copy with no filesystem scanning — no globbing over wp-content/plugins/*/vendor/. findFile() is duck-typed rather than checked against Composer\Autoload\ClassLoader, so scoped and prefixed Composer builds are covered too.

Evidence

Stock Rank Math 1.0.276, default plugin order — the installed plugin is running correctly, and this is the state that was silent before:

Notice reporting a dormant bundled copy inside seo-by-rank-math

Notice: Function WP\MCP\Core\McpAdapter was called incorrectly. Another copy of MCP Adapter is installed at /var/www/html/wp-content/plugins/seo-by-rank-math/vendor/wordpress/mcp-adapter/includes/Core/McpAdapter.php. Only one copy can run per request, and which one wins is decided by plugin load order, so this site may start running that copy instead of the MCP Adapter plugin (version 0.6.1) without anything changing. Whichever plugin ships it should depend on the MCP Adapter plugin with the "Requires Plugins" header rather than bundling its own.

All three states against the same stock plugin:

Situation Result
Default order, plugin wins Reports the dormant copy — previously silent
Rank Math first, its copy wins Reports the takeover, naming both versions
Rank Math deactivated Silent

The two messages are mutually exclusive: a takeover returns before the dormant scan runs, so the middle row emits exactly one notice, not two. I checked that explicitly rather than assuming it.

1040 tests, phpcs and phpstan clean.

Two things reviewers should weigh

This widens the blast radius, deliberately. Every WP_DEBUG site running Rank Math now gets a notice, even though nothing is broken for them today. That is the intent — the risk is real and currently invisible — but it makes the policy question from my earlier comment sharper rather than softer. If that is too aggressive for now, the dormant case is the part to drop; the takeover case stands on its own.

It does not find every copy. Only copies reachable through an autoloader that is registered by init and exposes findFile(). A plugin that requires its files directly will not show up. Catching those needs a filesystem scan, which I think belongs in a Site Health test rather than a per-request check — happy to open that as a follow-up issue.

The earlier caveats still stand: the 0.6.2 version string in _doing_it_wrong() is a guess, and the notice as a whole is a policy call rather than a bug fix.

Screenshot hosted on an evidence/pr-288 branch of my fork, since GitHub has no API for attaching images to comments. Not part of this PR's diff.

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 participants