diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..84c3bd6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,71 @@ +# WP-CLI AI Command + +A WP-CLI package that enables AI interactions with WordPress via the Model Context Protocol (MCP). + +## Project Overview + +- **Type**: WP-CLI package +- **PHP Version**: 8.2+ +- **Namespace**: `McpWp\AiCommand` +- **License**: Apache-2.0 + +## Architecture + +``` +src/ +├── AI/ # AI client implementations (WpAiClient, AiClient) +├── MCP/ # MCP protocol implementation +│ ├── Servers/ # MCP servers (WP_CLI tools) +│ └── Client.php # MCP client +├── Utils/ # Utilities (logging, config) +├── AiCommand.php # Main `wp ai` command +├── CredentialsCommand.php # `wp ai credentials` subcommand +├── McpCommand.php # `wp mcp` command +└── McpServerCommand.php # `wp mcp server` subcommand +``` + +## Development Commands + +```bash +# Run all tests +composer test + +# Individual test suites +composer phpunit # PHPUnit tests +composer behat # Behat integration tests +composer phpcs # Code style checks +composer phpstan # Static analysis +composer lint # Linter + +# Fix code style +composer format # or: composer phpcbf + +# Prepare test environment +composer prepare-tests +``` + +## Code Style + +- Uses WP_CLI_CS ruleset (WordPress coding standards) +- Global namespace prefix: `McpWp\AiCommand` (classes) or `ai_command` (functions/variables) +- Run `composer format` to auto-fix style issues + +## Key Dependencies + +- `logiscape/mcp-sdk-php`: MCP SDK for PHP +- `mcp-wp/mcp-server`: MCP server implementation +- `wp-cli/wp-cli`: WP-CLI framework +- `wordpress/wp-ai-client`: WordPress AI client (dev dependency for testing) + +## WP-CLI Commands + +- `wp ai` - Main AI interaction command +- `wp ai credentials list|set|delete` - Manage AI provider API keys +- `wp mcp prompt` - MCP prompt handling +- `wp mcp server add|list|remove|update` - Manage MCP servers + +## Testing Notes + +- PHPUnit tests are in `tests/phpunit/` +- Behat feature tests are in `features/` +- PHPStan config: `phpstan.neon.dist` diff --git a/README.md b/README.md index 0ab8420..3166a55 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,23 @@ To install the latest development version of this package, use the following com wp package install mcp-wp/ai-command:dev-main ``` -Right now, the plugin requires a WordPress site with the [AI Services plugin](https://wordpress.org/plugins/ai-services) installed. +This package uses the **WP AI Client** (available in WordPress 7.0+ or via the AI plugin) for AI functionality. + +### Configuration + +Configure credentials for AI providers: + +```bash +# Configure credentials for AI providers +wp ai credentials set openai sk-proj-YOUR-API-KEY +wp ai credentials set anthropic sk-ant-YOUR-API-KEY +wp ai credentials set google YOUR-GOOGLE-API-KEY + +# List configured credentials +wp ai credentials list +``` + +Credentials are stored in the WordPress database and can also be managed through the WordPress admin settings screen. ### Reporting a bug diff --git a/ai-command.php b/ai-command.php index d959925..5646c39 100644 --- a/ai-command.php +++ b/ai-command.php @@ -12,7 +12,5 @@ require_once __DIR__ . '/vendor/autoload.php'; } -WP_CLI::add_command( 'ai', AiCommand::class ); -WP_CLI::add_command( 'mcp prompt', AiCommand::class ); -WP_CLI::add_command( 'mcp', McpCommand::class ); -WP_CLI::add_command( 'mcp server', McpServerCommand::class ); +WP_CLI::add_command( 'ai', \McpWp\AiCommand\AiCommand::class ); +WP_CLI::add_command( 'ai credentials', \McpWp\AiCommand\CredentialsCommand::class ); diff --git a/composer.json b/composer.json index fdc41c4..09dd1c9 100644 --- a/composer.json +++ b/composer.json @@ -7,19 +7,17 @@ "authors": [], "require": { "php": "^8.2", - "logiscape/mcp-sdk-php": "dev-main", - "mcp-wp/mcp-server": "dev-main", - "wp-cli/wp-cli": "^2.11" + "wp-cli/wp-cli": "^2.12" }, "require-dev": { "humbug/php-scoper": "^0.18.17", "phpstan/extension-installer": "^1.4", - "phpstan/phpstan": "^2.1", - "phpstan/phpstan-strict-rules": "^2.0", + "phpstan/phpstan": "^1.12", + "phpstan/phpstan-strict-rules": "^1.6", "roave/security-advisories": "dev-latest", + "wordpress/wp-ai-client": "^0.2.1", "wp-cli/extension-command": "^2.1", - "wp-cli/wp-cli-tests": "^v4.3.9", - "wpackagist-plugin/ai-services": "^0.6.0" + "wp-cli/wp-cli-tests": "^5.0" }, "repositories":[ { @@ -48,19 +46,16 @@ }, "bundled": false, "commands": [ - "ai", - "mcp prompt", - "mcp server add", - "mcp server list", - "mcp server remove", - "mcp server update" + "ai prompt", + "ai credentials list", + "ai credentials set", + "ai credentials delete" ], "installer-disable": true }, "autoload": { "psr-4": { - "McpWp\\AiCommand\\": "src/", - "McpWp\\AiCommand\\MCP\\": "src/MCP" + "McpWp\\AiCommand\\": "src/" }, "files": [ "ai-command.php" diff --git a/features/ai-credentials.feature b/features/ai-credentials.feature new file mode 100644 index 0000000..d3e0a59 --- /dev/null +++ b/features/ai-credentials.feature @@ -0,0 +1,27 @@ +Feature: AI Credentials command + Scenario: Credentials management with WP AI Client + Given a WP installation + + When I run `wp ai credentials list` + Then STDOUT should contain: + """ + No credentials configured. + """ + + When I run `wp ai credentials set openai sk-test-key` + Then STDOUT should contain: + """ + Success: Credentials for 'openai' saved. + """ + + When I run `wp ai credentials list` + Then STDOUT should contain: + """ + openai + """ + + When I run `wp ai credentials delete openai` + Then STDOUT should contain: + """ + Success: Credentials for 'openai' deleted. + """ diff --git a/features/ai.feature b/features/ai.feature index ffbeab4..52bab7d 100644 --- a/features/ai.feature +++ b/features/ai.feature @@ -1,27 +1,22 @@ Feature: AI command - Scenario: Missing AI Services plugin - When I try `wp ai "Hello World"` + Scenario: AI prompt requires WordPress + When I try `wp ai prompt "Hello World"` Then STDERR should contain: """ This does not seem to be a WordPress installation. """ - When I try `wp ai "Hello World" --skip-wordpress` + Scenario: Skip WordPress not implemented + When I try `wp ai prompt "Hello World" --skip-wordpress` Then STDERR should contain: """ Not implemented yet. """ + Scenario: AI prompt requires configured models Given a WP installation - When I try `wp ai "Hello World"` + When I try `wp ai prompt "Hello World"` Then STDERR should contain: """ - This command currently requires the AI Services plugin. - """ - - When I run `wp plugin install ai-services --activate` - When I try `wp ai "Hello World"` - Then STDERR should contain: - """ - No service satisfying the given arguments is registered and available. + No models found """ diff --git a/features/mcp-server.feature b/features/mcp-server.feature deleted file mode 100644 index 3708993..0000000 --- a/features/mcp-server.feature +++ /dev/null @@ -1,75 +0,0 @@ -Feature: MCP server command - Scenario: CRUD - When I run `wp mcp server add foo "https://foo.example.com/mcp"` - Then STDOUT should contain: - """ - Server added. - """ - - When I run `wp mcp server add bar "https://bar.example.com/mcp"` - And I run `wp mcp server add baz "https://baz.example.com/mcp"` - And I run `wp mcp server list` - Then STDOUT should be a table containing rows: - | name | server | status | - | foo | https://foo.example.com/mcp | active | - | bar | https://bar.example.com/mcp | active | - | baz | https://baz.example.com/mcp | active | - - When I run `wp mcp server remove bar baz` - Then STDOUT should contain: - """ - Success: Removed 2 of 2 servers. - """ - - When I run `wp mcp server list` - Then STDOUT should contain: - """ - foo.example.com - """ - And STDOUT should not contain: - """ - bar.example.com - """ - And STDOUT should not contain: - """ - baz.example.com - """ - - When I try `wp mcp server add foo "https://foo.example.com/mcp"` - Then STDERR should contain: - """ - Server already exists. - """ - - When I run `wp mcp server add bar "https://bar.example.com/mcp"` - And I run `wp mcp server add baz "https://baz.example.com/mcp"` - And I run `wp mcp server update bar --status=inactive` - Then STDOUT should contain: - """ - Server updated. - """ - - When I run `wp mcp server list --status=inactive` - Then STDOUT should be a table containing rows: - | name | server | status | - | bar | https://bar.example.com/mcp | inactive | - - And STDOUT should not contain: - """ - foo.example.com - """ - And STDOUT should not contain: - """ - baz.example.com - """ - - When I run `wp mcp server update foo --name=fabulous` - Then STDOUT should contain: - """ - Server updated. - """ - - When I run `wp mcp server list` - Then STDOUT should be a table containing rows: - | name | server | status | - | fabulous | https://foo.example.com/mcp | active | diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 9d0d0fe..57b6e9c 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -4,15 +4,11 @@ parameters: - ai-command.php - src/ scanDirectories: - - vendor/wpackagist-plugin/ai-services/includes - - vendor/wpackagist-plugin/ai-services/third-party + - vendor/wordpress/wp-ai-client/src - vendor/wp-cli/wp-cli/bundle/rmccue/requests - vendor/wp-cli/wp-cli/php bootstrapFiles: - tests/phpstan/bootstrap.php reportMaybesInMethodSignatures: false - strictRules: - disallowedEmpty: false - strictArrayFilter: false includes: - phar://phpstan.phar/conf/bleedingEdge.neon diff --git a/src/AI/AiClient.php b/src/AI/AiClient.php deleted file mode 100644 index 6a3b613..0000000 --- a/src/AI/AiClient.php +++ /dev/null @@ -1,247 +0,0 @@ ->, server: string, callback: callable} - */ -class AiClient { - private bool $needs_approval = true; - - /** - * @param array $tools List of tools. - * @param bool $approval_mode Whether tool usage needs to be approved. - * @param string|null $model Model to use. - * - * @phpstan-param ToolDefinition[] $tools - */ - public function __construct( - private readonly array $tools, - private readonly bool $approval_mode, - private readonly ?string $service, - private readonly ?string $model - ) {} - - /** - * Calls a given tool. - * - * @param string $tool_name Tool name. - * @param mixed $tool_args Tool args. - * @return mixed - */ - private function call_tool( string $tool_name, mixed $tool_args ): mixed { - foreach ( $this->tools as $tool ) { - if ( $tool_name === $tool['name'] ) { - return call_user_func( $tool['callback'], $tool_args ); - } - } - - throw new InvalidArgumentException( 'Tool "' . $tool_name . '" not found.' ); - } - - /** - * Returns the name of the server a given tool is coming from. - * - * @param string $tool_name Tool name. - * @return mixed - */ - private function get_tool_server_name( string $tool_name ): mixed { - foreach ( $this->tools as $tool ) { - if ( $tool_name === $tool['name'] ) { - return $tool['server']; - } - } - - throw new InvalidArgumentException( 'Tool "' . $tool_name . '" not found.' ); - } - - public function call_ai_service_with_prompt( string $prompt ): void { - $parts = new Parts(); - $parts->add_text_part( $prompt ); - $content = new Content( Content_Role::USER, $parts ); - - $this->call_ai_service( [ $content ] ); - } - - /** - * Calls AI service with given contents. - * - * @param Content[] $contents Contents to send to AI. - * @return void - */ - private function call_ai_service( $contents ): void { - // See https://github.com/felixarntz/ai-services/issues/25. - // Temporarily ignore error because eventually this should not be needed anymore. - // @phpstan-ignore function.notFound - add_filter( - 'map_meta_cap', - static function () { - return [ 'exist' ]; - } - ); - - $new_contents = $contents; - - try { - $service = ai_services()->get_available_service( - [ - 'slugs' => $this->service ? [ $this->service ] : null, - 'capabilities' => [ - AI_Capability::MULTIMODAL_INPUT, - AI_Capability::TEXT_GENERATION, - AI_Capability::FUNCTION_CALLING, - ], - ] - ); - - $all_tools = $this->tools; - - $tools = new Tools(); - - if ( ! empty( $all_tools ) ) { - if ( 'openai' === $service->get_service_slug() ) { - $all_tools = array_slice( $all_tools, 0, 128 ); - } elseif ( 'google' === $service->get_service_slug() ) { - $all_tools = array_slice( $all_tools, 0, 512 ); - } - - $tools->add_function_declarations_tool( $all_tools ); - } - - /** - * Text generation model. - * - * @var With_Text_Generation&Generative_AI_Model $model - */ - $model = $service - ->get_model( - [ - 'feature' => 'text-generation', - 'model' => $this->model, - 'tools' => $tools, - 'capabilities' => [ - AI_Capability::MULTIMODAL_INPUT, - AI_Capability::TEXT_GENERATION, - AI_Capability::FUNCTION_CALLING, - ], - ], - [ - 'options' => [ - 'timeout' => 6000, - ], - ] - ); - - $candidates = $model->generate_text( $contents ); - - $text = ''; - - $parts = $candidates->get( 0 )->get_content()?->get_parts() ?? new Parts(); - - foreach ( $parts as $part ) { - if ( $part instanceof Text_Part ) { - if ( '' !== $text ) { - $text .= "\n\n"; - } - $text .= $part->get_text(); - } elseif ( $part instanceof Function_Call_Part ) { - WP_CLI::debug( "Suggesting tool call: '{$part->get_name()}'.", 'ai-command' ); - - // 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 ); - - $can_call_tool = true; - - if ( $this->approval_mode && $this->needs_approval ) { - WP_CLI::line( - WP_CLI::colorize( - sprintf( - "Run tool \"%%b%s%%n\" from \"%%b%s%%n\"?\n%%yNote:%%n Running tools from untrusted servers could have unintended consequences. Review each action carefully before approving.", - $part->get_name(), - $this->get_tool_server_name( $part->get_name() ) - ) - ) - ); - $result = menu( - [ - 'y' => 'Allow once', - 'a' => 'Always allow', - 'n' => 'Deny once', - ], - 'y', - 'Run tool? Choose between 1-3', - ); - - if ( 'n' === $result ) { - $can_call_tool = false; - } elseif ( 'a' === $result ) { - $this->needs_approval = false; - } - } - - if ( $can_call_tool ) { - $function_result = $this->call_tool( - $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'], - ]; - - WP_CLI::debug( "Called the '{$part->get_name()}' tool.", 'ai-command' ); - - $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; - } else { - WP_CLI::debug( "Function call denied:'{$part->get_name()}'.", 'ai-command' ); - } - } - } - - if ( $new_contents !== $contents ) { - $this->call_ai_service( $new_contents ); - return; - } - - // Keep the session open to continue chatting. - - WP_CLI::line( WP_CLI::colorize( "%G$text%n " ) ); - - $response = 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/AI/WpAiClient.php b/src/AI/WpAiClient.php new file mode 100644 index 0000000..55f28c4 --- /dev/null +++ b/src/AI/WpAiClient.php @@ -0,0 +1,97 @@ +service && $this->model ) { + $prompt_builder = $prompt_builder->using_model_preference( [ $this->service, $this->model ] ); + } elseif ( $this->model ) { + // If only model is specified without a service, try common providers. + // This provides a reasonable fallback that works with most configurations. + // The WP AI Client will automatically use the first available provider + // that has the specified model and is properly configured. + $prompt_builder = $prompt_builder->using_model_preference( + [ 'anthropic', $this->model ], + [ 'openai', $this->model ], + [ 'google', $this->model ] + ); + } + + // Generate text response. + $text = $prompt_builder->generate_text(); + + // Output the response. + WP_CLI::line( WP_CLI::colorize( "%G$text%n" ) ); + + // Keep the session open for follow-up questions. + $this->continue_conversation(); + + } catch ( Exception $e ) { + WP_CLI::error( $e->getMessage() ); + } + } + + /** + * Continues the conversation with follow-up prompts. + */ + private function continue_conversation(): void { + $user_response = prompt( '', false, '' ); + + if ( empty( $user_response ) ) { + return; + } + + try { + $prompt_builder = \WordPress\AI_Client\AI_Client::prompt( $user_response ); + + if ( $this->service && $this->model ) { + $prompt_builder = $prompt_builder->using_model_preference( [ $this->service, $this->model ] ); + } + + $text = $prompt_builder->generate_text(); + + WP_CLI::line( WP_CLI::colorize( "%G$text%n" ) ); + + $this->continue_conversation(); + } catch ( Exception $e ) { + WP_CLI::error( $e->getMessage() ); + } + } +} diff --git a/src/AiCommand.php b/src/AiCommand.php index 6e2ca5d..a60a082 100644 --- a/src/AiCommand.php +++ b/src/AiCommand.php @@ -2,12 +2,7 @@ namespace McpWp\AiCommand; -use Mcp\Client\ClientSession; -use McpWp\AiCommand\AI\AiClient; -use McpWp\AiCommand\MCP\Client; -use McpWp\AiCommand\Utils\CliLogger; -use McpWp\AiCommand\Utils\McpConfig; -use McpWp\MCP\Servers\WordPress\WordPress; +use McpWp\AiCommand\AI\WpAiClient; use WP_CLI; use WP_CLI\Utils; use WP_CLI_Command; @@ -15,9 +10,7 @@ /** * AI command class. * - * Allows interacting with an LLM using MCP. - * - * @phpstan-import-type ToolDefinition from AiClient + * Allows interacting with an LLM using the WP AI Client. */ class AiCommand extends WP_CLI_Command { @@ -29,45 +22,33 @@ class AiCommand extends WP_CLI_Command { * * : AI prompt. * - * [--skip-builtin-servers[=]] - * : Skip loading the built-in servers for WP-CLI and the current WordPress site. - * Can be set to 'all' (skip both), 'cli' (skip the WP-CLI server), - * or 'wp' (skip the WordPress server). - * * [--skip-wordpress] * : Run command without loading WordPress. (Not implemented yet) * - * [--approval-mode] - * : Approve tool usage before running. - * * [--service=] * : Manually specify the AI service to use. - * Depends on the available AI services. * Examples: 'google', 'anthropic', 'openai'. * * [--model=] * : Manually specify the LLM model that should be used. - * Depends on the available AI services. - * Examples: 'gemini-2.0-flash', 'gpt-4o'. + * Examples: 'gemini-2.0-flash', 'gpt-4o', 'claude-sonnet-4-5'. * * ## EXAMPLES * - * # Get data from WordPress - * $ wp ai "What are the titles of my last three posts?" - * - Hello world - * - My awesome post - * - Another post + * # Ask a simple question + * $ wp ai prompt "Explain WordPress in one sentence" + * WordPress is a free and open-source content management system... * - * # Interact with multiple MCP servers. - * $ wp ai "Take file foo.txt and create a new blog post from it" - * Success: Blog post created. + * # Use a specific model + * $ wp ai prompt "Summarize the history of WordPress" --model=gpt-4o + * WordPress was created in 2003... * * @when before_wp_load * * @param string[] $args Indexed array of positional arguments. * @param array $assoc_args Associative arguments. */ - public function __invoke( array $args, array $assoc_args ): void { + public function prompt( array $args, array $assoc_args ): void { $with_wordpress = null === Utils\get_flag_value( $assoc_args, 'skip-wordpress' ); if ( $with_wordpress ) { WP_CLI::get_runner()->load_wordpress(); @@ -75,133 +56,15 @@ public function __invoke( array $args, array $assoc_args ): void { WP_CLI::error( 'Not implemented yet.' ); } - if ( ! function_exists( '\ai_services' ) ) { - WP_CLI::error( 'This command currently requires the AI Services plugin. You can install it with `wp plugin install ai-services --activate`.' ); + // Ensure WP AI Client is available. + if ( ! class_exists( '\WordPress\AI_Client\AI_Client' ) ) { + WP_CLI::error( 'This command requires the WP AI Client. Please ensure WordPress 7.0+ or the AI plugin is installed and activated.' ); } - $skip_builtin_servers = Utils\get_flag_value( $assoc_args, 'skip-builtin-servers', 'all' ); - - $sessions = $this->get_sessions( $with_wordpress && 'cli' === $skip_builtin_servers, 'wp' === $skip_builtin_servers ); - $tools = $this->get_tools( $sessions ); - - $approval_mode = (bool) Utils\get_flag_value( $assoc_args, 'approval-mode', false ); - $service = Utils\get_flag_value( $assoc_args, 'service' ); - $model = Utils\get_flag_value( $assoc_args, 'model' ); - - $ai_client = new AiClient( $tools, $approval_mode, $service, $model ); + $service = Utils\get_flag_value( $assoc_args, 'service' ); + $model = Utils\get_flag_value( $assoc_args, 'model' ); + $ai_client = new WpAiClient( $service, $model ); $ai_client->call_ai_service_with_prompt( $args[0] ); } - - /** - * Returns a combined list of all tools for all existing MCP client sessions. - * - * @param array $sessions List of available sessions. - * @return array List of tools. - * - * @phpstan-return ToolDefinition[] - */ - protected function get_tools( array $sessions ): array { - $function_declarations = []; - - foreach ( $sessions as $name => $session ) { - foreach ( $session->listTools()->tools as $mcp_tool ) { - $parameters = json_decode( - json_encode( $mcp_tool->inputSchema->jsonSerialize(), JSON_THROW_ON_ERROR ), - true, - 512, - JSON_THROW_ON_ERROR - ); - 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, - 'server' => $name, - 'callback' => static function ( mixed $tool_args ) use ( $mcp_tool, $session ) { - $result = $session->callTool( $mcp_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], JSON_THROW_ON_ERROR ), - true, - 512, - JSON_THROW_ON_ERROR - ); - }, - ]; - } - } - - return $function_declarations; - } - - /** - * Returns a list of MCP client sessions for each MCP server that is configured. - * - * @param bool $with_wp_server Whether a session for the built-in WordPress MCP server should be created. - * @param bool $with_cli_server Whether a session for the built-in WP-CLI MCP server should be created. - * @return ClientSession[] - */ - public function get_sessions( bool $with_wp_server, bool $with_cli_server ): array { - $logger = new CliLogger(); - - $sessions = []; - - if ( $with_cli_server ) { - $sessions['current_site'] = ( new Client( $logger ) )->connect( - MCP\Servers\WP_CLI\WP_CLI::class - ); - } - - if ( $with_wp_server ) { - $sessions['wp_cli'] = ( new Client( $logger ) )->connect( - WordPress::class - ); - } - - $servers = ( new McpConfig() )->get_servers(); - - foreach ( $servers as $args ) { - if ( 'active' !== $args['status'] ) { - continue; - } - - $server = $args['server']; - - if ( str_starts_with( $server, 'http://' ) || str_starts_with( $server, 'https://' ) ) { - $sessions[] = ( new Client( $logger ) )->connect( - $server - ); - continue; - } - - $server = explode( ' ', $server ); - $cmd_or_url = array_shift( $server ); - - $sessions[ $args['name'] ] = ( new Client( $logger ) )->connect( - $cmd_or_url, - $server, - ); - } - - return $sessions; - } } diff --git a/src/CredentialsCommand.php b/src/CredentialsCommand.php new file mode 100644 index 0000000..bfa90c8 --- /dev/null +++ b/src/CredentialsCommand.php @@ -0,0 +1,142 @@ + $assoc_args Associative arguments. + */ + public function list( array $args, array $assoc_args ): void { + $this->ensure_wp_ai_client_available(); + + WP_CLI::get_runner()->load_wordpress(); + + $credentials = get_option( 'wp_ai_client_provider_credentials', [] ); + + if ( empty( $credentials ) ) { + WP_CLI::log( 'No credentials configured.' ); + return; + } + + $rows = []; + foreach ( $credentials as $provider => $data ) { + $rows[] = [ + 'provider' => $provider, + 'status' => ! empty( $data['api_key'] ) ? 'configured' : 'not configured', + ]; + } + + Utils\format_items( 'table', $rows, [ 'provider', 'status' ] ); + } + + /** + * Set credentials for an AI provider. + * + * ## OPTIONS + * + * + * : The AI provider to configure (e.g., 'openai', 'anthropic', 'google'). + * + * + * : The API key for the provider. + * + * ## EXAMPLES + * + * # Set OpenAI credentials + * $ wp ai credentials set openai sk-proj-... + * Success: Credentials for 'openai' saved. + * + * @when before_wp_load + * + * @param string[] $args Indexed array of positional arguments. + * @param array $assoc_args Associative arguments. + */ + public function set( array $args, array $assoc_args ): void { + $this->ensure_wp_ai_client_available(); + + WP_CLI::get_runner()->load_wordpress(); + + $provider = $args[0]; + $api_key = $args[1]; + + $credentials = get_option( 'wp_ai_client_provider_credentials', [] ); + $credentials[ $provider ] = [ 'api_key' => $api_key ]; + + update_option( 'wp_ai_client_provider_credentials', $credentials ); + + WP_CLI::success( "Credentials for '$provider' saved." ); + } + + /** + * Delete credentials for an AI provider. + * + * ## OPTIONS + * + * + * : The AI provider to remove credentials for. + * + * ## EXAMPLES + * + * # Delete OpenAI credentials + * $ wp ai credentials delete openai + * Success: Credentials for 'openai' deleted. + * + * @when before_wp_load + * + * @param string[] $args Indexed array of positional arguments. + * @param array $assoc_args Associative arguments. + */ + public function delete( array $args, array $assoc_args ): void { + $this->ensure_wp_ai_client_available(); + + WP_CLI::get_runner()->load_wordpress(); + + $provider = $args[0]; + + $credentials = get_option( 'wp_ai_client_provider_credentials', [] ); + + if ( ! isset( $credentials[ $provider ] ) ) { + WP_CLI::error( "No credentials found for '$provider'." ); + } + + unset( $credentials[ $provider ] ); + update_option( 'wp_ai_client_provider_credentials', $credentials ); + + WP_CLI::success( "Credentials for '$provider' deleted." ); + } + + /** + * Ensures the WP AI Client is available. + */ + private function ensure_wp_ai_client_available(): void { + if ( ! class_exists( '\WordPress\AI_Client\AI_Client' ) ) { + WP_CLI::error( 'The WP AI Client is not available. Please ensure the WP AI plugin is installed and activated.' ); + } + } +} diff --git a/src/MCP/Client.php b/src/MCP/Client.php deleted file mode 100644 index 8043441..0000000 --- a/src/MCP/Client.php +++ /dev/null @@ -1,99 +0,0 @@ -logger = $logger ?? new NullLogger(); - - parent::__construct( $this->logger ); - } - - /** - * @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 - ); - - [$read_stream, $write_stream] = $transport->connect(); - - $session = new InMemorySession( - $read_stream, - $write_stream, - $this->logger - ); - - $session->initialize(); - - return $session; - } - - // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url - $url_parts = parse_url( $command_or_url ); - - if ( - isset( $url_parts['scheme'], $url_parts['host'] ) && in_array( strtolower( $url_parts['scheme'] ), [ 'http', 'https' ], true ) - ) { - $options = [ - // Just for local debugging. - 'verify' => false, - ]; - if ( ! empty( $url_parts['user'] ) && ! empty( $url_parts['pass'] ) ) { - $options['auth'] = [ $url_parts['user'], $url_parts['pass'] ]; - } - - $url = $url_parts['scheme'] . '://' . $url_parts['host'] . ( $url_parts['path'] ?? '' ); - - $transport = new HttpTransport( $url, $options, $this->logger ); - - [$read_stream, $write_stream] = $transport->connect(); - - // Initialize the client session with the obtained streams - $session = new InMemorySession( - $read_stream, - $write_stream, - $this->logger - ); - - // Initialize the session (e.g., perform handshake if necessary) - $session->initialize(); - $this->logger->info( 'Session initialized successfully' ); - - return $session; - } - - return parent::connect( $command_or_url, $args, $env, $read_timeout ); - } -} diff --git a/src/MCP/HttpTransport.php b/src/MCP/HttpTransport.php deleted file mode 100644 index 6ac63d6..0000000 --- a/src/MCP/HttpTransport.php +++ /dev/null @@ -1,187 +0,0 @@ - $options Requests options. - * @param LoggerInterface|null $logger PSR-3 compliant logger. - * - * @throws InvalidArgumentException If the URL is empty. - */ - public function __construct( - private readonly string $url, - private readonly array $options = [], - ?LoggerInterface $logger = null, - ) { - if ( empty( $url ) ) { - throw new InvalidArgumentException( 'URL cannot be empty' ); - } - $this->logger = $logger ?? new NullLogger(); - } - - /** - * @return array{0: MemoryStream, 1: MemoryStream} - */ - public function connect(): array { - $shared_stream = new class($this->url,$this->options, $this->logger) extends MemoryStream { - private LoggerInterface $logger; - - private ?string $session_id = null; - - /** - * @param string $url URL to connect to. - * @param array $options Requests options. - * @param LoggerInterface $logger PSR-3 compliant logger. - */ - public function __construct( private readonly string $url, private readonly array $options, LoggerInterface $logger ) { - $this->logger = $logger; - } - - /** - * Send a JsonRpcMessage or Exception to the server via SSE. - * - * @param JsonRpcMessage|Exception $item 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 $item ): void { - if ( ! $item instanceof JsonRpcMessage ) { - throw new InvalidArgumentException( 'Only JsonRpcMessage instances can be sent.' ); - } - - /** - * @var Response $response - */ - $response = \WP_CLI\Utils\http_request( - 'POST', - $this->url, - // Wrong PHPDoc in Requests? - // @phpstan-ignore argument.type - json_encode( $item, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES ), - [ - 'Content-Type' => 'application/json', - 'Mcp-Session-Id' => $this->session_id, - ], - $this->options - ); - - if ( isset( $response->headers['mcp-session-id'] ) && ! isset( $this->session_id ) ) { - $this->session_id = $response->headers['mcp-session-id']; - } - - if ( empty( $response->body ) ) { - return; - } - - $data = json_decode( $response->body, true, 512, JSON_THROW_ON_ERROR ); - - $this->logger->debug( 'Received response for sent message: ' . json_encode( $data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT ) ); - - $json_rpc_response = $this->instantiateJsonRpcMessage( $data ); - - parent::send( $json_rpc_response ); - } - - /** - * Instantiate a JsonRpcMessage from decoded data. - * - * @param array $data The decoded JSON data. - * - * @return JsonRpcMessage The instantiated JsonRpcMessage object. - * - * @throws InvalidArgumentException If the message structure is invalid. - */ - private function instantiateJsonRpcMessage( array $data ): JsonRpcMessage { - if ( ! isset( $data['jsonrpc'] ) || '2.0' !== $data['jsonrpc'] ) { - throw new InvalidArgumentException( 'Invalid JSON-RPC version.' ); - } - - if ( isset( $data['method'] ) ) { - // It's a Request or Notification - if ( isset( $data['id'] ) ) { - // It's a Request - return new JsonRpcMessage( - new JSONRPCRequest( - '2.0', - new RequestId( $data['id'] ), - $data['params'] ?? null, - $data['method'] - ) - ); - } - - // It's a Notification - return new JsonRpcMessage( - new JSONRPCNotification( - '2.0', - $data['params'] ?? null, - $data['method'] - ) - ); - } - - if ( isset( $data['result'] ) || isset( $data['error'] ) ) { - // It's a Response or Error - if ( isset( $data['error'] ) ) { - // It's an Error - $error_data = $data['error']; - return new JsonRpcMessage( - new JSONRPCError( - '2.0', - new RequestId( $data['id'] ?? 0 ), - new JsonRpcErrorObject( - $error_data['code'], - $error_data['message'], - $error_data['data'] ?? null - ) - ) - ); - } - - // It's a Response - return new JsonRpcMessage( - new JSONRPCResponse( - '2.0', - new RequestId( $data['id'] ?? 0 ), - $data['result'] - ) - ); - } - - throw new InvalidArgumentException( 'Invalid JSON-RPC message structure.' ); - } - }; - - return [ $shared_stream, $shared_stream ]; - } -} diff --git a/src/MCP/InMemorySession.php b/src/MCP/InMemorySession.php deleted file mode 100644 index f12d423..0000000 --- a/src/MCP/InMemorySession.php +++ /dev/null @@ -1,165 +0,0 @@ -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. - * - * @phpstan-param Request $request - */ - 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 ) { - // Coming from HttpTransport. - if ( is_array( $inner_message->result ) ) { - return $result_type::fromResponseData( $inner_message->result ); - } - - // InMemoryTransport already returns the correct instances. - 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 deleted file mode 100644 index 055f61b..0000000 --- a/src/MCP/InMemoryTransport.php +++ /dev/null @@ -1,56 +0,0 @@ -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/ProxySession.php b/src/MCP/ProxySession.php deleted file mode 100644 index a78b390..0000000 --- a/src/MCP/ProxySession.php +++ /dev/null @@ -1,115 +0,0 @@ -getRequest(); - $actual_request = $request->getRequest(); - $method = $actual_request->method; - - $this->logger->info( 'Proxying request: ' . json_encode( $actual_request ) ); - - $result = null; - - if ( ! isset( $this->client_session ) ) { - $client = new Client( $this->logger ); - $this->client_session = $client->connect( - $this->url - ); - } - - switch ( get_class( $actual_request ) ) { - case InitializeRequest::class: - $result = $this->client_session->sendRequest( $actual_request, InitializeResult::class ); - break; - case SubscribeRequest::class: - case UnsubscribeRequest::class: - case PingRequest::class: - $result = $this->client_session->sendRequest( $actual_request, EmptyResult::class ); - break; - case ListResourcesRequest::class: - $result = $this->client_session->sendRequest( $actual_request, ListResourcesResult::class ); - break; - case ListToolsRequest::class: - $result = $this->client_session->sendRequest( $actual_request, ListToolsResult::class ); - break; - case CallToolRequest::class: - $result = $this->client_session->sendRequest( $actual_request, CallToolResult::class ); - break; - case ReadResourceRequest::class: - $result = $this->client_session->sendRequest( $actual_request, ReadResourceResult::class ); - break; - case ListPromptsRequest::class: - $result = $this->client_session->sendRequest( $actual_request, ListPromptsResult::class ); - break; - case GetPromptRequest::class: - $result = $this->client_session->sendRequest( $actual_request, GetPromptResult::class ); - break; - case CompleteRequest::class: - $result = $this->client_session->sendRequest( $actual_request, CompleteResult::class ); - break; - } - - if ( null === $result ) { - throw new InvalidArgumentException( "Unhandled proxied request for method: $method / " . get_class( $actual_request ) ); - } - - $responder->sendResponse( $result ); - } -} diff --git a/src/MCP/Servers/WP_CLI/Tools/CliCommands.php b/src/MCP/Servers/WP_CLI/Tools/CliCommands.php deleted file mode 100644 index 8a6837c..0000000 --- a/src/MCP/Servers/WP_CLI/Tools/CliCommands.php +++ /dev/null @@ -1,151 +0,0 @@ - - */ - private function get_commands( WP_CLI\Dispatcher\CompositeCommand $command ): array { - if ( WP_CLI::get_runner()->is_command_disabled( $command ) ) { - return []; - } - - // Value is different if it's a RootCommand instance. - // @phpstan-ignore booleanNot.alwaysFalse - if ( ! $command->can_have_subcommands() ) { - return [ $command ]; - } - - $commands = []; - - /** - * @var WP_CLI\Dispatcher\CompositeCommand $subcommand - */ - foreach ( $command->get_subcommands() as $subcommand ) { - array_push( $commands, ...$this->get_commands( $subcommand ) ); - } - - return $commands; - } - - /** - * Returns a list of tools. - * - * @return array Tools. - */ - public function get_tools(): array { - $commands = $this->get_commands( WP_CLI::get_root_command() ); - - $tools = []; - - /** - * Command class. - * - * @var WP_CLI\Dispatcher\RootCommand|WP_CLI\Dispatcher\Subcommand $command - */ - foreach ( $commands as $command ) { - $command_name = implode( ' ', get_path( $command ) ); - - $command_desc = $command->get_shortdesc(); - $command_synopsis = $command->get_synopsis(); - - /** - * Parsed synopsys. - * - * @var array $synopsis_spec - */ - $synopsis_spec = SynopsisParser::parse( $command_synopsis ); - - $properties = []; - $required = []; - - $this->logger->debug( "Synopsis for command: \"$command_name\"" . ' - ' . print_r( $command_synopsis, true ) ); - - foreach ( $synopsis_spec as $arg ) { - if ( 'positional' === $arg['type'] || 'assoc' === $arg['type'] ) { - $prop_name = str_replace( [ '-', '|' ], '_', $arg['name'] ); - $properties[ $prop_name ] = [ - 'type' => 'string', - 'description' => "Parameter {$arg['name']}", - ]; - - if ( ! isset( $arg['optional'] ) || ! $arg['optional'] ) { - $required[] = $prop_name; - } - } - } - - if ( empty( $properties ) ) { - // Some commands such as "wp cache flush" don't take any parameters, - // but the MCP SDK doesn't seem to like empty $properties. - $properties['dummy'] = [ - 'type' => 'string', - 'description' => 'Dummy parameter', - ]; - } - - $tool = [ - 'name' => 'wp_cli_' . str_replace( ' ', '_', $command_name ), - 'description' => $command_desc, - 'inputSchema' => [ - 'type' => 'object', - 'properties' => $properties, - 'required' => $required, - ], - 'callback' => 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 ( 'dummy' === $key ) { - continue; - } - - // 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(); - }, - ]; - - $tools[] = $tool; - } - - return $tools; - } -} diff --git a/src/MCP/Servers/WP_CLI/WP_CLI.php b/src/MCP/Servers/WP_CLI/WP_CLI.php deleted file mode 100644 index acbc30f..0000000 --- a/src/MCP/Servers/WP_CLI/WP_CLI.php +++ /dev/null @@ -1,20 +0,0 @@ -logger ) )->get_tools(), - ]; - - foreach ( $all_tools as $tool ) { - $this->register_tool( $tool ); - } - } -} diff --git a/src/McpCommand.php b/src/McpCommand.php deleted file mode 100644 index a98083e..0000000 --- a/src/McpCommand.php +++ /dev/null @@ -1,99 +0,0 @@ - - * : Name of an existing server to proxy requests to. - * - * ## EXAMPLES - * - * # Add server from URL. - * $ wp mcp server add "mywpserver" "https://example.com/wp-json/mcp/v1/mcp" - * Success: Server added. - * - * # Proxy requests to server - * $ wp mcp proxy "mywpserver" - * - * @when before_wp_load - * - * @param string[] $args Indexed array of positional arguments. - */ - public function proxy( $args ): void { - $server = $this->get_config()->get_server( $args[0] ); - - if ( null === $server ) { - WP_CLI::error( 'Server does not exist.' ); - return; - } - - $url = $server['server']; - - if ( ! str_starts_with( $url, 'http://' ) && ! str_starts_with( $url, 'https://' ) ) { - WP_CLI::error( 'Server is not using HTTP transport.' ); - return; - } - - $logger = new CliLogger(); - - $server = new Server( $args[0], $logger ); - - try { - $transport = StdioServerTransport::create(); - - $proxy_session = new ProxySession( - $url, - $transport, - $server->createInitializationOptions(), - $logger - ); - - $server->setSession( $proxy_session ); - - $proxy_session->registerHandlers( $server->getHandlers() ); - $proxy_session->registerNotificationHandlers( $server->getNotificationHandlers() ); - - $proxy_session->start(); - - $logger->info( 'Server started' ); - - } catch ( Exception $e ) { - $logger->error( 'Proxy error: ' . $e->getMessage() ); - } finally { - if ( isset( $proxy_session ) ) { - $proxy_session->stop(); - } - if ( isset( $transport ) ) { - $transport->stop(); - } - } - } - - /** - * Returns an McpConfig instance. - * - * @return McpConfig Config instance. - */ - protected function get_config(): McpConfig { - return new McpConfig(); - } -} diff --git a/src/McpServerCommand.php b/src/McpServerCommand.php deleted file mode 100644 index 55f320b..0000000 --- a/src/McpServerCommand.php +++ /dev/null @@ -1,233 +0,0 @@ -=] - * : Filter results by key=value pairs. - * - * [--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! - * - * @subcommand list - * - * @when before_wp_load - * - * @param string[] $args Indexed array of positional arguments. - * @param array $assoc_args Associative arguments. - */ - public function list_( $args, $assoc_args ): void { - $_servers = $this->get_config()->get_servers(); - - $servers = []; - - foreach ( $_servers as $server ) { - // Support features like --status=active. - foreach ( array_keys( $server ) as $field ) { - if ( isset( $assoc_args[ $field ] ) && ! in_array( $server[ $field ], array_map( 'trim', explode( ',', $assoc_args[ $field ] ) ), true ) ) { - continue 2; - } - } - - $servers[] = $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. - * - * @when before_wp_load - * - * @param string[] $args Indexed array of positional arguments. - */ - public function add( $args ): void { - if ( $this->get_config()->has_server( $args[0] ) ) { - WP_CLI::error( 'Server already exists.' ); - } else { - $this->get_config()->add_server( - [ - 'name' => $args[0], - 'server' => $args[1], - 'status' => 'active', - ] - ); - - WP_CLI::success( 'Server added.' ); - } - } - - /** - * Remove one or more MCP servers. - * - * ## OPTIONS - * - * [...] - * : One or more servers to remove - * - * [--all] - * : Whether to remove all servers. - * - * ## EXAMPLES - * - * # Remove server. - * $ wp mcp server remove "server-filesystem" - * Success: Server removed. - * - * @when before_wp_load - * - * @param string[] $args Indexed array of positional arguments. - * @param array $assoc_args Associative arguments. - */ - public function remove( $args, $assoc_args ): void { - $all = (bool) Utils\get_flag_value( $assoc_args, 'all', false ); - - if ( ! $all && empty( $args ) ) { - WP_CLI::error( 'Please specify one or more servers, or use --all.' ); - } - - $successes = 0; - $errors = 0; - $count = count( $args ); - - foreach ( $args as $server ) { - if ( ! $this->get_config()->has_server( $server ) ) { - WP_CLI::warning( "Server '$server' not found." ); - ++$errors; - } else { - $this->get_config()->remove_server( $server ); - ++$successes; - } - } - - Utils\report_batch_operation_results( 'server', 'remove', $count, $successes, $errors ); - } - - /** - * Update an MCP server. - * - * ## OPTIONS - * - * - * : Name of the server. - * - * --= - * : One or more fields to update. - * - * ## EXAMPLES - * - * # Remove server. - * $ wp mcp server update "server-filesystem" --status=inactive - * Success: Server updated. - * - * @when before_wp_load - * - * @param string[] $args Indexed array of positional arguments. - * @param array $assoc_args Associative arguments. - */ - public function update( $args, array $assoc_args ): void { - $server = $this->get_config()->get_server( $args[0] ); - - if ( null === $server ) { - WP_CLI::error( "Server '$args[0]' not found." ); - return; - } - - foreach ( $server as $key => $value ) { - if ( isset( $assoc_args[ $key ] ) ) { - $new_value = $assoc_args[ $key ]; - if ( 'status' === $key ) { - $new_value = 'inactive' === $new_value ? 'inactive' : 'active'; - } - $server[ $key ] = $new_value; - } - } - - $this->get_config()->update_server( $args[0], $server ); - - WP_CLI::success( 'Server updated.' ); - } - - /** - * Returns a Formatter object based on supplied parameters. - * - * @param array $assoc_args Parameters passed to command. Determines formatting. - * @return Formatter - * @param-out array $assoc_args - */ - protected function get_formatter( array &$assoc_args ) { - return new Formatter( - // TODO: Fix type. - // @phpstan-ignore paramOut.type - $assoc_args, - [ - 'name', - 'server', - 'status', - ] - ); - } - - /** - * Returns an McpConfig instance. - * - * @return McpConfig Config instance. - */ - protected function get_config(): McpConfig { - return new McpConfig(); - } -} diff --git a/src/Utils/CliLogger.php b/src/Utils/CliLogger.php deleted file mode 100644 index 08fdc2b..0000000 --- a/src/Utils/CliLogger.php +++ /dev/null @@ -1,114 +0,0 @@ -get_config()['servers']; - } - - /** - * Returns a server with the given name. - * - * @param string $name Server name. - * @return McpConfigServer|null Server if found, null otherwise. - */ - public function get_server( string $name ): ?array { - $config = $this->get_config(); - foreach ( $config['servers'] as $server ) { - if ( $name === $server['name'] ) { - return $server; - } - } - - return null; - } - - /** - * Determines whether a server with the given name exists in the config. - * - * @param string $name Server name. - * @return bool Whether the server exists. - */ - public function has_server( string $name ): bool { - return $this->get_server( $name ) !== null; - } - - /** - * Adds a new server to the list. - * - * @param McpConfigServer $server Server data. - * @return void - */ - public function add_server( array $server ): void { - $config = $this->get_config(); - $config['servers'][] = $server; - $this->update_config( $config ); - } - - /** - * Updates a specific server in the config. - * @param string $name Server name. - * @param McpConfigServer $server Server data. - * @return void - */ - public function update_server( string $name, array $server ): void { - $config = $this->get_config(); - foreach ( $config['servers'] as &$_server ) { - if ( $name === $_server['name'] ) { - $_server = $server; - } - } - - unset( $_server ); - - $this->update_config( $config ); - } - - /** - * Removes a given server from the config. - * - * @param string $name Server name. - * @return void - */ - public function remove_server( string $name ): void { - $config = $this->get_config(); - - foreach ( $config['servers'] as $key => $server ) { - if ( $name === $server['name'] ) { - unset( $config['servers'][ $key ] ); - } - } - - $this->update_config( $config ); - } - - /** - * Returns the current MCP config. - * - * @return array Config data. - * @phpstan-return McpConfigData - */ - protected function get_config() { - $config_file = Utils\get_home_dir() . '/.wp-cli/ai-command.json'; - - if ( ! file_exists( $config_file ) ) { - return [ - 'servers' => [], - ]; - } - - $json_content = file_get_contents( $config_file ); - - if ( false === $json_content ) { - return [ - 'servers' => [], - ]; - } - - $config = json_decode( $json_content, true, 512, JSON_THROW_ON_ERROR ); - - if ( null === $config ) { - return [ - 'servers' => [], - ]; - } - - /** - * Loaded config. - * - * @var McpConfigData $config - */ - return $config; - } - - /** - * Updates the MCP config. - * - * @param array $new_config Updated config. - * @return bool Whether updating was successful. - * @phpstan-param McpConfigData $new_config - */ - protected function update_config( $new_config ): bool { - $config_file = Utils\get_home_dir() . '/.wp-cli/ai-command.json'; - - if ( ! file_exists( $config_file ) ) { - touch( $config_file ); - } - - return (bool) file_put_contents( $config_file, json_encode( $new_config, JSON_PRETTY_PRINT ) ); - } -}