From 42f921ba3f8a19e87d6c53d65a0d7646bdcda5a6 Mon Sep 17 00:00:00 2001 From: fahimreza-dev Date: Tue, 6 Oct 2026 22:43:52 +0600 Subject: [PATCH] fix(mcp): run beside the mcp adapter 0.7.0 instead of fatalling MCP Adapter 0.7.0 (2026-10-02) broke Saddle two ways (#340): - Activating it on a Saddle site redeclared WP\MCP\Autoloader: the bundled copy was already loaded and the plugin's require_once is a different path. The bundle now stays off whenever an official plugin folder (mcp-adapter*/mcp-adapter.php) is installed, active or not, so activation can't collide and its Jetpack Autoloader can't pick up the plugin's newer classes. - With both active, every request fatalled: get_tools(), get_resources() and get_prompts() now require the request's protocol schema. A server with that API is not "current" (adapter_is_current() probes it), so the built-in transport serves /saddle/v1/mcp, as with WooCommerce's 0.1.0. Saddle's own server-list calls go through server_list(), which skips a method that wants arguments. Full 0.7.0 support (the typed records, a re-vendor) is a follow-up. Closes #340 Co-Authored-By: Claude Opus 5.5 --- includes/class-saddle-bundled-adapter.php | 31 +++++++++ includes/class-saddle-mcp.php | 38 +++++++++-- readme.txt | 1 + saddle.php | 21 +++++- tests/mcp-adapter-coexistence-test.php | 79 +++++++++++++++++++++++ 5 files changed, 164 insertions(+), 6 deletions(-) diff --git a/includes/class-saddle-bundled-adapter.php b/includes/class-saddle-bundled-adapter.php index aafd29f..6e7eef6 100644 --- a/includes/class-saddle-bundled-adapter.php +++ b/includes/class-saddle-bundled-adapter.php @@ -64,6 +64,17 @@ public static function load() { return; } + // The official MCP Adapter plugin is installed, active or not (#340). + // Activating it in the same request as a loaded bundle redeclares + // WP\MCP\Autoloader (its require_once is a different path) and fatals, + // and the bundle's Jetpack Autoloader can pick up that plugin's newer + // classes even while it is inactive. With the bundle off, an active + // copy serves the adapter and an inactive one leaves Saddle's + // built-in transport on /saddle/v1/mcp. + if ( self::standalone_installed() ) { + return; + } + $lib = SADDLE_DIR . 'includes/lib/wp-mcp/'; $autoloader = $lib . 'includes/Autoloader.php'; if ( ! is_readable( $autoloader ) ) { @@ -89,4 +100,24 @@ public static function load() { \WP\MCP\Plugin::instance(); } } + + /** + * Whether the official MCP Adapter plugin is installed (any folder name a + * release zip unpacks to, active or not). + * + * @param string|null $plugins_dir Plugins directory; WP_PLUGIN_DIR by default. A parameter only so it can be tested. + * @return bool + */ + public static function standalone_installed( $plugins_dir = null ) { + if ( null === $plugins_dir ) { + if ( ! defined( 'WP_PLUGIN_DIR' ) ) { + return false; + } + $plugins_dir = WP_PLUGIN_DIR; + } + + $found = glob( rtrim( (string) $plugins_dir, '/\\' ) . '/mcp-adapter*/mcp-adapter.php' ); + + return ! empty( $found ); + } } diff --git a/includes/class-saddle-mcp.php b/includes/class-saddle-mcp.php index 261c7b4..7fb23ad 100644 --- a/includes/class-saddle-mcp.php +++ b/includes/class-saddle-mcp.php @@ -254,14 +254,44 @@ private static function record_server_health( $adapter, $created, array $names ) $registered = array(); if ( method_exists( $adapter, 'get_server' ) ) { $server = $adapter->get_server( self::ADAPTER_SERVER_ID ); - if ( is_object( $server ) && method_exists( $server, 'get_tools' ) ) { - $registered = array_keys( $server->get_tools() ); + $tools = self::server_list( $server, 'get_tools' ); + if ( null !== $tools ) { + $registered = array_keys( $tools ); } } Saddle_MCP_Diagnostics::record_health( Saddle_MCP_Diagnostics::assess( $names, $registered ) ); } + /** + * A server's tools, resources or prompts, or null when the adapter's + * method wants arguments. MCP Adapter 0.7.0 asks for the request's + * protocol schema there (#340); Saddle::adapter_is_current() keeps such a + * copy off Saddle's path, and this keeps a stray call from fatalling every + * request if one is ever reached. + * + * @param object|null $server The adapter's McpServer instance. + * @param string $method get_tools, get_resources or get_prompts. + * @return array|null + */ + private static function server_list( $server, $method ) { + if ( ! is_object( $server ) || ! method_exists( $server, $method ) ) { + return null; + } + + try { + if ( 0 !== ( new ReflectionMethod( $server, $method ) )->getNumberOfRequiredParameters() ) { + return null; + } + } catch ( ReflectionException $e ) { + return null; + } + + $list = $server->$method(); + + return is_array( $list ) ? $list : null; + } + /** * Replace the adapter's default initialize `instructions` (the one-line * server description) with the same full context get-instructions returns. @@ -290,10 +320,10 @@ public static function filter_adapter_initialize( $result, $server ) { // adapter's router doesn't implement, and gets a 404 back on a // capability we told it we had. That reads as a broken connector. if ( isset( $data['capabilities'] ) && is_array( $data['capabilities'] ) ) { - if ( method_exists( $server, 'get_resources' ) && ! $server->get_resources() ) { + if ( array() === self::server_list( $server, 'get_resources' ) ) { unset( $data['capabilities']['resources'] ); } - if ( method_exists( $server, 'get_prompts' ) && ! $server->get_prompts() ) { + if ( array() === self::server_list( $server, 'get_prompts' ) ) { unset( $data['capabilities']['prompts'] ); } } diff --git a/readme.txt b/readme.txt index 6ad972c..c360b79 100644 --- a/readme.txt +++ b/readme.txt @@ -209,6 +209,7 @@ The WordPress.org version never checks for its own updates. The version from plu == Changelog == = 1.5.2 = +* Fixed: Saddle and the WordPress MCP Adapter plugin 0.7.0 can now run on the same site. Activating the adapter next to Saddle stopped with a fatal error, and with both active every page failed. Saddle now serves its own endpoint itself when the adapter is a version it doesn't support yet, and keeps its built-in copy of the adapter out of the way of the official plugin. * Changed: Bridle and Bridle KB are PlugPress plugins, so their tools are on as soon as the plugin is active, like Mailyard and SEO. You no longer switch them on under Services. * Fixed: When a step shows a preview first, such as moving a post to the trash, your AI app now shows you the preview and waits for your yes. Claude Code reads only the start of Saddle's guide, and it could confirm on its own. diff --git a/saddle.php b/saddle.php index d8001a8..647f32e 100644 --- a/saddle.php +++ b/saddle.php @@ -518,11 +518,28 @@ public static function adapter_available() { * the built-in transport serves the route and Saddle never starts that * adapter itself. * + * MCP Adapter 0.7.0 moved the other way (#340): McpServer::get_tools(), + * get_resources() and get_prompts() now need the request's negotiated + * protocol schema, and the handshake and list filters carry new record + * types. Saddle's server code is written against the 0.5–0.6 API, and on + * 0.7.0 it fatalled every request. Until it supports the new API, a + * server whose get_tools() wants arguments is treated like a copy that is + * too old: the built-in transport serves the route. + * * @param string $prompt_class The adapter's prompt class. A parameter only so the probe can be tested. + * @param string $server_class The adapter's server class. A parameter only so the probe can be tested. * @return bool */ - public static function adapter_is_current( $prompt_class = '\\WP\\MCP\\Domain\\Prompts\\McpPrompt' ) { - return method_exists( $prompt_class, 'fromArray' ); + public static function adapter_is_current( $prompt_class = '\\WP\\MCP\\Domain\\Prompts\\McpPrompt', $server_class = '\\WP\\MCP\\Core\\McpServer' ) { + if ( ! method_exists( $prompt_class, 'fromArray' ) || ! method_exists( $server_class, 'get_tools' ) ) { + return false; + } + + try { + return 0 === ( new ReflectionMethod( ltrim( $server_class, '\\' ), 'get_tools' ) )->getNumberOfRequiredParameters(); + } catch ( ReflectionException $e ) { + return false; + } } diff --git a/tests/mcp-adapter-coexistence-test.php b/tests/mcp-adapter-coexistence-test.php index 05d512c..65bdb79 100644 --- a/tests/mcp-adapter-coexistence-test.php +++ b/tests/mcp-adapter-coexistence-test.php @@ -147,6 +147,51 @@ public function test_an_adapter_older_than_0_5_is_not_handed_the_route() { $this->assertFalse( Saddle::adapter_is_current( 'Saddle_Test_Adapter_010_Prompt' ) ); } + /** + * MCP Adapter 0.7.0 made McpServer::get_tools() (and get_resources(), + * get_prompts()) require the request's protocol schema. Saddle called it + * bare and every request on the site fatalled (#340). A server with that + * API is not "current" yet: the built-in transport serves the route. + */ + public function test_an_adapter_whose_server_needs_a_schema_is_not_handed_the_route() { + $this->assertFalse( Saddle::adapter_is_current( 'Saddle_Test_Adapter_Prompt', 'Saddle_Test_Adapter_070_Server' ) ); + $this->assertTrue( Saddle::adapter_is_current( 'Saddle_Test_Adapter_Prompt', 'Saddle_Test_Adapter_061_Server' ) ); + $this->assertFalse( Saddle::adapter_is_current( 'Saddle_Test_Adapter_Prompt', 'Saddle_Test_No_Such_Server' ) ); + } + + /** + * A stray call to a schema-taking server method is skipped, not made. + */ + public function test_server_lists_are_read_only_from_the_api_saddle_knows() { + $list = new ReflectionMethod( 'Saddle_MCP', 'server_list' ); + $list->setAccessible( true ); + + $this->assertNull( $list->invoke( null, new Saddle_Test_Adapter_070_Server(), 'get_tools' ) ); + $this->assertSame( array( 'saddle-get-site-info' => true ), $list->invoke( null, new Saddle_Test_Adapter_061_Server(), 'get_tools' ) ); + $this->assertNull( $list->invoke( null, null, 'get_tools' ) ); + $this->assertNull( $list->invoke( null, new Saddle_Test_Adapter_061_Server(), 'get_prompts' ) ); + } + + /** + * With the official plugin installed, active or not, the bundle stays off: + * activating it next to a loaded bundle redeclared WP\MCP\Autoloader + * (#340). Any folder a release zip unpacks to counts. + */ + public function test_the_bundle_stands_aside_for_an_installed_official_plugin() { + $dir = trailingslashit( get_temp_dir() ) . 'saddle-plugins-' . wp_generate_password( 6, false ); + wp_mkdir_p( $dir . '/akismet' ); + $this->assertFalse( Saddle_Bundled_Adapter::standalone_installed( $dir ) ); + + wp_mkdir_p( $dir . '/mcp-adapter-0.7.0' ); + file_put_contents( $dir . '/mcp-adapter-0.7.0/mcp-adapter.php', 'assertTrue( Saddle_Bundled_Adapter::standalone_installed( $dir ) ); + + unlink( $dir . '/mcp-adapter-0.7.0/mcp-adapter.php' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- test fixture. + rmdir( $dir . '/mcp-adapter-0.7.0' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- test fixture. + rmdir( $dir . '/akismet' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- test fixture. + rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- test fixture. + } + /** * The other half: the copy this suite runs (0.6.1, the one Gravity Forms * sites run) still gets the route, so the probe did not switch the @@ -168,3 +213,37 @@ public static function from_array( array $data ) { return $data; } } + +/** + * A current adapter's McpPrompt: has fromArray(). + */ +class Saddle_Test_Adapter_Prompt { + + public static function fromArray( array $data ) { + return $data; + } +} + +/** + * MCP Adapter 0.6.1's McpServer: get_tools() takes nothing. + */ +class Saddle_Test_Adapter_061_Server { + + public function get_tools() { + return array( 'saddle-get-site-info' => true ); + } + + public function get_prompts( $schema ) { + return array( $schema ); + } +} + +/** + * MCP Adapter 0.7.0's McpServer: get_tools() wants the protocol schema. + */ +class Saddle_Test_Adapter_070_Server { + + public function get_tools( $schema ) { + return array( $schema ); + } +}