From 37572bd180f4aa1ac3aed9c2c7dc77bc5ca25f6a Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 3 Apr 2025 23:05:09 +0200 Subject: [PATCH] Import code from mcp-server / de-duplication (#51) * Import mcp-server repo with php-scoper * exclude folder --- .github/workflows/code-quality.yml | 2 +- .gitignore | 5 +- README.md | 8 +- ai-command.php | 10 +- composer.json | 54 ++- docs/reference.md | 4 +- phpcs.xml.dist | 9 +- scoper.inc.php | 17 + src/AI/AiClient.php | 2 +- src/AiCommand.php | 19 +- src/MCP/Client.php | 23 +- src/MCP/HttpTransport.php | 44 +-- src/MCP/InMemorySession.php | 22 +- src/MCP/InMemoryTransport.php | 7 +- src/MCP/Server.php | 351 ------------------ src/MCP/Servers/WP_CLI/Tools/CliCommands.php | 2 +- src/MCP/Servers/WP_CLI/WP_CLI.php | 6 +- src/MCP/Servers/WordPress/MediaManager.php | 37 -- .../WordPress/Tools/CommunityEvents.php | 56 --- src/MCP/Servers/WordPress/Tools/Dummy.php | 36 -- .../Servers/WordPress/Tools/ImageTools.php | 67 ---- src/MCP/Servers/WordPress/Tools/RestApi.php | 187 ---------- .../WordPress/Tools/RouteInformation.php | 107 ------ src/MCP/Servers/WordPress/WordPress.php | 62 ---- src/MCP/Servers/WordPress/WpAiClient.php | 194 ---------- src/McpServerCommand.php | 4 +- src/Utils/CliLogger.php | 2 +- src/Utils/McpConfig.php | 2 +- tests/MCP/Client/ClientTest.php | 2 +- 29 files changed, 140 insertions(+), 1201 deletions(-) create mode 100644 scoper.inc.php delete mode 100644 src/MCP/Server.php delete mode 100644 src/MCP/Servers/WordPress/MediaManager.php delete mode 100644 src/MCP/Servers/WordPress/Tools/CommunityEvents.php delete mode 100644 src/MCP/Servers/WordPress/Tools/Dummy.php delete mode 100644 src/MCP/Servers/WordPress/Tools/ImageTools.php delete mode 100644 src/MCP/Servers/WordPress/Tools/RestApi.php delete mode 100644 src/MCP/Servers/WordPress/Tools/RouteInformation.php delete mode 100644 src/MCP/Servers/WordPress/WordPress.php delete mode 100644 src/MCP/Servers/WordPress/WpAiClient.php diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index f33b99c..6e56f3d 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -53,7 +53,7 @@ jobs: custom-cache-suffix: $(date -u "+%Y-%m") - name: Run Linter - run: vendor/bin/parallel-lint -j 10 . --show-deprecated --exclude vendor --exclude .git --checkstyle | cs2pr + run: vendor/bin/parallel-lint -j 10 . --show-deprecated --exclude vendor --exclude .git --exclude third-party --checkstyle | cs2pr phpcs: name: PHPCS diff --git a/.gitignore b/.gitignore index 11d4378..eb31bc2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ wp-cli.local.yml node_modules/ vendor/ +src/vendor/ +src/composer.json +third-party/ *.zip *.tar.gz *.swp @@ -11,4 +14,4 @@ composer.lock phpunit.xml phpcs.xml .phpcs.xml -.vscode/ \ No newline at end of file +.vscode/ diff --git a/README.md b/README.md index 7601cd3..706fc4a 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Tip: for better on support of the latest PHP versions, use the v2.12 nightly bui To install the latest development version of this package, use the following command instead: ```bash -wp package install swissspidy/ai-command:dev-main +wp package install mcp-wp/ai-command:dev-main ``` Right now, the plugin requires a WordPress site with the [AI Services plugin](https://wordpress.org/plugins/ai-services) installed. @@ -165,13 +165,13 @@ For a more thorough introduction, [check out WP-CLI's guide to contributing](htt Think you’ve found a bug? We’d love for you to help us get it fixed. -Before you create a new issue, you should [search existing issues](https://github.com/swissspidy/ai-command/issues?q=label%3Abug%20) to see if there’s an existing resolution to it, or if it’s already been fixed in a newer version. +Before you create a new issue, you should [search existing issues](https://github.com/mcp-wp/ai-command/issues?q=label%3Abug%20) to see if there’s an existing resolution to it, or if it’s already been fixed in a newer version. -Once you’ve done a bit of searching and discovered there isn’t an open or fixed issue for your bug, please [create a new issue](https://github.com/swissspidy/ai-command/issues/new). Include as much detail as you can, and clear steps to reproduce if possible. For more guidance, [review our bug report documentation](https://make.wordpress.org/cli/handbook/bug-reports/). +Once you’ve done a bit of searching and discovered there isn’t an open or fixed issue for your bug, please [create a new issue](https://github.com/mcp-wp/ai-command/issues/new). Include as much detail as you can, and clear steps to reproduce if possible. For more guidance, [review our bug report documentation](https://make.wordpress.org/cli/handbook/bug-reports/). ### Creating a pull request -Want to contribute a new feature? Please first [open a new issue](https://github.com/swissspidy/ai-command/issues/new) to discuss whether the feature is a good fit for the project. +Want to contribute a new feature? Please first [open a new issue](https://github.com/mcp-wp/ai-command/issues/new) to discuss whether the feature is a good fit for the project. Once you've decided to commit the time to seeing your pull request through, [please follow our guidelines for creating a pull request](https://make.wordpress.org/cli/handbook/pull-requests/) to make sure it's a pleasant experience. See "[Setting up](https://make.wordpress.org/cli/handbook/pull-requests/#setting-up)" for details specific to working on this package locally. diff --git a/ai-command.php b/ai-command.php index f1799fa..73f4c2e 100644 --- a/ai-command.php +++ b/ai-command.php @@ -1,6 +1,6 @@ ./third-party/composer.json", + "@composer dump-autoload --working-dir ./third-party --no-dev --classmap-authoritative", + "sed -i'.bak' -e 's/Composer\\\\/AiCommand_Composer\\\\/' third-party/vendor/composer/*.php && rm -rf third-party/vendor/composer/*.php.bak", + "echo '{ \"autoload\": { \"classmap\": [\"\"] } }' > ./src/composer.json", + "@composer dump-autoload --working-dir ./src --no-dev --classmap-authoritative", + "sed -i'.bak' -e 's/Composer\\\\/AiCommand_Composer\\\\/' src/vendor/composer/*.php && rm -rf src/vendor/composer/*.php.bak" + ] }, "support": { - "issues": "https://github.com/swissspidy/ai-command/issues" + "issues": "https://github.com/mcp-wp/ai-command/issues" } } diff --git a/docs/reference.md b/docs/reference.md index d6a8eb4..46f6f91 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -138,7 +138,7 @@ $server->register_resource( List resources ```PHP -$server = new WP_CLI\AiCommand\MCP\Server(); +$server = new McpWp\AiCommand\MCP\Server(); $resources = $server->list_resources(); echo json_encode( $resources, JSON_PRETTY_PRINT ); @@ -147,7 +147,7 @@ echo json_encode( $resources, JSON_PRETTY_PRINT ); Read resource ```PHP -$server = new WP_CLI\AiCommand\MCP\Server(); +$server = new McpWp\AiCommand\MCP\Server(); $resource_data = $server->read_resource( 'file://./products.json' ); echo json_encode( $resource_data, JSON_PRETTY_PRINT ); diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 0bf1fc3..f42ddfc 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -45,10 +45,12 @@ - + + + scoper.inc.php @@ -58,4 +60,9 @@ + + + */third-party/* + */vendor/* + */includes/vendor/* diff --git a/scoper.inc.php b/scoper.inc.php new file mode 100644 index 0000000..614a8a8 --- /dev/null +++ b/scoper.inc.php @@ -0,0 +1,17 @@ + 'McpWp\AiCommand_Dependencies', + 'finders' => [ + Finder::create() + ->files() + ->in( 'vendor/logiscape/mcp-sdk-php/src' ), + Finder::create() + ->files() + ->in( 'vendor/mcp-wp/mcp-server/src/MCP' ), + ], + 'exclude-namespaces' => [ 'Psr' ], + 'exclude-classes' => [ 'WP_Community_Events' ], +]; diff --git a/src/AI/AiClient.php b/src/AI/AiClient.php index 382896e..8d1bfe3 100644 --- a/src/AI/AiClient.php +++ b/src/AI/AiClient.php @@ -1,6 +1,6 @@ load_wordpress(); - } else { - // TODO: Implement. - \WP_CLI::error( 'Not implemented yet' ); } $sessions = $this->get_sessions( $with_wordpress ); @@ -131,13 +128,13 @@ class AiCommand extends WP_CLI_Command { $sessions = []; // The WP-CLI MCP server is always available. - $sessions[] = ( new MCP\Client( new CliLogger() ) )->connect( + $sessions[] = ( new Client( new CliLogger() ) )->connect( MCP\Servers\WP_CLI\WP_CLI::class ); if ( $with_wordpress ) { $sessions[] = ( new Client( new CliLogger() ) )->connect( - MCP\Servers\WordPress\WordPress::class + WordPress::class ); } diff --git a/src/MCP/Client.php b/src/MCP/Client.php index bd91db0..2875dc0 100644 --- a/src/MCP/Client.php +++ b/src/MCP/Client.php @@ -1,16 +1,14 @@ connect(); - $this->session = new InMemorySession( + $session = new InMemorySession( $read_stream, $write_stream, $this->logger ); - $this->session->initialize(); + $session->initialize(); - return $this->session; + return $session; } // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url @@ -82,17 +81,17 @@ class Client extends McpCLient { [$read_stream, $write_stream] = $transport->connect(); // Initialize the client session with the obtained streams - $this->session = new InMemorySession( + $session = new InMemorySession( $read_stream, $write_stream, $this->logger ); // Initialize the session (e.g., perform handshake if necessary) - $this->session->initialize(); + $session->initialize(); $this->logger->info( 'Session initialized successfully' ); - return $this->session; + return $session; } return parent::connect( $command_or_url, $args, $env, $read_timeout ); diff --git a/src/MCP/HttpTransport.php b/src/MCP/HttpTransport.php index 558a8a2..51cc475 100644 --- a/src/MCP/HttpTransport.php +++ b/src/MCP/HttpTransport.php @@ -1,46 +1,20 @@ - * - * Based on the Python SDK for the Model Context Protocol - * https://github.com/modelcontextprotocol/python-sdk - * - * PHP conversion developed by: - * - Josh Abbott - * - Claude 3.5 Sonnet (Anthropic AI model) - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - * - * @package logiscape/mcp-sdk-php - * @author Josh Abbott - * @copyright Logiscape LLC - * @license MIT License - * @link https://github.com/logiscape/mcp-sdk-php - * - * Filename: Client/Transport/SseTransport.php - */ - -declare(strict_types=1); - -namespace WP_CLI\AiCommand\MCP; +namespace McpWp\AiCommand\MCP; use Exception; use InvalidArgumentException; -use Mcp\Shared\MemoryStream; -use Mcp\Types\JsonRpcMessage; +use McpWp\AiCommand_Dependencies\Mcp\Shared\MemoryStream; +use McpWp\AiCommand_Dependencies\Mcp\Types\JsonRpcMessage; use Psr\Log\LoggerInterface; use RuntimeException; use Psr\Log\NullLogger; -use Mcp\Types\JSONRPCRequest; -use Mcp\Types\JSONRPCNotification; -use Mcp\Types\JSONRPCResponse; -use Mcp\Types\JSONRPCError; -use Mcp\Types\RequestId; -use Mcp\Types\JsonRpcErrorObject; +use McpWp\AiCommand_Dependencies\Mcp\Types\JSONRPCRequest; +use McpWp\AiCommand_Dependencies\Mcp\Types\JSONRPCNotification; +use McpWp\AiCommand_Dependencies\Mcp\Types\JSONRPCResponse; +use McpWp\AiCommand_Dependencies\Mcp\Types\JSONRPCError; +use McpWp\AiCommand_Dependencies\Mcp\Types\RequestId; +use McpWp\AiCommand_Dependencies\Mcp\Types\JsonRpcErrorObject; use WpOrg\Requests\Response; /** diff --git a/src/MCP/InMemorySession.php b/src/MCP/InMemorySession.php index b37fb55..abf22d7 100644 --- a/src/MCP/InMemorySession.php +++ b/src/MCP/InMemorySession.php @@ -1,18 +1,18 @@ - */ - private array $tools = []; - - /** - * @var Array - */ - private array $resources = []; - - /** - * @var Array - */ - private array $resource_templates = []; - - protected McpServer $mcp_server; - - protected LoggerInterface $logger; - - public function __construct( private readonly string $name, ?LoggerInterface $logger = null ) { - $this->logger = $logger ?? new NullLogger(); - - $this->mcp_server = new McpServer( $name, $this->logger ); - - $this->mcp_server->registerHandler( - 'initialize', - [ $this, 'initialize' ] - ); - - $this->mcp_server->registerHandler( - 'tools/list', - [ $this, 'list_tools' ] - ); - - $this->mcp_server->registerHandler( - 'tools/call', - [ $this, 'call_tool' ] - ); - - $this->mcp_server->registerHandler( - 'resources/list', - [ $this, 'list_resources' ] - ); - - $this->mcp_server->registerHandler( - 'resources/read', - [ $this, 'read_resources' ] - ); - - $this->mcp_server->registerHandler( - 'resources/templates/list', - [ $this, 'list_resource_templates' ] - ); - - $this->mcp_server->registerNotificationHandler( - 'notifications/initialized', - [ $this, 'do_nothing' ] - ); - } - - public function do_nothing(): void { - // Do nothing. - } - - public function register_tool( array $tool_definition ): void { - if ( ! isset( $tool_definition['name'] ) || ! is_callable( $tool_definition['callable'] ) ) { - throw new InvalidArgumentException( "Invalid tool definition. Must be an array with 'name' and 'callable'." ); - } - - $name = $tool_definition['name']; - $callable = $tool_definition['callable']; - $description = $tool_definition['description'] ?? null; - $input_schema = $tool_definition['inputSchema'] ?? null; - - $this->tools[ $name ] = [ - 'tool' => new Tool( - $name, - ToolInputSchema::fromArray( - $input_schema, - ), - $description - ), - 'callable' => $callable, - 'inputSchema' => $input_schema, - ]; - } - - public function initialize(): InitializeResult { - return new InitializeResult( - capabilities: $this->mcp_server->getCapabilities( new NotificationOptions(), [] ), - serverInfo: new Implementation( - $this->name, - '0.0.1', // TODO: Make dynamic. - ), - protocolVersion: Version::LATEST_PROTOCOL_VERSION - ); - } - - // TODO: Implement pagination, see https://spec.modelcontextprotocol.io/specification/2024-11-05/server/utilities/pagination/#response-format - // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found - public function list_tools( $params ): ListToolsResult { - $prepared_tools = []; - foreach ( $this->tools as $tool ) { - $prepared_tools[] = $tool['tool']; - } - - return new ListToolsResult( $prepared_tools ); - } - - public function call_tool( $params ): CallToolResult { - $found_tool = null; - foreach ( $this->tools as $name => $tool ) { - if ( $name === $params->name ) { - $found_tool = $tool; - break; - } - } - - if ( ! $found_tool ) { - throw new InvalidArgumentException( "Unknown tool: {$params->name}" ); - } - - $result = call_user_func( $found_tool['callable'], $params->arguments ); - - if ( $result instanceof CallToolResult ) { - return $result; - } - - if ( is_wp_error( $result ) ) { - return new CallToolResult( - [ - new TextContent( - $result->get_error_message() - ), - ], - true - ); - } - - if ( is_string( $result ) ) { - $result = [ new TextContent( $result ) ]; - } - - if ( ! is_array( $result ) ) { - $result = [ $result ]; - } - return new CallToolResult( $result ); - } - - // TODO: Implement pagination, see https://spec.modelcontextprotocol.io/specification/2024-11-05/server/utilities/pagination/#response-format - // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found - public function list_resources(): ListResourcesResult { - return new ListResourcesResult( $this->resources ); - } - - // TODO: Make dynamic. - public function read_resources( RequestParams $params ): ReadResourceResult { - $uri = $params->uri; - if ( 'example://greeting' !== $uri ) { - throw new InvalidArgumentException( "Unknown resource: {$uri}" ); - } - - return new ReadResourceResult( - [ - new TextResourceContents( - 'Hello from the example MCP server!', - $uri, - 'text/plain' - ), - ] - ); - } - - public function register_resource( Resource $res ): void { - $this->resources[ $res->name ] = $res; - } - - // TODO: Implement pagination, see https://spec.modelcontextprotocol.io/specification/2024-11-05/server/utilities/pagination/#response-format - // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found - public function list_resource_templates( $params ): ListResourceTemplatesResult { - return new ListResourceTemplatesResult( $this->resource_templates ); - } - - public function register_resource_template( ResourceTemplate $resource_template ): void { - $this->resource_templates[ $resource_template->name ] = $resource_template; - } - - /** - * Processes an incoming message from the client. - */ - public function handle_message( JsonRpcMessage $message ) { - $this->logger->debug( 'Received message: ' . json_encode( $message ) ); - - $inner_message = $message->message; - - try { - if ( $inner_message instanceof JSONRPCRequest ) { - // It's a request - return $this->process_request( $inner_message ); - } - - if ( $inner_message instanceof JSONRPCNotification ) { - // It's a notification - $this->process_notification( $inner_message ); - return null; - } - - // Server does not expect responses from client; ignore or log - $this->logger->warning( 'Received unexpected message type: ' . get_class( $inner_message ) ); - } catch ( McpError $e ) { - if ( $inner_message instanceof JSONRPCRequest ) { - return $this->send_error( $inner_message->id, $e->error ); - } - } catch ( \Exception $e ) { - $this->logger->error( 'Error handling message: ' . $e->getMessage() ); - if ( $inner_message instanceof JSONRPCRequest ) { - // Code -32603 is Internal error as per JSON-RPC spec - return $this->send_error( - $inner_message->id, - new ErrorData( - -32603, - $e->getMessage() - ) - ); - } - } - } - - /** - * Processes a JSONRPCRequest message. - */ - private function process_request( JSONRPCRequest $request ): JsonRpcMessage { - $method = $request->method; - $handlers = $this->mcp_server->getHandlers(); - $handler = $handlers[ $method ] ?? null; - - if ( null === $handler ) { - throw new McpError( - new ErrorData( - -32601, // Method not found - "Method not found: {$method}" - ) - ); - } - - $params = $request->params ?? null; - $result = $handler( $params ); - - if ( ! $result instanceof Result ) { - $result = new Result(); - } - - return $this->send_response( $request->id, $result ); - } - - /** - * Processes a JSONRPCNotification message. - */ - private function process_notification( JSONRPCNotification $notification ): void { - $method = $notification->method; - $handlers = $this->mcp_server->getNotificationHandlers(); - $handler = $handlers[ $method ] ?? null; - - if ( null !== $handler ) { - $params = $notification->params ?? null; - $handler( $params ); - } - - $this->logger->warning( "No handler registered for notification method: $method" ); - } - - /** - * Sends a response to a request. - * - * @param RequestId $id The request ID to respond to. - * @param Result $result The result object. - */ - private function send_response( RequestId $id, Result $result ): JsonRpcMessage { - // Create a JSONRPCResponse object and wrap in JsonRpcMessage - $response = new JSONRPCResponse( - '2.0', - $id, - $result - ); - $response->validate(); - - return new JsonRpcMessage( $response ); - } - - - /** - * Sends an error response to a request. - * - * @param RequestId $id The request ID to respond to. - * @param ErrorData $error The error data. - */ - private function send_error( RequestId $id, ErrorData $error ): JsonRpcMessage { - $error_object = new JsonRpcErrorObject( - $error->code, - $error->message, - $error->data ?? null - ); - - $response = new JSONRPCError( - '2.0', - $id, - $error_object - ); - $response->validate(); - - return new JsonRpcMessage( $response ); - } -} diff --git a/src/MCP/Servers/WP_CLI/Tools/CliCommands.php b/src/MCP/Servers/WP_CLI/Tools/CliCommands.php index e0ac3aa..f1939dd 100644 --- a/src/MCP/Servers/WP_CLI/Tools/CliCommands.php +++ b/src/MCP/Servers/WP_CLI/Tools/CliCommands.php @@ -1,6 +1,6 @@ $wp_filetype['type'], - 'post_title' => sanitize_file_name( $file_name ), - 'post_content' => '', - 'post_status' => 'inherit', - ); - - // Insert the attachment - $attach_id = wp_insert_attachment( $attachment, $new_file_path ); - - // Generate attachment metadata - require_once ABSPATH . 'wp-admin/includes/image.php'; - $attach_data = wp_generate_attachment_metadata( $attach_id, $new_file_path ); - wp_update_attachment_metadata( $attach_id, $attach_data ); - - return $attach_id; - } -} diff --git a/src/MCP/Servers/WordPress/Tools/CommunityEvents.php b/src/MCP/Servers/WordPress/Tools/CommunityEvents.php deleted file mode 100644 index b1be844..0000000 --- a/src/MCP/Servers/WordPress/Tools/CommunityEvents.php +++ /dev/null @@ -1,56 +0,0 @@ - 'fetch_wp_community_events', - 'description' => 'Fetches upcoming WordPress community events near a specified city or the user\'s current location. If no events are found in the exact location, nearby events within a specific radius will be considered.', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'location' => [ - 'type' => 'string', - 'description' => 'City name or "near me" for auto-detected location. If no events are found in the exact location, the tool will also consider nearby events within a specified radius (default: 100 km).', - ], - ], - 'required' => [ 'location' ], // We only require the location - ], - 'callable' => static function ( $params ) { - $location_input = strtolower( trim( $params['location'] ) ); - - // Manually include the WP_Community_Events class if it's not loaded - if ( ! class_exists( 'WP_Community_Events' ) ) { - require_once ABSPATH . 'wp-admin/includes/class-wp-community-events.php'; - } - - $location = [ - 'description' => $location_input, - ]; - - $events_instance = new WP_Community_Events( 0, $location ); - - // Get events from WP_Community_Events - $events = $events_instance->get_events( $location_input ); - - // Check for WP_Error - if ( is_wp_error( $events ) ) { - return $events; - } - - return new TextContent( - json_encode( $events['events'] ) - ); - }, - ]; - - return $tools; - } -} diff --git a/src/MCP/Servers/WordPress/Tools/Dummy.php b/src/MCP/Servers/WordPress/Tools/Dummy.php deleted file mode 100644 index 60e88ba..0000000 --- a/src/MCP/Servers/WordPress/Tools/Dummy.php +++ /dev/null @@ -1,36 +0,0 @@ - 'greet-user', - 'description' => 'Greet a given user by their name', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'name' => [ - 'type' => 'string', - 'description' => 'Name', - ], - ], - 'required' => [ 'name' ], - ], - 'callable' => static function ( $arguments ) { - $name = $arguments['name']; - - return new TextContent( - "Hello my friend, $name" - ); - }, - ]; - - return $tools; - } -} diff --git a/src/MCP/Servers/WordPress/Tools/ImageTools.php b/src/MCP/Servers/WordPress/Tools/ImageTools.php deleted file mode 100644 index f2c0a95..0000000 --- a/src/MCP/Servers/WordPress/Tools/ImageTools.php +++ /dev/null @@ -1,67 +0,0 @@ - 'generate_image', - 'description' => 'Generates an image.', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'prompt' => [ - 'type' => 'string', - 'description' => 'The prompt for generating the image.', - ], - ], - 'required' => [ 'prompt' ], - ], - 'callable' => function ( $params ) { - $client = new WpAiClient(); - - return new TextContent( - $client->get_image_from_ai_service( $params['prompt'] ) - ); - }, - ]; - - $tools[] = [ - 'name' => 'modify_image', - 'description' => 'Modifies an image with a given image id and prompt.', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'prompt' => [ - 'type' => 'string', - 'description' => 'The prompt for generating the image.', - ], - 'media_id' => [ - 'type' => 'string', - 'description' => 'the id of the media element', - ], - ], - 'required' => [ 'prompt', 'media_id' ], - ], - 'callable' => function ( $params ) { - $media_element = [ - 'filepath' => get_attached_file( $params['media_id'] ), - 'mime_type' => get_post_mime_type( $params['media_id'] ), - ]; - - $client = new WpAiClient(); - - return new TextContent( - $client->modify_image_with_ai( $params['prompt'], $media_element ) - ); - }, - ]; - - return $tools; - } -} diff --git a/src/MCP/Servers/WordPress/Tools/RestApi.php b/src/MCP/Servers/WordPress/Tools/RestApi.php deleted file mode 100644 index 7eeccbb..0000000 --- a/src/MCP/Servers/WordPress/Tools/RestApi.php +++ /dev/null @@ -1,187 +0,0 @@ - $arg ) { - $description = $arg['description'] ?? $title; - $type = $this->sanitize_type( $arg['type'] ?? 'string' ); - - $schema[ $title ] = [ - 'type' => $type, - 'description' => $description, - ]; - if ( isset( $arg['required'] ) && $arg['required'] ) { - $required[] = $title; - } - } - - return [ - 'type' => 'object', - 'properties' => $schema, - 'required' => $required, - ]; - } - - protected function sanitize_type( $type ) { - - $mapping = array( - 'string' => 'string', - 'integer' => 'integer', - 'number' => 'integer', - 'boolean' => 'boolean', - ); - - // Validated types: - if ( ! \is_array( $type ) && isset( $mapping[ $type ] ) ) { - return $mapping[ $type ]; - } - - if ( 'array' === $type || 'object' === $type ) { - return 'string'; // TODO, better solution. - } - if ( empty( $type ) || 'null' === $type ) { - return 'string'; - } - - if ( ! \is_array( $type ) ) { - throw new \Exception( 'Invalid type: ' . $type ); - } - - // Find valid values in array. - if ( \in_array( 'string', $type, true ) ) { - return 'string'; - } - if ( \in_array( 'integer', $type, true ) ) { - return 'integer'; - } - // TODO, better types handling. - return 'string'; - } - - public function get_tools(): array { - $server = rest_get_server(); - $routes = $server->get_routes(); - $tools = []; - - foreach ( $routes as $route => $endpoints ) { - foreach ( $endpoints as $endpoint ) { - foreach ( $endpoint['methods'] as $method_name => $enabled ) { - $information = new RouteInformation( - $route, - $method_name, - $endpoint['callback'], - ); - - if ( ! $information->is_wp_rest_controller() ) { - continue; - } - - $tool = [ - 'name' => $information->get_sanitized_route_name(), - 'description' => $this->generate_description( $information ), - 'inputSchema' => $this->args_to_schema( $endpoint['args'] ), - 'callable' => function ( $inputs ) use ( $route, $method_name, $server ) { - return json_encode( $this->rest_callable( $inputs, $route, $method_name, $server ) ); - }, - ]; - - $tools[] = $tool; - } - } - } - - return $tools; - } - - /** - * Create description based on route and method. - * - * Get a list of posts GET /wp/v2/posts - * Get post with id GET /wp/v2/posts/(?P[\d]+) - */ - protected function generate_description( RouteInformation $information ): string { - - $verb = match ( $information->get_method() ) { - 'GET' => 'Get', - 'POST' => 'Create', - 'PUT', 'PATCH' => 'Update', - 'DELETE' => 'Delete', - }; - - $schema = $information->get_wp_rest_controller()->get_public_item_schema(); - $title = $schema['title']; - - $determiner = $information->is_singular() - ? 'a' - : 'list of'; - - return $verb . ' ' . $determiner . ' ' . $title; - } - - protected function rest_callable( $inputs, $route, $method_name, \WP_REST_Server $server ): array { - preg_match_all( '/\(?P<(\w+)>/', $route, $matches ); - - foreach ( $matches[1] as $match ) { - if ( array_key_exists( $match, $inputs ) ) { - $route = preg_replace( '/(\(\?P<' . $match . '>.*?\))/', $inputs[ $match ], $route, 1 ); - } - } - - $this->logger->debug( 'Rest Route: ' . $route . ' ' . $method_name ); - - if ( isset( $inputs['meta'] ) ) { - if ( false === $inputs['meta'] || '' === $inputs['meta'] || [] === $inputs['meta'] ) { - unset( $inputs['meta'] ); - } - } - - foreach ( $inputs as $key => $value ) { - $this->logger->debug( ' param->' . $key . ' : ' . $value ); - } - - $request = new WP_REST_Request( $method_name, $route ); - $request->set_body_params( $inputs ); - - /** - * @var WP_REST_Response $response - */ - $response = $server->dispatch( $request ); - - $data = $server->response_to_data( $response, false ); - - // Quick fix to reduce amount of data that is returned. - // TODO: Improve - unset( $data['_links'], $data[0]['_links'] ); - - if ( isset( $data[0]['slug'] ) ) { - $debug_data = 'Result List: '; - foreach ( $data as $item ) { - $debug_data .= $item['id'] . '=>' . $item['slug'] . ', '; - } - } elseif ( isset( $data['slug'] ) ) { - $debug_data = 'Result: ' . $data['id'] . ' ' . $data['slug']; - } else { - $debug_data = 'Unknown format'; - } - - $this->logger->debug( $debug_data ); - - return $data; - } -} diff --git a/src/MCP/Servers/WordPress/Tools/RouteInformation.php b/src/MCP/Servers/WordPress/Tools/RouteInformation.php deleted file mode 100644 index 2aaffd0..0000000 --- a/src/MCP/Servers/WordPress/Tools/RouteInformation.php +++ /dev/null @@ -1,107 +0,0 @@ -route; - - preg_match_all( '/\(?P<(\w+)>/', $this->route, $matches ); - - foreach ( $matches[1] as $match ) { - $route = preg_replace( '/(\(\?P<' . $match . '>.*\))/', 'p_' . $match, $route, 1 ); - } - - return $this->method . '_' . sanitize_title( $route ); - } - - public function get_route(): string { - return $this->route; - } - - public function get_method(): string { - return $this->method; - } - - public function is_create(): bool { - return 'POST' === $this->method; - } - - public function is_update(): bool { - return 'PUT' === $this->method || 'PATCH' === $this->method; - } - - public function is_delete(): bool { - return 'DELETE' === $this->method; - } - - public function is_get(): bool { - return 'GET' === $this->method; - } - - public function is_singular(): bool { - // Always true - if ( str_ends_with( $this->route, '(?P[\d]+)' ) ) { - return true; - } - - // Never true - if ( ! str_contains( $this->route, '?P' ) ) { - return false; - } - - return false; - } - - public function is_wp_rest_controller(): bool { - // The callback form for a WP_REST_Controller is [ WP_REST_Controller, method ] - if ( ! is_array( $this->callback ) ) { - return false; - } - - $allowed = [ - WP_REST_Posts_Controller::class, - WP_REST_Users_Controller::class, - WP_REST_Taxonomies_Controller::class, - ]; - - /** - * Filters the list of supported REST API controllers in the WordPress MCP server. - * - * @param array $allowed List of REST API controller class names. - */ - $allowed = apply_filters( 'ai_command_wordpress_allowed_rest_controllers', $allowed ); - - foreach ( $allowed as $controller ) { - if ( $this->callback[0] instanceof $controller ) { - return true; - } - } - - return false; - } - - public function get_wp_rest_controller(): WP_REST_Controller { - if ( ! $this->is_wp_rest_controller() ) { - throw new BadMethodCallException( 'The callback needs to be a WP_Rest_Controller' ); - } - - return $this->callback[0]; - } -} diff --git a/src/MCP/Servers/WordPress/WordPress.php b/src/MCP/Servers/WordPress/WordPress.php deleted file mode 100644 index 606bae3..0000000 --- a/src/MCP/Servers/WordPress/WordPress.php +++ /dev/null @@ -1,62 +0,0 @@ -logger ) )->get_tools(), - ...( new CommunityEvents() )->get_tools(), - ...( new Dummy() )->get_tools(), - ...( new ImageTools() )->get_tools(), - ]; - - /** - * Filters all the tools exposed by the WordPress MCP server. - * - * @param array $all_tools MCP tools. - */ - $all_tools = apply_filters( 'ai_command_wordpress_tools', $all_tools ); - - foreach ( $all_tools as $tool ) { - $this->register_tool( $tool ); - } - - /** - * Fires after tools have been registered in the WordPress MCP server. - * - * Can be used to register additional tools. - * - * @param Server $server WordPress MCP server instance. - */ - do_action( 'ai_command_wordpress_tools_loaded', $this ); - - $this->register_resource( - new Resource( - 'Greeting Text', - 'example://greeting', - 'A simple greeting message', - 'text/plain' - ) - ); - - $this->register_resource_template( - new ResourceTemplate( - 'Attachment', - 'media://{id}', - 'WordPress attachment', - 'application/octet-stream' - ) - ); - } -} diff --git a/src/MCP/Servers/WordPress/WpAiClient.php b/src/MCP/Servers/WordPress/WpAiClient.php deleted file mode 100644 index f0961ba..0000000 --- a/src/MCP/Servers/WordPress/WpAiClient.php +++ /dev/null @@ -1,194 +0,0 @@ -get_available_service( - [ - 'capabilities' => [ - AI_Capability::IMAGE_GENERATION, - ], - ] - ); - $candidates = $service - ->get_model( - [ - 'feature' => 'image-generation', - 'capabilities' => [ - AI_Capability::IMAGE_GENERATION, - ], - ] - ) - ->generate_image( $prompt ); - - $image_id = null; - foreach ( $candidates->get( 0 )->get_content()->get_parts() as $part ) { - if ( $part instanceof Inline_Data_Part ) { - $image_url = $part->get_base64_data(); // Data URL. - $image_blob = Helpers::base64_data_url_to_blob( $image_url ); - - if ( $image_blob ) { - $filename = tempnam( '/tmp', 'ai-generated-image' ); - $parts = explode( '/', $part->get_mime_type() ); - $extension = $parts[1]; - rename( $filename, $filename . '.' . $extension ); - $filename .= '.' . $extension; - - file_put_contents( $filename, $image_blob->get_binary_data() ); - - $image_url = $filename; - $image_id = MediaManager::upload_to_media_library( $image_url ); - } - - break; - } - - if ( $part instanceof File_Data_Part ) { - $image_url = $part->get_file_uri(); // Actual URL. May have limited TTL (often 1 hour). - // TODO: Save as file or so. - break; - } - } - - return $image_id ?: 'no image found'; - } - - // Temporary workaround until AI Services plugin supports it. - public function modify_image_with_ai( $prompt, $media_element ): bool { - - $mime_type = $media_element['mime_type']; - $image_path = $media_element['filepath']; - $image_contents = file_get_contents( $image_path ); - - // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode - $base64_image = base64_encode( $image_contents ); - - // API Configuration - $api_key = get_option( 'ais_google_api_key' ); - - if ( ! $api_key ) { - throw new RuntimeException( 'Gemini API Key is not available' ); - } - $model = 'gemini-2.0-flash-exp'; - $api_url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$api_key}"; - - // Prepare request payload - $payload = [ - 'contents' => [ - [ - 'role' => 'user', - 'parts' => [ - [ - 'text' => $prompt, - ], - [ - 'inline_data' => [ - 'mime_type' => $mime_type, - 'data' => $base64_image, - ], - ], - ], - ], - ], - 'generationConfig' => [ - 'responseModalities' => [ 'TEXT', 'IMAGE' ], - ], - ]; - - // Convert payload to JSON - $json_payload = json_encode( $payload ); - - // Set up cURL request - $ch = curl_init( $api_url ); - curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); - curl_setopt( $ch, CURLOPT_POST, true ); - curl_setopt( $ch, CURLOPT_POSTFIELDS, $json_payload ); - curl_setopt( - $ch, - CURLOPT_HTTPHEADER, - [ - 'Content-Type: application/json', - 'Content-Length: ' . strlen( $json_payload ), - ] - ); - - // Execute request - $response = curl_exec( $ch ); - $error = curl_error( $ch ); - $status_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE ); - curl_close( $ch ); - - // Handle errors - if ( $error ) { - throw new RuntimeException( 'cURL Error: ' . $error ); - } - - if ( $status_code >= 400 ) { - throw new RuntimeException( "API Error (Status $status_code): " . $response ); - } - - // Process response - $response_data = json_decode( $response, true ); - - // Check for valid response - if ( empty( $response_data ) || ! isset( $response_data['candidates'][0]['content']['parts'] ) ) { - throw new RuntimeException( 'Invalid API response format' ); - } - - // Extract image data from response - $image_data = null; - foreach ( $response_data['candidates'][0]['content']['parts'] as $part ) { - if ( isset( $part['inlineData'] ) ) { - $image_data = $part['inlineData']['data']; - $response_mime_type = $part['inlineData']['mimeType']; - break; - } - } - - if ( ! $image_data ) { - throw new RuntimeException( 'No image data in response' ); - } - - // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode - $binary_data = base64_decode( $image_data ); - if ( false === $binary_data ) { - throw new RuntimeException( 'Failed to decode image data' ); - } - - // Create temporary file for the image - $extension = explode( '/', $response_mime_type )[1] ?? 'jpg'; - $filename = tempnam( '/tmp', 'ai-generated-image' ); - rename( $filename, $filename . '.' . $extension ); - $filename .= '.' . $extension; - - // Save image to the file - if ( ! file_put_contents( $filename, $binary_data ) ) { - throw new RuntimeException( 'Failed to save image to temporary file' ); - } - - // Upload to media library - $image_id = MediaManager::upload_to_media_library( $filename ); - - if ( $image_id ) { - return $image_id; - } - - return false; - } -} diff --git a/src/McpServerCommand.php b/src/McpServerCommand.php index d33f34d..3c512c4 100644 --- a/src/McpServerCommand.php +++ b/src/McpServerCommand.php @@ -1,8 +1,8 @@