diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml new file mode 100644 index 0000000..f33b99c --- /dev/null +++ b/.github/workflows/code-quality.yml @@ -0,0 +1,83 @@ +name: Code Quality Checks + +on: + pull_request: + branches: + - main + - master + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + actionlint: + name: Lint GitHub Actions workflows + runs-on: ubuntu-latest + steps: + - name: Check out source code + uses: actions/checkout@v4 + + - name: Add problem matcher + run: | + curl -s -o .github/actionlint-matcher.json https://raw.githubusercontent.com/rhysd/actionlint/main/.github/actionlint-matcher.json + echo "::add-matcher::.github/actionlint-matcher.json" + + - name: Check workflow files + uses: docker://rhysd/actionlint:latest + with: + args: -color -shellcheck= + + lint: + name: Lint PHP files + runs-on: ubuntu-latest + steps: + - name: Check out source code + uses: actions/checkout@v4 + + - name: Set up PHP environment + uses: shivammathur/setup-php@v2 + with: + php-version: 'latest' + ini-values: zend.assertions=1, error_reporting=-1, display_errors=On + tools: cs2pr + env: + COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Install Composer dependencies & cache dependencies + uses: "ramsey/composer-install@v3" + env: + COMPOSER_ROOT_VERSION: dev-${{ github.event.repository.default_branch }} + with: + # Bust the cache at least once a month - output format: YYYY-MM. + 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 + + phpcs: + name: PHPCS + runs-on: ubuntu-latest + + steps: + - name: Check out source code + uses: actions/checkout@v4 + + - name: Set up PHP environment + uses: shivammathur/setup-php@v2 + with: + php-version: 'latest' + tools: cs2pr + env: + COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Install Composer dependencies & cache dependencies + uses: "ramsey/composer-install@v3" + env: + COMPOSER_ROOT_VERSION: dev-${{ github.event.repository.default_branch }} + with: + # Bust the cache at least once a month - output format: YYYY-MM. + custom-cache-suffix: $(date -u "+%Y-%m") + + - name: Run PHPCS + run: vendor/bin/phpcs -q --report=checkstyle | cs2pr --graceful-warnings diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 7a55b08..32add46 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -6,7 +6,6 @@ on: - main - master workflow_dispatch: - workflow_call: concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -50,7 +49,7 @@ jobs: WP_CLI_TEST_DBUSER: wp_cli_test WP_CLI_TEST_DBPASS: password1 WP_CLI_TEST_DBNAME: wp_cli_test - WP_CLI_TEST_DBHOST: 127.0.0.1:${{ job.services.mysql.ports[3306] }} + WP_CLI_TEST_DBHOST: 127.0.0.1:${{ job.services.mysql.ports['3306'] }} unit: #----------------------------------------------------------------------- name: Unit test / PHP ${{ matrix.php }} diff --git a/README.md b/README.md index cce1840..9fecf6d 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,121 @@ To install the latest development version of this package, use the following com wp package install swissspidy/ai-command:dev-main ``` + +## Using + +This package implements the following commands: + +### wp ai + +AI prompt. + +~~~ +wp ai [--skip-wordpress] +~~~ + +**OPTIONS** + + + AI prompt. + + [--skip-wordpress] + Run command without loading WordPress. (Not implemented yet) + +**EXAMPLES** + + # Get data from WordPress + $ wp ai "What are the titles of my last three posts?" + - Hello world + - My awesome post + - Another post + + # Interact with multiple MCP servers. + $ wp ai "Take file foo.txt and create a new blog post from it" + Success: Blog post created. + + + +### wp mcp server list + +Lists available MCP servers. + +~~~ +wp mcp server list +~~~ + +**OPTIONS** + +[--format=] +: Render output in a particular format. +--- +default: table +options: +- table +- csv +- json +- count + +**EXAMPLES** + + # Greet the world. + $ wp mcp server list + Success: Hello World! + + # Greet the world. + $ wp ai "create 10 test posts about swiss recipes and include generated featured images" + Success: Hello World! + + + +### wp mcp server add + +Add a new MCP server to the list + +~~~ +wp mcp server add +~~~ + +**OPTIONS** + + + Name for referencing the server later + + + Server command or URL. + +**EXAMPLES** + + # Add server from URL. + $ wp mcp server add "server-github" "https://github.com/mcp" + Success: Server added. + + # Add server with command to execute + $ wp mcp server add "server-filesystem" "npx -y @modelcontextprotocol/server-filesystem /my/allowed/folder/" + Success: Server added. + + + +### wp mcp server remove + +Remove a new MCP server from the list + +~~~ +wp mcp server remove +~~~ + +**OPTIONS** + + + Name of the server to remove + +**EXAMPLES** + + # Remove server. + $ wp mcp server remove "server-filesystem" + Success: Server removed. + + ## Contributing We appreciate you taking the initiative to contribute to this project. diff --git a/ai-command.php b/ai-command.php index fc2f8c7..f1799fa 100644 --- a/ai-command.php +++ b/ai-command.php @@ -2,13 +2,6 @@ namespace WP_CLI\AiCommand; -use WP_CLI\AiCommand\ToolRepository\CollectionToolRepository; -use WP_CLI\AiCommand\Tools\ImageTools; -use WP_CLI\AiCommand\Tools\MiscTools; -use WP_CLI\AiCommand\Tools\URLTools; -use WP_CLI\AiCommand\Tools\CommunityEvents; -use WP_CLI\AiCommand\Tools\MapRESTtoMCP; -use WP_CLI\AiCommand\Tools\MapCLItoMCP; use WP_CLI; if ( ! class_exists( '\WP_CLI' ) ) { @@ -21,30 +14,6 @@ require_once $ai_command_autoloader; } -WP_CLI::add_command( 'ai', static function ( $args, $assoc_args ) { - $server = new MCP\Server(); - $client = new MCP\Client($server); - - $tools = new ToolCollection(); - - $all_tools = [ - ...(new ImageTools($client, $server))->get_tools(), - ...(new CommunityEvents($client))->get_tools(), - ...(new MiscTools($server))->get_tools(), - ...(new URLTools($server))->get_tools(), - ...(new MapRESTtoMCP())->map_rest_to_mcp(), - ...(new MapCLItoMCP())->map_cli_to_mcp(), - - ]; - - foreach ($all_tools as $tool) { - $tools->add($tool); - } - - $ai_command = new AiCommand( - new CollectionToolRepository( $tools ), - $server, - $client - ); - $ai_command( $args, $assoc_args ); -} ); +WP_CLI::add_command( 'ai', AiCommand::class ); +WP_CLI::add_command( 'mcp prompt', AiCommand::class ); +WP_CLI::add_command( 'mcp server', McpServerCommand::class ); diff --git a/composer.json b/composer.json index ac98c4f..4db1be0 100644 --- a/composer.json +++ b/composer.json @@ -7,6 +7,7 @@ "authors": [], "require": { "php": "^8.2", + "logiscape/mcp-sdk-php": "^1.0", "wp-cli/wp-cli": "^2.11" }, "require-dev": { @@ -28,7 +29,9 @@ "bundled": false, "commands": [ "ai", - "ai prompt" + "mcp server list", + "mcp server add", + "mcp server remove" ] }, "autoload": { diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 215800a..0bf1fc3 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -51,4 +51,11 @@ + + + + + + + diff --git a/src/AI/AiClient.php b/src/AI/AiClient.php new file mode 100644 index 0000000..734fb88 --- /dev/null +++ b/src/AI/AiClient.php @@ -0,0 +1,142 @@ +tool_callback = $tool_callback; + } + + public function call_ai_service_with_prompt( string $prompt ) { + $parts = new Parts(); + $parts->add_text_part( $prompt ); + $content = new Content( Content_Role::USER, $parts ); + + $this->call_ai_service( [ $content ] ); + } + + private function call_ai_service( $contents ) { + // See https://github.com/felixarntz/ai-services/issues/25. + add_filter( + 'map_meta_cap', + static function () { + return [ 'exist' ]; + } + ); + + $new_contents = $contents; + + $tools = new Tools(); + $tools->add_function_declarations_tool( $this->tools ); + + try { + $service = ai_services()->get_available_service( + [ + 'capabilities' => [ + AI_Capability::MULTIMODAL_INPUT, + AI_Capability::TEXT_GENERATION, + AI_Capability::FUNCTION_CALLING, + ], + ] + ); + + if ( $service->get_service_slug() === 'openai' ) { + $model = 'gpt-4o'; + } else { + $model = 'gemini-2.0-flash'; + } + + $candidates = $service + ->get_model( + [ + 'feature' => 'text-generation', + 'model' => $model, + 'tools' => $tools, + 'capabilities' => [ + AI_Capability::MULTIMODAL_INPUT, + AI_Capability::TEXT_GENERATION, + AI_Capability::FUNCTION_CALLING, + ], + ], + [ + 'options' => [ + 'timeout' => 6000, + ], + ] + ) + ->generate_text( $contents ); + + $text = ''; + foreach ( $candidates->get( 0 )->get_content()->get_parts() as $part ) { + if ( $part instanceof Text_Part ) { + if ( '' !== $text ) { + $text .= "\n\n"; + } + $text .= $part->get_text(); + } elseif ( $part instanceof Function_Call_Part ) { + $function_name = $part->get_name(); + + echo "Output generated with the '$function_name' tool:\n"; + + // Need to repeat the function call part. + $parts = new Parts(); + $parts->add_function_call_part( $part->get_id(), $part->get_name(), $part->get_args() ); + $new_contents[] = new Content( Content_Role::MODEL, $parts ); + + $function_result = call_user_func( + $this->tool_callback, + $part->get_name(), + $part->get_args() + ); + + // Debugging. + // TODO: Need to figure out correct format so LLM picks it up. + $function_result = [ + 'name' => $part->get_name(), + 'content' => $function_result['text'], + ]; + + $parts = new Parts(); + $parts->add_function_response_part( $part->get_id(), $part->get_name(), $function_result ); + $content = new Content( Content_Role::USER, $parts ); + $new_contents[] = $content; + } + } + + if ( $new_contents !== $contents ) { + $this->call_ai_service( $new_contents ); + return; + } + + // Keep the session open to continue chatting. + + WP_CLI::line( $text ); + + $response = \cli\prompt( '', false, '' ); + + $parts = new Parts(); + $parts->add_text_part( $response ); + $content = new Content( Content_Role::USER, $parts ); + $new_contents[] = $content; + $this->call_ai_service( $new_contents ); + return; + } catch ( Exception $e ) { + WP_CLI::error( $e->getMessage() ); + } + } +} diff --git a/src/AiCommand.php b/src/AiCommand.php index 8ae9dd0..9968948 100644 --- a/src/AiCommand.php +++ b/src/AiCommand.php @@ -2,183 +2,168 @@ namespace WP_CLI\AiCommand; -use WP_CLI\AiCommand\ToolRepository\CollectionToolRepository; -use WP_CLI\AiCommand\Tools\FileTools; -use WP_CLI\AiCommand\Tools\URLTools; -use WP_CLI; +use Mcp\Client\Client; +use Mcp\Client\ClientSession; +use Mcp\Client\Transport\StdioServerParameters; +use WP_CLI\AiCommand\AI\AiClient; +use WP_CLI\AiCommand\Utils\CliLogger; +use WP_CLI\AiCommand\Utils\McpConfig; +use WP_CLI\Utils; use WP_CLI_Command; -use WP_Community_Events; -use WP_Error; /** + * AI command class. * - * Resources: File-like data that can be read by clients (like API responses or file contents) - * Tools: Functions that can be called by the LLM (with user approval) - * Prompts: Pre-written templates that help users accomplish specific tasks - * - * MCP follows a client-server architecture where: - * - * Hosts are LLM applications (like Claude Desktop or IDEs) that initiate connections - * Clients maintain 1:1 connections with servers, inside the host application - * Servers provide context, tools, and prompts to clients + * Allows interacting with an LLM using MCP. */ class AiCommand extends WP_CLI_Command { - public function __construct( - private CollectionToolRepository $tools, - private WP_CLI\AiCommand\MCP\Server $server, - private WP_CLI\AiCommand\MCP\Client $client - ) { - parent::__construct(); - } - /** - * Greets the world. + * AI prompt. * * ## OPTIONS * - * - * : AI prompt. + * + * : AI prompt. + * + * [--skip-wordpress] + * : Run command without loading WordPress. (Not implemented yet) * * ## EXAMPLES * - * # Greet the world. + * # Get data from WordPress * $ wp ai "What are the titles of my last three posts?" - * Success: Hello World! + * - Hello world + * - My awesome post + * - Another post * - * # Greet the world. - * $ wp ai "create 10 test posts about swiss recipes and include generated featured images" - * Success: Hello World! + * # Interact with multiple MCP servers. + * $ wp ai "Take file foo.txt and create a new blog post from it" + * Success: Blog post created. * - * @when after_wp_load + * @when before_wp_load * * @param array $args Indexed array of positional arguments. * @param array $assoc_args Associative array of associative arguments. */ public function __invoke( $args, $assoc_args ) { - $this->register_tools($this->server); - $this->register_resources($this->server); - - $prompt = ''; - if ( isset( $args[0] ) ) { - $prompt = $args[0]; + $with_wordpress = null === Utils\get_flag_value( $assoc_args, 'skip-wordpress' ); + if ( $with_wordpress ) { + \WP_CLI::get_runner()->load_wordpress(); + } else { + // TODO: Implement. + \WP_CLI::error( 'Not implemented yet' ); } - $result = $this->client->call_ai_service_with_prompt( $prompt ); - - WP_CLI::success( $result ); - } - - // Register tools for AI processing - private function register_tools($server) : void { - // TODO; Is this the correct place? Or should the server already have the tools registered? - $filters = apply_filters( 'wp_cli/ai_command/command/filters', [] ); - $tools = $this->tools->find_all( $filters ); + $sessions = $this->get_sessions( $with_wordpress ); + $tools = $this->get_tools( $sessions ); + + $ai_client = new AiClient( + $tools, + static function ( $tool_name, $tool_args ) use ( $sessions ) { + // Find the right tool from the right server. + foreach ( $sessions as $session ) { + foreach ( $session->listTools()->tools as $mcp_tool ) { + if ( $tool_name === $mcp_tool->name ) { + $result = $session->callTool( $tool_name, $tool_args ); + // TODO: Convert ImageContent or EmbeddedResource into Blob? + + // To trigger the jsonSerialize() methods. + // TODO: Return all array items, not just first one. + return json_decode( json_encode( $result->content[0] ), true ); + } + } + } - foreach( $tools as $tool ) { - $server->register_tool( $tool->get_data() ); - } + return null; + } + ); - $this->register_media_resources($server); + $ai_client->call_ai_service_with_prompt( $args[0] ); } /** - * Register resources for AI access + * Returns a combined list of all tools for all existing MCP client sessions. * - * TODO remove this function. - * A) it does not belong here - * B) it is not used* + * @param array $sessions List of available sessions. + * @return array List of tools. */ - private function register_resources( $server ) { - // Register Users resource - $server->register_resource( - [ - 'name' => 'users', - 'uri' => 'data://users', - 'description' => 'List of users', - 'mimeType' => 'application/json', - 'dataKey' => 'users', // Data will be fetched from 'users' - ] - ); + protected function get_tools( array $sessions ): array { + $function_declarations = []; + + foreach ( $sessions as $session ) { + foreach ( $session->listTools()->tools as $mcp_tool ) { + $parameters = json_decode( json_encode( $mcp_tool->inputSchema->jsonSerialize() ), true ); + unset( $parameters['additionalProperties'], $parameters['$schema'] ); + + // Not having any properties doesn't seem to work. + if ( empty( $parameters['properties'] ) ) { + $parameters['properties'] = [ + 'dummy' => [ + 'type' => 'string', + ], + ]; + } + + // FIXME: had some issues with the inputSchema here. + if ( 'edit_file' === $mcp_tool->name || 'search_files' === $mcp_tool->name ) { + continue; + } + + $function_declarations[] = [ + 'name' => $mcp_tool->name, + 'description' => $mcp_tool->description, + 'parameters' => $parameters, + ]; + } + } - // Register Product Catalog resource - $server->register_resource( - [ - 'name' => 'product_catalog', - 'uri' => 'file://./products.json', - 'description' => 'Product catalog', - 'mimeType' => 'application/json', - 'filePath' => './products.json', // Data will be fetched from products.json + return $function_declarations; } /** - * TODO Move Probably don't want this in the command class. + * Returns a list of MCP client sessions for each MCP server that is configured. + * + * @param bool $with_wordpress Whether a session for the built-in WordPress MCP server should be created. + * @return ClientSession[] */ - protected function register_media_resources( $server ) { + public function get_sessions( bool $with_wordpress ): array { + $sessions = []; - $args = array( - 'post_type' => 'attachment', - 'post_status' => 'inherit', - 'posts_per_page' => - 1, + // The WP-CLI MCP server is always available. + $sessions[] = ( new MCP\Client( new CliLogger() ) )->connect( + MCP\Servers\WP_CLI\WP_CLI::class ); - $media_items = get_posts( $args ); - - foreach ( $media_items as $media ) { - - $media_id = $media->ID; - $media_url = wp_get_attachment_url( $media_id ); - $media_type = get_post_mime_type( $media_id ); - $media_title = get_the_title( $media_id ); - - $server->register_resource( - [ - 'name' => 'media_' . $media_id, - 'uri' => 'media://' . $media_id, - 'description' => $media_title, - 'mimeType' => $media_type, - 'callable' => function () use ( $media_id, $media_url, $media_type ) { - $data = [ - 'id' => $media_id, - 'url' => $media_url, - 'filepath' => get_attached_file( $media_id ), - 'alt' => get_post_meta( $media_id, '_wp_attachment_image_alt', true ), - 'mime_type' => $media_type, - 'metadata' => wp_get_attachment_metadata( $media_id ), - ]; - - return $data; - }, - ] + if ( $with_wordpress ) { + $sessions[] = ( new MCP\Client( new CliLogger() ) )->connect( + MCP\Servers\WordPress\WordPress::class ); } - // Also register a media collection resource - $server->register_resource( - [ - 'name' => 'media_collection', - 'uri' => 'data://media', - 'description' => 'Collection of all media items', - 'mimeType' => 'application/json', - 'callable' => function () { - - $args = array( - 'post_type' => 'attachment', - 'post_status' => 'inherit', - 'posts_per_page' => - 1, - 'fields' => 'ids', - ); - - $media_ids = get_posts( $args ); - $media_map = []; - - foreach ( $media_ids as $id ) { - $media_map[ $id ] = 'media://' . $id; - } + $servers = array_values( ( new McpConfig() )->get_config() ); + + foreach ( $servers as $args ) { + if ( str_starts_with( $args, 'http://' ) || str_starts_with( $args, 'https://' ) ) { + $sessions[] = ( new Client() )->connect( + $args + ); + } else { + $args = explode( ' ', $args ); + $cmd = array_shift( $args ); + $server_params = new StdioServerParameters( + $cmd, + $args + ); + + $sessions[] = ( new Client() )->connect( + $server_params->getCommand(), + $server_params->getArgs(), + $server_params->getEnv() + ); + } + } - return $media_map; - }, - ] - ); + return $sessions; } } diff --git a/src/Collection.php b/src/Collection.php deleted file mode 100644 index 77c4dc7..0000000 --- a/src/Collection.php +++ /dev/null @@ -1,40 +0,0 @@ -data); - } - - public function key(): int - { - return (int)key($this->data); - } - - public function valid(): bool - { - return key($this->data) !== null; - } - - public function rewind(): void - { - reset($this->data); - } - - public function count(): int - { - return count($this->data); - } - -} diff --git a/src/Entity/Tool.php b/src/Entity/Tool.php deleted file mode 100644 index efdcee8..0000000 --- a/src/Entity/Tool.php +++ /dev/null @@ -1,43 +0,0 @@ -validate(); - } - - private function validate(): void - { - foreach ($this->tags as $tag) { - if ( ! preg_match('/^[a-z][a-z-]+$/', $tag)) { - throw new InvalidArgumentException('Tags can only contain [a-z] and -.'); - } - } - } - - public function get_name(): string - { - return $this->data['name']; - } - - public function get_tags(): array - { - return $this->tags; - } - - public function get_data(): array - { - return $this->data; - } - -} diff --git a/src/MCP/Client.php b/src/MCP/Client.php index c45892d..1a8beee 100644 --- a/src/MCP/Client.php +++ b/src/MCP/Client.php @@ -2,449 +2,67 @@ namespace WP_CLI\AiCommand\MCP; -use Exception; -use Felix_Arntz\AI_Services\Services\API\Enums\AI_Capability; -use Felix_Arntz\AI_Services\Services\API\Enums\Content_Role; -use Felix_Arntz\AI_Services\Services\API\Helpers; -use Felix_Arntz\AI_Services\Services\API\Types\Blob; -use Felix_Arntz\AI_Services\Services\API\Types\Content; -use Felix_Arntz\AI_Services\Services\API\Types\Parts; -use Felix_Arntz\AI_Services\Services\API\Types\Parts\File_Data_Part; -use Felix_Arntz\AI_Services\Services\API\Types\Parts\Function_Call_Part; -use Felix_Arntz\AI_Services\Services\API\Types\Parts\Inline_Data_Part; -use Felix_Arntz\AI_Services\Services\API\Types\Parts\Text_Part; -use Felix_Arntz\AI_Services\Services\API\Types\Text_Generation_Config; -use Felix_Arntz\AI_Services\Services\API\Types\Tools; -use WP_CLI; +use Mcp\Client\Client as McpCLient; +use Mcp\Client\ClientSession; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; -class Client { - private $server; // Instance of MCPServer +class Client extends McpCLient { + private ?ClientSession $session = null; - public function __construct( Server $server ) { - $this->server = $server; - } - - public function send_request( $method, $params = [] ) { - $request = [ - 'jsonrpc' => '2.0', - 'method' => $method, - 'params' => $params, - 'id' => uniqid( '', true ), // Generate a unique ID for each request - ]; - - $request_data = json_encode( $request ); - $response_data = $this->server->process_request( $request_data ); - $response = json_decode( $response_data, true ); - - if ( json_last_error() !== JSON_ERROR_NONE ) { - throw new Exception( 'Invalid JSON response: ' . json_last_error_msg() ); - } - - if ( isset( $response['error'] ) ) { - throw new Exception( 'JSON-RPC Error: ' . $response['error']['message'], $response['error']['code'] ); - } - - return $response['result']; - } + private LoggerInterface $logger; - public function __call( $name, $arguments ) { - // Magic method for calling any method - return $this->send_request( $name, $arguments[0] ?? [] ); - } - - public function list_resources() { - return $this->send_request( 'resources/list' ); - } + /** + * Client constructor. + * + * @param LoggerInterface|null $logger PSR-3 compliant logger. + */ + public function __construct( ?LoggerInterface $logger = null ) { + $this->logger = $logger ?? new NullLogger(); - public function read_resource( $uri ) { - return $this->send_request( 'resources/read', [ 'uri' => $uri ] ); + parent::__construct( $this->logger ); } - // Must not have the same name as the tool, otherwise it takes precedence. - public function get_image_from_ai_service( string $prompt, string $title = 'ai-generated-image' ) { - // See https://github.com/felixarntz/ai-services/issues/25. - add_filter( - 'map_meta_cap', - static function () { - return [ 'exist' ]; - } - ); - - try { - $service = ai_services()->get_available_service( - [ - 'capabilities' => [ - AI_Capability::IMAGE_GENERATION, - ], - ] + /** + * @param string|class-string $command_or_url Class name, command, or URL. + * @param array $args Unused. + * @param array|null $env Unused. + * @param float|null $read_timeout Unused. + * @return ClientSession + */ + public function connect( + string $command_or_url, + array $args = [], + ?array $env = null, + ?float $read_timeout = null + ): ClientSession { + if ( class_exists( $command_or_url ) ) { + /** + * @var Server $server + */ + $server = new $command_or_url( $this->logger ); + + $transport = new InMemoryTransport( + $server, + $this->logger ); - $candidates = $service - ->get_model( - [ - 'feature' => 'image-generation', - 'capabilities' => [ - AI_Capability::IMAGE_GENERATION, - ], - ] - ) - ->generate_image( $prompt ); - - } catch ( Exception $e ) { - WP_CLI::error( $e->getMessage() ); - } - - $image_url = ''; - 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', \sanitize_title( $title ) . '-'); - $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 = \WP_CLI\AiCommand\MediaManager::upload_to_media_library($image_url, $title); - } - - 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 ( isset( $image_id ) || 'no image found' ); - } - // See https://github.com/felixarntz/ai-services/blob/main/docs/Accessing-AI-Services-in-PHP.md for further processing. + [$read_stream, $write_stream] = $transport->connect(); - WP_CLI::debug( "Generated image: $image_url", 'ai' ); - - return $image_url; - } - - public function call_ai_service_with_prompt( string $input_prompt ) { - if ( empty( $input_prompt ) ) { - echo "How can I help you?" . PHP_EOL; - $prompt = \cli\prompt( '', false, '> ' ); - return $this->call_ai_service_with_prompt( $prompt ) ; - } - - if ( $input_prompt === 'exit' || $input_prompt === 'quit' || $input_prompt === 'q' ) { - return "Bye!"; - } - - $prompt = apply_filters( 'ai_command_prompt', $input_prompt ); - \WP_CLI::debug( "Prompt: {$prompt}", 'mcp_server' ); - - $parts = new Parts(); - $parts->add_text_part( $prompt ); - - $contents = [ - new Content( Content_Role::USER, $parts ), - ]; - -// $parts = new Parts(); -// $parts->add_inline_data_part( -// 'image/png', -// Helpers::blob_to_base64_data_url( new Blob( file_get_contents( '/private/tmp/ai-generated-imaget1sjmomi30i31C1YtZy.png' ), 'image/png' ) ), -// ); -// -// $contents[] = $parts; - - return $this->call_ai_service( $contents ); - } - - private function call_ai_service( $contents ) { - // See https://github.com/felixarntz/ai-services/issues/25. - add_filter( - 'map_meta_cap', - static function () { - return [ 'exist' ]; - } - ); - - $capabilities = $this->get_capabilities(); - - $function_declarations = []; - - foreach ( $capabilities['methods'] ?? [] as $tool ) { - $function_declarations[] = [ - 'name' => $tool['name'], - 'description' => $tool['description'] ?? '', // Provide a description - 'parameters' => $tool['inputSchema'] ?? [], // Provide the inputSchema - ]; - } - - $new_contents = $contents; - - $tools = new Tools(); - $tools->add_function_declarations_tool( $function_declarations ); - - try { - $service = ai_services()->get_available_service( - [ - 'capabilities' => [ - AI_Capability::MULTIMODAL_INPUT, - AI_Capability::TEXT_GENERATION, - AI_Capability::FUNCTION_CALLING, - ], - ] + // Initialize the client session with the obtained streams + $this->session = new InMemorySession( + $read_stream, + $write_stream, + $this->logger ); - \WP_CLI::debug( 'Making request...' . print_r( $contents, true ), 'ai' ); - - // if ( $service->get_service_slug() === 'openai' ) { - // $model = 'gpt-4o'; - // } else { - // $model = 'gemini-2.0-flash'; - // } - - $candidates = $service - ->get_model( - [ - 'feature' => 'text-generation', - // 'model' => $model, - 'tools' => $tools, - 'capabilities' => [ - AI_Capability::MULTIMODAL_INPUT, - AI_Capability::TEXT_GENERATION, - AI_Capability::FUNCTION_CALLING, - ], - // 'generationConfig' => Text_Generation_Config::from_array( - // array( - // 'responseModalities' => array( - // 'Text', - // 'Image', - // ), - // ) - // ), - ] - ) - ->generate_text( $contents ); + // Initialize the session (e.g., perform handshake if necessary) + $this->session->initialize(); - $text = ''; - foreach ( $candidates->get( 0 )->get_content()->get_parts() as $part ) { - if ( $part instanceof Text_Part ) { - if ( '' !== $text ) { - $text .= "\n\n"; - } - $text .= $part->get_text(); - } elseif ( $part instanceof Function_Call_Part ) { - $function_result = $this->{$part->get_name()}( $part->get_args() ); - - WP_CLI::debug( 'Calling Tool: ' . $part->get_name(), 'mcp_server' ); - - // Odd limitation of add_function_response_part(). - if ( ! is_array( $function_result ) ) { - $function_result = [ $function_result ]; - } - - $function_result = [ 'result' => $function_result ]; - - $parts = new Parts(); - $parts->add_function_call_part( $part->get_id(), $part->get_name(), $part->get_args() ); - $new_contents[] = new Content( Content_Role::MODEL, $parts ); - - $parts = new Parts(); - $parts->add_function_response_part( $part->get_id(), $part->get_name(), $function_result ); - $content = new Content( Content_Role::USER, $parts ); - $new_contents[] = $content; - } elseif ( $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; - } else { - $binary_data = base64_decode( $image_url ); - if ( false !== $binary_data ) { - $image_blob = new Blob( $binary_data, $part->get_mime_type() ); - - $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; - } - } - - $text .= "Generated image: $image_url\n"; - - 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; - } - } - - if ( $new_contents !== $contents ) { - return $this->call_ai_service( $new_contents ); - } - - // Keep the session open to continue chatting. - - WP_CLI::line( "\033[38;5;10m" . $text . "\033[0m" ); - - $response = \cli\prompt( '', false, '> ' ); - - $parts = new Parts(); - $parts->add_text_part( $response ); - $content = new Content( Content_Role::USER, $parts ); - $new_contents[] = $content; - return $this->call_ai_service( $new_contents ); - } catch ( Exception $e ) { - WP_CLI::error( $e->getMessage() ); - } - } - - public function modify_image_with_ai($prompt, $media_element) { - - - $mime_type = $media_element['mime_type']; - $image_path = $media_element['filepath']; - $image_contents = file_get_contents($image_path); - - // Convert image to base64 - $base64_image = base64_encode($image_contents); - - // API Configuration - $api_key = get_option('ais_google_api_key'); - - if(!$api_key) { - WP_CLI::error("Gemini API Key is not available"); + return $this->session; } - $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) { - WP_CLI::error("cURL Error: " . $error); - return false; - } - - if ($status_code >= 400) { - WP_CLI::error("API Error (Status $status_code): " . $response); - return false; - } - // Process response - $response_data = json_decode($response, true); - - // Check for valid response - if (empty($response_data) || !isset($response_data['candidates'][0]['content']['parts'])) { - WP_CLI::error("Invalid API response format"); - return false; - } - - // 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) { - WP_CLI::error("No image data in response"); - return false; - } - - // Decode base64 image - $binary_data = base64_decode($image_data); - if (false === $binary_data) { - WP_CLI::error("Failed to decode image data"); - return false; - } - - // 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)) { - WP_CLI::error("Failed to save image to temporary file"); - return false; - } - - // Upload to media library - $image_id = \WP_CLI\AiCommand\MediaManager::upload_to_media_library($filename); - - if ($image_id) { - WP_CLI::success('Image generated with ID: ' . $image_id); - return $image_id; - } - - return false; -} + return parent::connect( $command_or_url, $args, $env, $read_timeout ); + } } diff --git a/src/MCP/InMemorySession.php b/src/MCP/InMemorySession.php new file mode 100644 index 0000000..33a89bb --- /dev/null +++ b/src/MCP/InMemorySession.php @@ -0,0 +1,154 @@ +logger = $logger ?? new NullLogger(); + $this->read_stream = $read_stream; + $this->write_stream = $write_stream; + + parent::__construct( + $read_stream, + $write_stream, + null, + $this->logger + ); + } + + /** + * Sends a request and waits for a typed result. If an error response is received, throws an exception. + * + * @param McpModel $request A typed request object (e.g., InitializeRequest, PingRequest). + * @param string $result_type The fully-qualified class name of the expected result type (must implement McpModel). TODO: Implement. + * @return McpModel The validated result object. + * @throws McpError If an error response is received. + */ + public function sendRequest( McpModel $request, string $result_type ): McpModel { + $this->validate_request_object( $request ); + + $request_id_value = $this->request_id++; + $request_id = new RequestId( $request_id_value ); + + // Convert the typed request into a JSON-RPC request message + // Assuming $request has public properties: method, params + $json_rpc_request = new JsonRpcMessage( + new JSONRPCRequest( + '2.0', + $request_id, + $request->params ?? null, + $request->method + ) + ); + + // Send the request message + $this->writeMessage( $json_rpc_request ); + + $message = $this->readNextMessage(); + + $inner_message = $message->message; + + if ( $inner_message instanceof JSONRPCError ) { + // It's an error response + // Convert JsonRpcErrorObject into ErrorData + $error_data = new ErrorData( + $inner_message->error->code, + $inner_message->error->message, + $inner_message->error->data + ); + throw new McpError( $error_data ); + } + + if ( $inner_message instanceof JSONRPCResponse ) { + return $inner_message->result; + } + + // Invalid response + throw new InvalidArgumentException( 'Invalid JSON-RPC response received' ); + } + + private function validate_request_object( McpModel $request ): void { + // Check if request has a method property + if ( ! property_exists( $request, 'method' ) || empty( $request->method ) ) { + throw new InvalidArgumentException( 'Request must have a method' ); + } + } + + /** + * Write a JsonRpcMessage to the write stream. + * + * @param JsonRpcMessage $message The JSON-RPC message to send. + * + * @throws RuntimeException If writing to the stream fails. + * + * @return void + */ + protected function writeMessage( JsonRpcMessage $message ): void { + $this->logger->debug( 'Sending message to server: ' . json_encode( $message, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT ) ); + $this->write_stream->send( $message ); + } + + /** + * Read the next message from the read stream. + * + * @throws RuntimeException If an invalid message type is received. + * + * @return JsonRpcMessage The received JSON-RPC message. + */ + protected function readNextMessage(): JsonRpcMessage { + return $this->read_stream->receive(); + } + + /** + * Start any additional message processing mechanisms if necessary. + * + * @return void + */ + protected function startMessageProcessing(): void { + // Not used. + } + + /** + * Stop any additional message processing mechanisms if necessary. + * + * @return void + */ + protected function stopMessageProcessing(): void { + // Not used. + } +} diff --git a/src/MCP/InMemoryTransport.php b/src/MCP/InMemoryTransport.php new file mode 100644 index 0000000..8eeb945 --- /dev/null +++ b/src/MCP/InMemoryTransport.php @@ -0,0 +1,55 @@ +server,$this->logger) extends MemoryStream { + private LoggerInterface $logger; + + public function __construct( private readonly Server $server, LoggerInterface $logger ) { + $this->logger = $logger; + } + + /** + * Send a JsonRpcMessage or Exception to the server via SSE. + * + * @param JsonRpcMessage|Exception $message The JSON-RPC message or exception to send. + * + * @return void + * + * @throws InvalidArgumentException If the message is not a JsonRpcMessage. + * @throws RuntimeException If sending the message fails. + */ + public function send( mixed $message ): void { + if ( ! $message instanceof JsonRpcMessage ) { + throw new InvalidArgumentException( 'Only JsonRpcMessage instances can be sent.' ); + } + + $response = $this->server->handle_message( $message ); + + $this->logger->debug( 'Received response for sent message: ' . json_encode( $response, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT ) ); + + if ( null !== $response ) { + parent::send( $response ); + } + } + }; + + return [ $shared_stream, $shared_stream ]; + } +} diff --git a/src/MCP/Server.php b/src/MCP/Server.php index 9310c1b..eadb3d0 100644 --- a/src/MCP/Server.php +++ b/src/MCP/Server.php @@ -2,17 +2,92 @@ namespace WP_CLI\AiCommand\MCP; -use Exception; -use WP_CLI; use InvalidArgumentException; +use Mcp\Server\NotificationOptions; +use Mcp\Server\Server as McpServer; +use Mcp\Shared\ErrorData; +use Mcp\Shared\McpError; +use Mcp\Shared\Version; +use Mcp\Types\CallToolResult; +use Mcp\Types\Implementation; +use Mcp\Types\InitializeResult; +use Mcp\Types\JSONRPCError; +use Mcp\Types\JsonRpcErrorObject; +use Mcp\Types\JsonRpcMessage; +use Mcp\Types\JSONRPCNotification; +use Mcp\Types\JSONRPCRequest; +use Mcp\Types\JSONRPCResponse; +use Mcp\Types\ListResourcesResult; +use Mcp\Types\ListResourceTemplatesResult; +use Mcp\Types\ListToolsResult; +use Mcp\Types\ReadResourceResult; +use Mcp\Types\RequestId; +use Mcp\Types\Resource; +use Mcp\Types\ResourceTemplate; +use Mcp\Types\Result; +use Mcp\Types\TextContent; +use Mcp\Types\TextResourceContents; +use Mcp\Types\Tool; +use Mcp\Types\ToolInputSchema; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; class Server { + private array $data = []; - private array $data = []; - private array $tools = []; + /** + * @var array + */ + private array $tools = []; + + /** + * @var Array + */ private array $resources = []; - public function __construct() { + /** + * @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' ] + ); } public function register_tool( array $tool_definition ): void { @@ -25,273 +100,242 @@ public function register_tool( array $tool_definition ): void { $description = $tool_definition['description'] ?? null; $input_schema = $tool_definition['inputSchema'] ?? null; - // TODO: This is a temporary limit. - if ( count( $this->tools ) >= 128 ) { - WP_CLI::debug( 'Too many tools, max is 128', 'tools' ); - return; - } - $this->tools[ $name ] = [ - 'name' => $name, + 'tool' => new Tool( + $name, + ToolInputSchema::fromArray( + $input_schema, + ), + $description + ), 'callable' => $callable, - 'description' => $description, 'inputSchema' => $input_schema, ]; } - public function register_resource( array $resource_definition ) { - // Validate the resource definition (similar to tool validation) - if ( ! isset( $resource_definition['name'] ) || ! isset( $resource_definition['uri'] ) ) { - throw new InvalidArgumentException( 'Invalid resource definition.' ); - } - - $this->resources[ $resource_definition['name'] ] = $resource_definition; + 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 + ); } - public function get_capabilities(): array { - $capabilities = [ - 'version' => '1.0', // MCP version (adjust as needed) - 'methods' => [], - 'data_resources' => [], - ]; - - foreach ( $this->tools as $tool ) { // Iterate through the tools array - $capabilities['methods'][] = [ // Add each tool as an element in the array - 'name' => $tool['name'], - 'description' => $tool['description'], - 'inputSchema' => $tool['inputSchema'], - ]; + // 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']; } - // Add data resources - // Add resources to capabilities - foreach ( $this->resources as $resource ) { - $capabilities['data_resources'] = [ - 'name' => $resource['name'], - // You can add more details about the resource here if needed - ]; - } - - return $capabilities; + return new ListToolsResult( $prepared_tools ); } - public function handle_request( string $request_data ): false|string { - $request = json_decode( $request_data, true ); - - if ( json_last_error() !== JSON_ERROR_NONE ) { - return $this->create_error_response( null, 'Invalid JSON', - 32700 ); // Parse error - } - - if ( ! isset( $request['jsonrpc'] ) || '2.0' !== $request['jsonrpc'] ) { - return $this->create_error_response( $request['id'] ?? null, 'Invalid JSON-RPC version', - 32600 ); // Invalid Request + public function call_tool( $params ): CallToolResult { + $found_tool = null; + foreach ( $this->tools as $name => $tool ) { + if ( $name === $params->name ) { + $found_tool = $tool; + break; + } } - if ( ! isset( $request['method'] ) ) { - return $this->create_error_response( $request['id'] ?? null, 'Missing method', - 32600 ); // Invalid Request + if ( ! $found_tool ) { + throw new InvalidArgumentException( "Unknown tool: {$params->name}" ); } - $method = $request['method']; - $params = $request['params'] ?? []; - $id = $request['id'] ?? null; + $result = call_user_func( $found_tool['callable'], $params->arguments ); - if ( 'get_capabilities' === $method ) { // Handle capabilities request - $capabilities = $this->get_capabilities(); - - return $this->create_success_response( $id, $capabilities ); + if ( $result instanceof CallToolResult ) { + return $result; } - try { - // Check if it's a data access request (starts with "get_") - if ( str_starts_with( $method, 'get_' ) ) { - $resource = substr( $method, 4 ); // Extract the resource name (e.g., "users" from "get_users") - - if ( isset( $this->data[ $resource ] ) ) { - $result = $this->handle_get_request( '/' . $resource, $params ); // Re-use handleGetRequest - } elseif ( isset( $this->data[ "{$resource}s" ] ) ) { - $result = $this->handle_get_request( '/' . "{$resource}s", $params ); // Re-use handleGetRequest - } else { - return $this->create_error_response( $id, 'Resource not found', - 32601 ); // Method not found - } - } elseif ( 'resources/list' === $method ) { - $result = $this->list_resources(); - } elseif ( 'resources/read' === $method ) { - $result = $this->read_resource( $params['uri'] ?? null ); - } else { // Treat as a tool call - - $tool = $this->tools[ $method ] ?? null; - if ( ! $tool ) { - return $this->create_error_response( $id, 'Method not found', - 32601 ); - } - - // Validate input parameters against the schema - $input_schema = $tool['inputSchema'] ?? null; - if ( $input_schema ) { - $is_valid = $this->validate_input( $params, $input_schema ); - if ( ! $is_valid['valid'] ) { - return $this->create_error_response( $id, 'Invalid input parameters: ' . implode( ', ', $is_valid['errors'] ), - 32602 ); // Invalid params - } - } - - $result = call_user_func( $tool['callable'], $params ); // Call the 'callable' property - - return $this->create_success_response( $id, $result ); // Return success immediately - - } - - return $this->create_success_response( $id, $result ); + if ( is_wp_error( $result ) ) { + return new CallToolResult( + [ + new TextContent( + $result->get_error_message() + ), + ], + true + ); + } - } catch ( Exception $e ) { - return $this->create_error_response( $id, $e->getMessage(), - 32000 ); // Application error + if ( is_string( $result ) ) { + $result = [ new TextContent( $result ) ]; } - } - public function list_resources() { - $result = []; - foreach ( $this->resources as $resource ) { - $result[] = [ - 'uri' => $resource['uri'], - 'name' => $resource['name'], - 'description' => $resource['description'] ?? null, - 'mimeType' => $resource['mimeType'] ?? null, - ]; + if ( ! is_array( $result ) ) { + $result = [ $result ]; } + return new CallToolResult( $result ); + } - return $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 ); } - private function read_resource( $uri ) { - // Find the resource by URI - $resource = null; - foreach ( $this->resources as $r ) { - if ( $r['uri'] === $uri ) { - $resource = $r; - break; - } + // TODO: Make dynamic. + public function read_resources( $params ): ReadResourceResult { + $uri = $params->uri; + if ( 'example://greeting' !== $uri ) { + throw new InvalidArgumentException( "Unknown resource: {$uri}" ); } - if ( ! $resource ) { - throw new Exception( 'Resource not found.' ); - } + return new ReadResourceResult( + [ + new TextResourceContents( + 'Hello from the example MCP server!', + $uri, + 'text/plain' + ), + ] + ); + } - // Access the resource data (replace with your actual data access logic) - $data = $this->get_resource_data( $resource ); + public function register_resource( Resource $res ): void { + $this->resources[ $res->name ] = $res; + } - // Determine if it's text or binary - $is_binary = isset( $resource['mimeType'] ) && ! str_starts_with( $resource['mimeType'], 'text/' ); + // 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 ); + } - return [ - 'uri' => $resource['uri'], - 'mimeType' => $resource['mimeType'] ?? null, - ( $is_binary ? 'blob' : 'text' ) => $data, - ]; + public function register_resource_template( ResourceTemplate $resource_template ): void { + $this->resource_templates[ $resource_template->name ] = $resource_template; } - public function get_resource_data( $mcp_resource ) { - // Replace this with your actual logic to access the resource data - // based on the resource definition. + /** + * Processes an incoming message from the client. + */ + public function handle_message( JsonRpcMessage $message ) { + $this->logger->debug( 'Received message: ' . json_encode( $message ) ); - if ( str_starts_with( $mcp_resource, 'media://' ) ) { - return $this->get_media_data( $mcp_resource ); - } + $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; + } - // Example: If the resource is a file, read the file contents. - if ( isset( $mcp_resource['filePath'] ) ) { - return file_get_contents( $mcp_resource['filePath'] ); + // 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() + ) + ); + } } + } - // Example: If the resource is in the $data array, return the data. - if ( isset( $mcp_resource['dataKey'] ) ) { - return $this->data[ $mcp_resource['dataKey'] ]; + /** + * 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}" + ) + ); } - //... other data access logic... + $params = $request->params ?? null; + $result = $handler( $params ); - throw new Exception( 'Unable to access resource data.' ); - } - - private function get_media_data( $mcp_resource ) { - - foreach ( $this->resources as $resource ) { - if ( $resource['uri'] === $mcp_resource ) { - $callback_response = $resource['callable'](); - return $callback_response; - } + if ( ! $result instanceof Result ) { + $result = new Result(); } + + return $this->send_response( $request->id, $result ); } - // TODO: use a dedicated JSON schema validator library - private function validate_input( $input, $schema ): array { - $errors = []; - foreach ( $schema['properties'] ?? [] as $param_name => $param_schema ) { - if ( isset( $param_schema['required'] ) && true === $param_schema['required'] && ! isset( $input[ $param_name ] ) ) { - $errors[] = $param_name . ' is required'; - } - // Add more validation rules as needed (e.g., type checking) - if ( isset( $input[ $param_name ], $param_schema['type'] ) ) { - $input_type = gettype( $input[ $param_name ] ); - if ( $input_type !== $param_schema['type'] ) { - $errors[] = $param_name . ' must be of type ' . $param_schema['type'] . ' but ' . $input_type . ' was given.'; - } - } + /** + * 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 ); } - return [ - 'valid' => empty( $errors ), - 'errors' => $errors, - ]; + $this->logger->warning( "No handler registered for notification method: $method" ); } - private function handle_get_request( $path, $params ) { - $parts = explode( '/', ltrim( $path, '/' ) ); - $resource = $parts[0]; - $id = $params['id'] ?? null; // Simplified parameter handling - - if ( isset( $this->data[ $resource ] ) ) { - $data = $this->data[ $resource ]; - - if ( null !== $id ) { - foreach ( $data as $item ) { - if ( $item['id'] === $id ) { - return $item; - } - } - throw new Exception( 'Resource not found' ); - } - - return $data; - } + /** + * 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(); - throw new Exception( 'Resource not found' ); + return new JsonRpcMessage( $response ); } - private function create_success_response( $id, $result ): false|string { - return json_encode( - [ - 'jsonrpc' => '2.0', - 'result' => $result, - 'id' => $id, - ], - JSON_THROW_ON_ERROR + + /** + * 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 ); - } - private function create_error_response( $id, $message, $code ): false|string { - return json_encode( - [ - 'jsonrpc' => '2.0', - 'error' => [ - 'code' => $code, - 'message' => $message, - ], - 'id' => $id, - ], - JSON_THROW_ON_ERROR + $response = new JSONRPCError( + '2.0', + $id, + $error_object ); - } + $response->validate(); - public function process_request( $request_data ): false|string { - return $this->handle_request( $request_data ); + return new JsonRpcMessage( $response ); } } diff --git a/src/Tools/MapCLItoMCP.php b/src/MCP/Servers/WP_CLI/Tools/CliCommands.php similarity index 52% rename from src/Tools/MapCLItoMCP.php rename to src/MCP/Servers/WP_CLI/Tools/CliCommands.php index db65b3d..e0ac3aa 100644 --- a/src/Tools/MapCLItoMCP.php +++ b/src/MCP/Servers/WP_CLI/Tools/CliCommands.php @@ -1,15 +1,16 @@ find_command_to_run( [ $command ] ); - list( $command ) = $command_to_run; + [$command] = WP_CLI::get_runner()->find_command_to_run( [ $command ] ); if ( ! is_object( $command ) ) { continue; @@ -47,10 +47,10 @@ public function map_cli_to_mcp() : array { 'description' => 'Dummy parameter', ]; - WP_CLI::debug( 'Synopsis for command: ' . $command_name . ' - ' . print_r( $command_synopsis, true ), 'ai' ); + $this->logger->debug( 'Synopsis for command: ' . $command_name . ' - ' . print_r( $command_synopsis, true ) ); foreach ( $command_synopsis as $arg ) { - if ( $arg['type'] === 'positional' || $arg['type'] === 'assoc' ) { + if ( 'positional' === $arg['type'] || 'assoc' === $arg['type'] ) { $prop_name = str_replace( '-', '_', $arg['name'] ); $properties[ $prop_name ] = [ 'type' => 'string', @@ -63,57 +63,56 @@ public function map_cli_to_mcp() : array { } } - $tool = new Tool([ - 'name' => 'wp_cli_' . str_replace( ' ', '_', $command_name ), - 'description' => $command_desc, - 'inputSchema' => [ - 'type' => 'object', - 'properties' => $properties, - 'required' => $required, - ], - 'callable' => function ( $params ) use ( $command_name, $synopsis_spec ) { - $args = []; - $assoc_args = []; - - // Process positional arguments first - foreach ( $synopsis_spec as $arg ) { - if ( $arg['type'] === 'positional' ) { - $prop_name = str_replace( '-', '_', $arg['name'] ); - if ( isset( $params[ $prop_name ] ) ) { - $args[] = $params[ $prop_name ]; - } + $tool = [ + 'name' => 'wp_cli_' . str_replace( ' ', '_', $command_name ), + 'description' => $command_desc, + 'inputSchema' => [ + 'type' => 'object', + 'properties' => $properties, + 'required' => $required, + ], + 'callable' => function ( $params ) use ( $command_name, $synopsis_spec ) { + $args = []; + $assoc_args = []; + + // Process positional arguments first + foreach ( $synopsis_spec as $arg ) { + if ( 'positional' === $arg['type'] ) { + $prop_name = str_replace( '-', '_', $arg['name'] ); + if ( isset( $params[ $prop_name ] ) ) { + $args[] = $params[ $prop_name ]; } } + } - // Process associative arguments and flags - foreach ( $params as $key => $value ) { - // Skip positional args and dummy param - if ( $key === 'dummy' ) { - continue; - } + // Process associative arguments and flags + foreach ( $params as $key => $value ) { + // Skip positional args and dummy param + if ( 'dummy' === $key ) { + continue; + } - // Check if this is an associative argument - foreach ( $synopsis_spec as $arg ) { - if ( ( $arg['type'] === 'assoc' || $arg['type'] === 'flag' ) && - str_replace( '-', '_', $arg['name'] ) === $key ) { - $assoc_args[ str_replace( '_', '-', $key ) ] = $value; - break; - } + // Check if this is an associative argument + foreach ( $synopsis_spec as $arg ) { + if ( ( 'assoc' === $arg['type'] || 'flag' === $arg['type'] ) && + str_replace( '-', '_', $arg['name'] ) === $key ) { + $assoc_args[ str_replace( '_', '-', $key ) ] = $value; + break; } } + } - ob_start(); - WP_CLI::run_command( array_merge( explode( ' ', $command_name ), $args ), $assoc_args ); - return ob_get_clean(); - }, - ] - ); + ob_start(); + WP_CLI::run_command( array_merge( explode( ' ', $command_name ), $args ), $assoc_args ); + return ob_get_clean(); + }, + ]; $tools[] = $tool; - + } else { - \WP_CLI::debug( $command_name . ' subcommands: ' . print_r( $command->get_subcommands(), true ), 'ai' ); + $this->logger->debug( $command_name . ' subcommands: ' . print_r( $command->get_subcommands(), true ) ); foreach ( $command->get_subcommands() as $subcommand ) { @@ -135,7 +134,7 @@ public function map_cli_to_mcp() : array { ]; foreach ( $synopsis_spec as $arg ) { - if ( $arg['type'] === 'positional' || $arg['type'] === 'assoc' ) { + if ( 'positional' === $arg['type'] || 'assoc' === $arg['type'] ) { $prop_name = str_replace( '-', '_', $arg['name'] ); $properties[ $prop_name ] = [ 'type' => 'string', @@ -143,69 +142,59 @@ public function map_cli_to_mcp() : array { ]; } - /* - // Handle flag type parameters (boolean) - if ($arg['type'] === 'flag') { - $prop_name = str_replace('-', '_', $arg['name']); - $properties[ $prop_name ] = [ - 'type' => 'boolean', - 'description' => $arg['description'] ?? "Flag {$arg['name']}", - 'default' => false - ]; - }*/ + // TODO: Handle flag type parameters (boolean) if ( ! isset( $arg['optional'] ) || ! $arg['optional'] ) { $required[] = $prop_name; } } - $tool = new Tool([ - 'name' => 'wp_cli_' . str_replace( ' ', '_', $command_name ) . '_' . str_replace( ' ', '_', $subcommand_name ), - 'description' => $subcommand_desc, - 'inputSchema' => [ - 'type' => 'object', - 'properties' => $properties, - 'required' => $required, - ], - 'callable' => function ( $params ) use ( $command_name, $subcommand_name, $synopsis_spec ) { - - \WP_CLI::debug( 'Subcommand: ' . $subcommand_name . ' - Received params: ' . print_r( $params, true ), 'ai' ); - - $args = []; - $assoc_args = []; - - // Process positional arguments first - foreach ( $synopsis_spec as $arg ) { - if ( $arg['type'] === 'positional' ) { - $prop_name = str_replace( '-', '_', $arg['name'] ); - if ( isset( $params[ $prop_name ] ) ) { - $args[] = $params[ $prop_name ]; - } + $tool = [ + 'name' => 'wp_cli_' . str_replace( ' ', '_', $command_name ) . '_' . str_replace( ' ', '_', $subcommand_name ), + 'description' => $subcommand_desc, + 'inputSchema' => [ + 'type' => 'object', + 'properties' => $properties, + 'required' => $required, + ], + 'callable' => function ( $params ) use ( $command_name, $subcommand_name, $synopsis_spec ) { + + $this->logger->debug( 'Subcommand: ' . $subcommand_name . ' - Received params: ' . print_r( $params, true ) ); + + $args = []; + $assoc_args = []; + + // Process positional arguments first + foreach ( $synopsis_spec as $arg ) { + if ( 'positional' === $arg['type'] ) { + $prop_name = str_replace( '-', '_', $arg['name'] ); + if ( isset( $params[ $prop_name ] ) ) { + $args[] = $params[ $prop_name ]; } } + } - // Process associative arguments and flags - foreach ( $params as $key => $value ) { - // Skip positional args and dummy param - if ( $key === 'dummy' ) { - continue; - } + // Process associative arguments and flags + foreach ( $params as $key => $value ) { + // Skip positional args and dummy param + if ( 'dummy' === $key ) { + continue; + } - // Check if this is an associative argument - foreach ( $synopsis_spec as $arg ) { - if ( ( $arg['type'] === 'assoc' || $arg['type'] === 'flag' ) && - str_replace( '-', '_', $arg['name'] ) === $key ) { - $assoc_args[ str_replace( '_', '-', $key ) ] = $value; - break; - } + // Check if this is an associative argument + foreach ( $synopsis_spec as $arg ) { + if ( ( 'assoc' === $arg['type'] || 'flag' === $arg['type'] ) && + str_replace( '-', '_', $arg['name'] ) === $key ) { + $assoc_args[ str_replace( '_', '-', $key ) ] = $value; + break; } } + } - ob_start(); - WP_CLI::run_command( array_merge( [ $command_name, $subcommand_name ], $args ), $assoc_args ); - return ob_get_clean(); - }, - ] - ); + ob_start(); + WP_CLI::run_command( array_merge( [ $command_name, $subcommand_name ], $args ), $assoc_args ); + return ob_get_clean(); + }, + ]; $tools[] = $tool; } diff --git a/src/MCP/Servers/WP_CLI/WP_CLI.php b/src/MCP/Servers/WP_CLI/WP_CLI.php new file mode 100644 index 0000000..e6decbc --- /dev/null +++ b/src/MCP/Servers/WP_CLI/WP_CLI.php @@ -0,0 +1,20 @@ +logger ) )->get_tools(), + ]; + + foreach ( $all_tools as $tool ) { + $this->register_tool( $tool ); + } + } +} diff --git a/src/MCP/Servers/WordPress/MediaManager.php b/src/MCP/Servers/WordPress/MediaManager.php new file mode 100644 index 0000000..ee9bca1 --- /dev/null +++ b/src/MCP/Servers/WordPress/MediaManager.php @@ -0,0 +1,37 @@ + $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 new file mode 100644 index 0000000..b1be844 --- /dev/null +++ b/src/MCP/Servers/WordPress/Tools/CommunityEvents.php @@ -0,0 +1,56 @@ + '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 new file mode 100644 index 0000000..60e88ba --- /dev/null +++ b/src/MCP/Servers/WordPress/Tools/Dummy.php @@ -0,0 +1,36 @@ + '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 new file mode 100644 index 0000000..f2c0a95 --- /dev/null +++ b/src/MCP/Servers/WordPress/Tools/ImageTools.php @@ -0,0 +1,67 @@ + '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/Tools/MapRESTtoMCP.php b/src/MCP/Servers/WordPress/Tools/RestApi.php similarity index 55% rename from src/Tools/MapRESTtoMCP.php rename to src/MCP/Servers/WordPress/Tools/RestApi.php index d25f7a1..c7f3039 100644 --- a/src/Tools/MapRESTtoMCP.php +++ b/src/MCP/Servers/WordPress/Tools/RestApi.php @@ -1,17 +1,16 @@ $arg ) { $description = $arg['description'] ?? $title; - $type = $this->sanitize_type( $arg['type'] ?? 'string' ); + $type = $this->sanitize_type( $arg['type'] ?? 'string' ); $schema[ $title ] = [ - 'type' => $type, + 'type' => $type, 'description' => $description, ]; if ( isset( $arg['required'] ) && $arg['required'] ) { @@ -33,58 +32,56 @@ public function args_to_schema( $args = [] ) { } return [ - 'type' => 'object', + 'type' => 'object', 'properties' => $schema, - 'required' => $required, + 'required' => $required, ]; } - protected function sanitize_type( $type) { + protected function sanitize_type( $type ) { $mapping = array( - 'string' => 'string', + 'string' => 'string', 'integer' => 'integer', - 'number' => 'integer', + 'number' => 'integer', 'boolean' => 'boolean', ); // Validated types: - if ( !\is_array($type) && isset($mapping[ $type ]) ) { + if ( ! \is_array( $type ) && isset( $mapping[ $type ] ) ) { return $mapping[ $type ]; } - if ( $type === 'array' || $type === 'object' ) { + if ( 'array' === $type || 'object' === $type ) { return 'string'; // TODO, better solution. } - if (empty( $type ) || $type === 'null' ) { + if ( empty( $type ) || 'null' === $type ) { return 'string'; } - if ( !\is_array( $type ) ) { + if ( ! \is_array( $type ) ) { throw new \Exception( 'Invalid type: ' . $type ); - return 'string'; } // Find valid values in array. - if ( \in_array( 'string', $type ) ) { + if ( \in_array( 'string', $type, true ) ) { return 'string'; } - if ( \in_array( 'integer', $type ) ) { + if ( \in_array( 'integer', $type, true ) ) { return 'integer'; } // TODO, better types handling. return 'string'; - } - public function map_rest_to_mcp() : array { + public function get_tools(): array { $server = rest_get_server(); $routes = $server->get_routes(); - $tools = []; + $tools = []; foreach ( $routes as $route => $endpoints ) { foreach ( $endpoints as $endpoint ) { - foreach( $endpoint['methods'] as $method_name => $enabled ) { + foreach ( $endpoint['methods'] as $method_name => $enabled ) { $information = new RouteInformation( $route, $method_name, @@ -95,14 +92,14 @@ public function map_rest_to_mcp() : array { continue; } - $tool = new Tool( [ - 'name' => $information->get_sanitized_route_name(), + $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 $this->rest_callable( $inputs, $route, $method_name, $server ); + 'callable' => function ( $inputs ) use ( $route, $method_name, $server ) { + return json_encode( $this->rest_callable( $inputs, $route, $method_name, $server ) ); }, - ], [ 'wp-rest'] ); + ]; $tools[] = $tool; } @@ -119,9 +116,9 @@ public function map_rest_to_mcp() : array { * 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 { + protected function generate_description( RouteInformation $information ): string { - $verb = match ($information->get_method()) { + $verb = match ( $information->get_method() ) { 'GET' => 'Get', 'POST' => 'Create', 'PUT', 'PATCH' => 'Update', @@ -129,7 +126,7 @@ protected function generate_description( RouteInformation $information ) : str }; $schema = $information->get_wp_rest_controller()->get_public_item_schema(); - $title = $schema['title']; + $title = $schema['title']; $determiner = $information->is_singular() ? 'a' @@ -138,26 +135,28 @@ protected function generate_description( RouteInformation $information ) : str return $verb . ' ' . $determiner . ' ' . $title; } - protected function rest_callable( $inputs, $route, $method_name, \WP_REST_Server $server ) { + 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 ) { + foreach ( $matches[1] as $match ) { if ( array_key_exists( $match, $inputs ) ) { - $route = preg_replace( '/(\(\?P<'.$match.'>.*?\))/', $inputs[$match], $route, 1 ); + $route = preg_replace( '/(\(\?P<' . $match . '>.*?\))/', $inputs[ $match ], $route, 1 ); } } - WP_CLI::debug( 'Rest Route: ' . $route . ' ' . $method_name, 'mcp_server' ); + $this->logger->debug( 'Rest Route: ' . $route . ' ' . $method_name ); - if ( $inputs['meta'] === false || $inputs['meta'] === null || $inputs['meta'] === '' || $inputs['meta'] === [] ) { - unset( $inputs['meta'] ); + if ( isset( $inputs['meta'] ) ) { + if ( false === $inputs['meta'] || '' === $inputs['meta'] || [] === $inputs['meta'] ) { + unset( $inputs['meta'] ); + } } - foreach( $inputs as $key => $value ) { - WP_CLI::debug( ' param->' . $key . ' : ' . $value, 'mcp_server' ); + foreach ( $inputs as $key => $value ) { + $this->logger->debug( ' param->' . $key . ' : ' . $value ); } - $request = new WP_REST_Request( $method_name, $route ); + $request = new WP_REST_Request( $method_name, $route ); $request->set_body_params( $inputs ); /** @@ -167,17 +166,22 @@ protected function rest_callable( $inputs, $route, $method_name, \WP_REST_Server $data = $server->response_to_data( $response, false ); - if( isset( $data[0]['slug'] ) ) { + // 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'] ) ) { + } elseif ( isset( $data['slug'] ) ) { $debug_data = 'Result: ' . $data['id'] . ' ' . $data['slug']; } else { $debug_data = 'Unknown format'; } - WP_CLI::debug( $debug_data, 'mcp_server' ); + + $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 new file mode 100644 index 0000000..2aaffd0 --- /dev/null +++ b/src/MCP/Servers/WordPress/Tools/RouteInformation.php @@ -0,0 +1,107 @@ +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 new file mode 100644 index 0000000..606bae3 --- /dev/null +++ b/src/MCP/Servers/WordPress/WordPress.php @@ -0,0 +1,62 @@ +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 new file mode 100644 index 0000000..f0961ba --- /dev/null +++ b/src/MCP/Servers/WordPress/WpAiClient.php @@ -0,0 +1,194 @@ +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 new file mode 100644 index 0000000..cb48ead --- /dev/null +++ b/src/McpServerCommand.php @@ -0,0 +1,158 @@ +] + * : Render output in a particular format. + * --- + * default: table + * options: + * - table + * - csv + * - json + * - count + * + * ## EXAMPLES + * + * # Greet the world. + * $ wp mcp server list + * Success: Hello World! + * + * # Greet the world. + * $ wp ai "create 10 test posts about swiss recipes and include generated featured images" + * Success: Hello World! + * + * @subcommand list + * + * @param array $args Indexed array of positional arguments. + * @param array $assoc_args Associative array of associative arguments. + */ + public function list_( $args, $assoc_args ): void { + $config = $this->get_config()->get_config(); + + $servers = []; + + foreach ( $config as $name => $server ) { + $servers[] = [ + 'name' => $name, + 'server' => $server, + ]; + } + + $formatter = $this->get_formatter( $assoc_args ); + $formatter->display_items( $servers ); + } + + /** + * Add a new MCP server to the list + * + * ## OPTIONS + * + * + * : Name for referencing the server later + * + * + * : Server command or URL. + * + * ## EXAMPLES + * + * # Add server from URL. + * $ wp mcp server add "server-github" "https://github.com/mcp" + * Success: Server added. + * + * # Add server with command to execute + * $ wp mcp server add "server-filesystem" "npx -y @modelcontextprotocol/server-filesystem /my/allowed/folder/" + * Success: Server added. + * + * @param array $args Indexed array of positional arguments. + */ + public function add( $args ): void { + $config = $this->get_config()->get_config(); + + if ( isset( $config[ $args[0] ] ) ) { + \WP_CLI::error( 'Server already exists.' ); + } else { + $config[ $args[0] ] = $args[1]; + $result = $this->get_config()->update_config( $config ); + + if ( ! $result ) { + \WP_CLI::error( 'Could not add server.' ); + } else { + \WP_CLI::success( 'Server added.' ); + } + } + } + + /** + * Remove a new MCP server from the list + * + * ## OPTIONS + * + * + * : Name of the server to remove + * + * ## EXAMPLES + * + * # Remove server. + * $ wp mcp server remove "server-filesystem" + * Success: Server removed. + * + * @param array $args Indexed array of positional arguments. + */ + public function remove( $args ): void { + $config = $this->get_config()->get_config(); + + if ( ! array_key_exists( $args[0], $config ) ) { + \WP_CLI::error( 'Server not found.' ); + } else { + unset( $config[ $args[0] ] ); + $result = $this->get_config()->update_config( $config ); + + if ( ! $result ) { + \WP_CLI::error( 'Could not remove server.' ); + } else { + \WP_CLI::success( 'Server removed.' ); + } + } + } + + /** + * Returns a Formatter object based on supplied parameters. + * + * @param array $assoc_args Parameters passed to command. Determines formatting. + * @return Formatter + */ + protected function get_formatter( &$assoc_args ) { + return new Formatter( + $assoc_args, + [ + 'name', + 'server', + ] + ); + } + + /** + * Returns an McpConfig instance. + * + * @return McpConfig Config instance. + */ + protected function get_config(): McpConfig { + return new McpConfig(); + } +} diff --git a/src/MediaManager.php b/src/MediaManager.php deleted file mode 100644 index ac27429..0000000 --- a/src/MediaManager.php +++ /dev/null @@ -1,43 +0,0 @@ - $wp_filetype['type'], - 'post_title' => $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); - - // Update alt text - if($title) { - update_post_meta($attach_id, '_wp_attachment_image_alt', $title); - } - - return $attach_id; - - } -} diff --git a/src/RouteInformation.php b/src/RouteInformation.php deleted file mode 100644 index ee11f40..0000000 --- a/src/RouteInformation.php +++ /dev/null @@ -1,132 +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 $this->method === 'POST'; - } - - public function is_update(): bool - { - return $this->method === 'PUT' || $this->method === 'PATCH'; - } - - public function is_delete(): bool - { - return $this->method === 'DELETE'; - } - - public function is_get(): bool - { - return $this->method === 'GET'; - } - - 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_list(): bool - { - return ! $this->is_singular(); - } - - public function get_scope(): string - { - if ( ! $this->is_wp_rest_controller()) { - return 'default'; - } - - $context = [ - WP_REST_Posts_Controller::class => 'post', - WP_REST_Users_Controller::class => 'user', - WP_REST_Taxonomies_Controller::class => 'taxonomy', - ]; - - return $context[get_class($this->get_wp_rest_controller())]; - } - - 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, - ]; - - 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/ToolCollection.php b/src/ToolCollection.php deleted file mode 100644 index f4d3263..0000000 --- a/src/ToolCollection.php +++ /dev/null @@ -1,29 +0,0 @@ -add($tool); - } - } - - public function add(Tool $tool): void - { - $this->data[] = $tool; - } - - public function current(): Tool - { - return current($this->data); - } - -} diff --git a/src/ToolRepository.php b/src/ToolRepository.php deleted file mode 100644 index 88a019c..0000000 --- a/src/ToolRepository.php +++ /dev/null @@ -1,31 +0,0 @@ -find_all() as $tool) { - if ($tool->get_name() === $name) { - return $tool; - } - } - - return null; - } - - public function find_all(array $filters = []): ToolCollection - { - $defaults = [ - 'include' => 'all', - 'exclude' => [], - ]; - - $filters = array_merge($defaults, $filters); - - $filtered = iterator_to_array($this->collection); - - if ($filters['include'] !== 'all') { - $all = $filtered; - $filtered = []; - - foreach ($filters['include'] as $tag_to_include) { - foreach ($all as $tool) { - foreach ($tool->get_tags() as $tag_to_check) { - if ($tag_to_include === $tag_to_check) { - $filtered[$tool->get_name()] = $tool; - - continue 2; - } - } - } - } - } - - if ($filters['exclude']) { - foreach ($filters['exclude'] as $tag_to_exclude) { - foreach ($filtered as $tool) { - foreach ($tool->get_tags() as $tag_to_check) { - if ($tag_to_exclude === $tag_to_check) { - unset($filtered[$tool->get_name()]); - } - } - } - } - } - - return new ToolCollection($filtered); - } - -} diff --git a/src/Tools/CommunityEvents.php b/src/Tools/CommunityEvents.php deleted file mode 100644 index e5e9896..0000000 --- a/src/Tools/CommunityEvents.php +++ /dev/null @@ -1,100 +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' => function ( $params ) { - // Default user ID is 0 - $user_id = 0; - - // Get the location from the parameters (already supplied in the prompt) - $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'; - } - - // Determine location for the WP_Community_Events class - $location = null; - if ( $location_input !== 'near me' ) { - // Provide city name (WP will resolve coordinates) - $location = [ - 'description' => $location_input, - ]; - } - - // Instantiate WP_Community_Events with user ID (0) and optional location - $events_instance = new WP_Community_Events( $user_id, $location ); - - // Get events from WP_Community_Events - $events = $events_instance->get_events($location_input); - - // Check for WP_Error - if ( is_wp_error( $events ) ) { - return [ 'error' => $events->get_error_message() ]; - } - - // If no events found - if ( empty( $events['events'] ) ) { - return [ 'message' => 'No events found near ' . ( $location_input === 'near me' ? 'your location' : $location_input ) ]; - } - - // Format and return the events correctly - $formatted_events = array_map( function ( $event ) { - // Log event details to ensure properties are accessible - error_log( 'Event details: ' . print_r( $event, true ) ); - - // Initialize a formatted event string - $formatted_event = ''; - - // Format event title - if ( isset( $event['title'] ) ) { - $formatted_event .= $event['title'] . "\n"; - } - - // Format the date nicely - $formatted_event .= ' - Date: ' . ( isset( $event['date'] ) ? date( 'F j, Y g:i A', strtotime( $event['date'] ) ) : 'No date available' ) . "\n"; - - // Format the location - if ( isset( $event['location']['location'] ) ) { - $formatted_event .= ' - Location: ' . $event['location']['location'] . "\n"; - } - - // Format the event URL - $formatted_event .= isset( $event['url'] ) ? ' - URL: ' . $event['url'] . "\n" : ''; - - return $formatted_event; - }, $events['events'] ); - - // Combine the formatted events into a single string - $formatted_events_output = implode("\n", $formatted_events); - - // Return the formatted events string - return [ - 'message' => "OK. I found " . count($formatted_events) . " WordPress events near " . ( $location_input === 'near me' ? 'your location' : $location_input ) . ":\n\n" . $formatted_events_output - ]; - }, - ] ); - - return $tools; - } -} diff --git a/src/Tools/FileTools.php b/src/Tools/FileTools.php deleted file mode 100644 index 8e77d20..0000000 --- a/src/Tools/FileTools.php +++ /dev/null @@ -1,125 +0,0 @@ -register_tool( - [ - 'name' => 'write_file', - 'description' => 'Writes a file.', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'path' => [ - 'type' => 'string', - 'description' => 'The path of the file to write.', - ], - 'content' => [ - 'type' => 'string', - 'description' => 'The content of the file to write.', - ], - ], - 'required' => [ 'path', 'content' ], - ], - 'callable' => function ( $params ) { - $path = $params['path']; - $content = $params['content']; - return file_put_contents( $path, $content ); - }, - ] - ); - - $server->register_tool( - [ - 'name' => 'delete_file', - 'description' => 'Deletes a file.', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'path' => [ - 'type' => 'string', - 'description' => 'The path of the file to delete.', - ], - ], - 'required' => [ 'path' ], - ], - 'callable' => function ( $params ) { - $path = $params['path']; - return unlink( $path ); - }, - ] - ); - - $server->register_tool( - [ - 'name' => 'read_file', - 'description' => 'Reads a file.', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'path' => [ - 'type' => 'string', - 'description' => 'The path of the file to read.', - ], - ], - 'required' => [ 'path' ], - ], - 'callable' => function ( $params ) { - $path = $params['path']; - return file_get_contents( $path ); - }, - ] - ); - - $server->register_tool( - [ - 'name' => 'move_file', - 'description' => 'Moves a file.', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'path' => [ - 'type' => 'string', - 'description' => 'The path of the file to move.', - ], - 'new_path' => [ - 'type' => 'string', - 'description' => 'The new path of the file.', - ], - ], - 'required' => [ 'path', 'new_path' ], - ], - 'callable' => function ( $params ) { - $path = $params['path']; - $new_path = $params['new_path']; - return rename( $path, $new_path ); - }, - ] - ); - - $server->register_tool( - [ - 'name' => 'list_files', - 'description' => 'Lists files in a directory.', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'path' => [ - 'type' => 'string', - 'description' => 'The path of the directory to list files from.', - ], - ], - 'required' => [ 'path' ], - ], - 'callable' => function ( $params ) { - $path = $params['path']; - return scandir( $path ); - }, - ] - ); - - } -} - diff --git a/src/Tools/ImageTools.php b/src/Tools/ImageTools.php deleted file mode 100644 index 76a31b3..0000000 --- a/src/Tools/ImageTools.php +++ /dev/null @@ -1,84 +0,0 @@ -client = $client; - $this->server = $server; - } - - - public function get_tools(){ - return [ - $this->image_generation_tool(), - $this->image_modification_tool() - ]; - } - - public function image_generation_tool() { - return new Tool( - [ - 'name' => 'generate_image', - 'description' => 'Generates an image', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'prompt' => [ - 'type' => 'string', - 'description' => 'The prompt for generating the image.', - ], - 'title' => [ - 'type' => 'string', - 'description' => 'the title of the image, also used in filename.', - ], - ], - 'required' => [ 'prompt' ], - ], - 'callable' => function ( $params ) { - if (empty($params['title'])) { - $params['title'] = $params['prompt']; - } - return $this->client->get_image_from_ai_service( $params['prompt'], $params['title'] ); - }, - ] - ); - } - - public function image_modification_tool() { - - return new Tool( - [ - '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_uri = 'media://' . $params['media_id']; - $media_resource = $this->server->get_resource_data( $media_uri ); - return $this->client->modify_image_with_ai( $params['prompt'], $media_resource ); - }, - ] - ); - - } -} diff --git a/src/Tools/MiscTools.php b/src/Tools/MiscTools.php deleted file mode 100644 index 3fb68bd..0000000 --- a/src/Tools/MiscTools.php +++ /dev/null @@ -1,45 +0,0 @@ -server = $server; - } - - public function get_tools(){ - $tools = []; - - $tools[] = new Tool( [ - 'name' => 'list_tools', - 'description' => 'Lists all available tools with their descriptions.', - 'inputSchema' => [ - 'type' => 'object', // Object type for input - 'properties' => [ - 'placeholder' => [ - 'type' => 'integer', - 'description' => '', - ] - ], - 'required' => [], // No required fields - ], - 'callable' => function () { - // Get all capabilities - $capabilities = $this->server->get_capabilities(); - - // Prepare a list of tools with their descriptions - $tool_list = 'Return this to the user as a bullet list with each tool name and description on a new line. \n\n'; - $tool_list .= print_r($capabilities['methods'], true); - - // Return the formatted string of tools with descriptions - return $tool_list; - }, - ] ); - - return $tools; - } -} diff --git a/src/Tools/URLTools.php b/src/Tools/URLTools.php deleted file mode 100644 index 438f25d..0000000 --- a/src/Tools/URLTools.php +++ /dev/null @@ -1,67 +0,0 @@ -server = $server; - } - - public function get_tools(){ - $tools = []; - - $tools[] = new Tool( [ - 'name' => 'retrieve_page', - 'description' => 'Retrieves a page from the web.', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'url' => [ - 'type' => 'string', - 'description' => 'The URL of the page to retrieve.', - ], - ], - 'required' => [ 'url' ], - ], - 'callable' => function ( $params ) { - $url = $params['url']; - $response = wp_remote_get( $url ); - $body = wp_remote_retrieve_body( $response ); - return $body; - }, - ] - ); - - $tools[] = new Tool( [ - 'name' => 'retrieve_rss_feed', - 'description' => 'Retrieves an RSS feed.', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'url' => [ - 'type' => 'string', - 'description' => 'The URL of the RSS feed to retrieve.', - ], - ], - 'required' => [ 'url' ], - ], - 'callable' => function ( $params ) { - $url = $params['url']; - $response = wp_remote_get( $url ); - $body = wp_remote_retrieve_body( $response ); - $rss = simplexml_load_string( $body ); - return $rss; - }, - ] - ); - - return $tools; - } -} diff --git a/src/Utils/CliLogger.php b/src/Utils/CliLogger.php new file mode 100644 index 0000000..9418e13 --- /dev/null +++ b/src/Utils/CliLogger.php @@ -0,0 +1,105 @@ +