diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml new file mode 100644 index 0000000..7a55b08 --- /dev/null +++ b/.github/workflows/testing.yml @@ -0,0 +1,97 @@ +name: Testing + +on: + pull_request: + branches: + - main + - master + workflow_dispatch: + workflow_call: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + behat: + name: Functional / PHP ${{ matrix.php }} + strategy: + matrix: + php: ['8.2'] + runs-on: ubuntu-latest + services: + mysql: + image: mysql:8 + env: + MYSQL_ALLOW_EMPTY_PASSWORD: yes + MYSQL_DATABASE: wp_cli_test + MYSQL_USER: wp_cli_test + MYSQL_PASSWORD: password1 + MYSQL_HOST: 127.0.0.1 + ports: + - 3306 + options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3 + steps: + - name: Check out source code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + tools: composer + + - name: Install composer packages + run: composer install + + - name: Run Behat + run: composer behat + env: + 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] }} + + unit: #----------------------------------------------------------------------- + name: Unit test / PHP ${{ matrix.php }} + strategy: + matrix: + php: [ '8.2' ] + 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: '${{ matrix.php }}' + ini-values: zend.assertions=1, error_reporting=-1, display_errors=On + coverage: none + tools: composer,cs2pr + + - 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: Grab PHPUnit version + id: phpunit_version + run: echo "VERSION=$(vendor/bin/phpunit --version | grep --only-matching --max-count=1 --extended-regexp '\b[0-9]+\.[0-9]+')" >> $GITHUB_OUTPUT + + # PHPUnit 10 may fail a test run when the "old" configuration format is used. + # Luckily, there is a build-in migration tool since PHPUnit 9.3. + - name: Migrate PHPUnit configuration for PHPUnit 10+ + if: ${{ startsWith( steps.phpunit_version.outputs.VERSION, '10.' ) }} + continue-on-error: true + run: composer phpunit -- --migrate-configuration + + - name: Setup problem matcher to provide annotations for PHPUnit + run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Run PHPUnit + run: composer phpunit diff --git a/.phpunit.result.cache b/.phpunit.result.cache new file mode 100644 index 0000000..2f08977 --- /dev/null +++ b/.phpunit.result.cache @@ -0,0 +1 @@ +{"version":1,"defects":[],"times":{"AiCommand\\Tests\\MCP\\Client\\ClientTest::test_runs":0.001}} \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 45dc083..0000000 --- a/.travis.yml +++ /dev/null @@ -1,83 +0,0 @@ -os: linux -dist: xenial - -language: php -php: 7.4 - -services: - - mysql - -notifications: - email: - on_success: never - on_failure: change - -branches: - only: - - master - -cache: - directories: - - $HOME/.composer/cache - -env: - global: - - PATH="$TRAVIS_BUILD_DIR/vendor/bin:$PATH" - - WP_CLI_BIN_DIR="$TRAVIS_BUILD_DIR/vendor/bin" - -before_install: - - | - # Remove Xdebug for a huge performance increase: - if [ -f ~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/xdebug.ini ]; then - phpenv config-rm xdebug.ini - else - echo "xdebug.ini does not exist" - fi - - | - # Raise PHP memory limit to 2048MB - echo 'memory_limit = 2048M' >> ~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/travis.ini - - composer validate - -install: - - composer install - - composer prepare-tests - -script: - - composer phpunit - - composer behat || composer behat-rerun - -jobs: - include: - - stage: test - php: nightly - env: WP_VERSION=trunk - - stage: test - php: 7.4 - env: WP_VERSION=latest - - stage: test - php: 7.3 - env: WP_VERSION=latest - - stage: test - php: 7.2 - env: WP_VERSION=latest - - stage: test - php: 7.1 - env: WP_VERSION=latest - - stage: test - php: 7.0 - env: WP_VERSION=latest - - stage: test - php: 5.6 - env: WP_VERSION=latest - - stage: test - php: 5.6 - env: WP_VERSION=3.7.11 - dist: trusty - - stage: test - php: 5.6 - env: WP_VERSION=trunk - - allow_failures: - - stage: test - php: nightly - env: WP_VERSION=trunk diff --git a/behat.yml b/behat.yml new file mode 100644 index 0000000..d6ee862 --- /dev/null +++ b/behat.yml @@ -0,0 +1,7 @@ +default: + suites: + default: + contexts: + - WP_CLI\Tests\Context\FeatureContext + paths: + - features diff --git a/composer.json b/composer.json index 43ead3e..66c8338 100644 --- a/composer.json +++ b/composer.json @@ -6,13 +6,12 @@ "license": "MIT", "authors": [], "require": { - "gemini-api-php/client": "^1.7", - "logiscape/mcp-sdk-php": "^1.0", - "symfony/http-client": "^7.2", + "php": "^8.2", "wp-cli/wp-cli": "^2.12" }, "require-dev": { - "wp-cli/wp-cli-tests": "^v4.3.6" + "roave/security-advisories": "dev-latest", + "wp-cli/wp-cli-tests": "^v4.3.9" }, "config": { "process-timeout": 7200, @@ -48,6 +47,7 @@ "behat-rerun": "rerun-behat-tests", "lint": "run-linter-tests", "phpcs": "run-phpcs-tests", + "phpcbf": "run-phpcbf-cleanup", "phpunit": "run-php-unit-tests", "prepare-tests": "install-package-tests", "test": [ diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..215800a --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,54 @@ + + + Custom ruleset for ai-command + + + + + . + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..a2576da --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,26 @@ + + + + tests + + + + + + src + + + diff --git a/src/AiCommand.php b/src/AiCommand.php index b27b224..b0e87bc 100644 --- a/src/AiCommand.php +++ b/src/AiCommand.php @@ -2,7 +2,6 @@ namespace WP_CLI\AiCommand; -use Exception; use WP_CLI; use WP_CLI_Command; @@ -45,67 +44,97 @@ class AiCommand extends WP_CLI_Command { public function __invoke( $args, $assoc_args ) { $server = new MCP\Server(); - $server->registerTool( [ - 'name' => 'calculate_total', - 'description' => 'Calculates the total price.', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'price' => [ 'type' => 'integer', 'description' => 'The price of the item.' ], - 'quantity' => [ 'type' => 'integer', 'description' => 'The quantity of items.' ], + $server->register_tool( + [ + 'name' => 'calculate_total', + 'description' => 'Calculates the total price.', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'price' => [ + 'type' => 'integer', + 'description' => 'The price of the item.', + ], + 'quantity' => [ + 'type' => 'integer', + 'description' => 'The quantity of items.', + ], + ], + 'required' => [ 'price', 'quantity' ], ], - 'required' => [ 'price', 'quantity' ], - ], - 'callable' => function ( $params ) { - $price = $params['price'] ?? 0; - $quantity = $params['quantity'] ?? 1; + 'callable' => function ( $params ) { + $price = $params['price'] ?? 0; + $quantity = $params['quantity'] ?? 1; - return $price * $quantity; - }, - ] ); + return $price * $quantity; + }, + ] + ); - $server->registerTool( [ - 'name' => 'greet', - 'description' => 'Greets the user.', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'name' => [ 'type' => 'string', 'description' => 'The name of the user.' ], + $server->register_tool( + [ + 'name' => 'greet', + 'description' => 'Greets the user.', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'name' => [ + 'type' => 'string', + 'description' => 'The name of the user.', + ], + ], + 'required' => [ 'name' ], ], - 'required' => [ 'name' ], - ], - 'callable' => function ( $params ) { - return "Hello, " . $params['name'] . "!"; - }, - ] ); + 'callable' => function ( $params ) { + return 'Hello, ' . $params['name'] . '!'; + }, + ] + ); // Register resources: - $server->registerResource( [ - 'name' => 'users', - 'uri' => 'data://users', - 'description' => 'List of users', - 'mimeType' => 'application/json', - 'dataKey' => 'users', // This tells getResourceData() to look in the $data array - ] ); + $server->register_resource( + [ + 'name' => 'users', + 'uri' => 'data://users', + 'description' => 'List of users', + 'mimeType' => 'application/json', + 'dataKey' => 'users', // This tells getResourceData() to look in the $data array + ] + ); - $server->registerResource( [ - 'name' => 'product_catalog', - 'uri' => 'file://./products.json', - 'description' => 'Product catalog', - 'mimeType' => 'application/json', - 'filePath' => './products.json', // This tells getResourceData() to read from a file - ] ); + $server->register_resource( + [ + 'name' => 'product_catalog', + 'uri' => 'file://./products.json', + 'description' => 'Product catalog', + 'mimeType' => 'application/json', + 'filePath' => './products.json', // This tells getResourceData() to read from a file + ] + ); $client = new MCP\Client( $server ); - $result = $client->callGemini( [ + $server->register_tool( [ - "role" => "user", - "parts" => [ - "text" => $args[0] - ] + 'name' => '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 ) use ( $client ) { + return $client->get_image_from_ai_service( $params['prompt'] ); + }, ] - ] ); + ); + + $result = $client->call_ai_service_with_prompt( $args[0] ); WP_CLI::success( $result ); } diff --git a/src/MCP/Client.php b/src/MCP/Client.php index d13b26d..39d74e2 100644 --- a/src/MCP/Client.php +++ b/src/MCP/Client.php @@ -3,6 +3,17 @@ 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\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\Tools; +use WP_CLI; class Client { @@ -12,129 +23,207 @@ public function __construct( Server $server ) { $this->server = $server; } - public function sendRequest( $method, $params = [] ) { + public function send_request( $method, $params = [] ) { $request = [ 'jsonrpc' => '2.0', 'method' => $method, 'params' => $params, - 'id' => uniqid(), // Generate a unique ID for each request + 'id' => uniqid( '', true ), // Generate a unique ID for each request ]; - $requestData = json_encode( $request ); - $responseData = $this->server->processRequest( $requestData ); - $response = json_decode( $responseData, true ); + $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'] ); + throw new Exception( 'JSON-RPC Error: ' . $response['error']['message'], $response['error']['code'] ); } return $response['result']; } - public function __call( $name, $arguments ) { // Magic method for calling any method - return $this->sendRequest( $name, $arguments[0] ?? [] ); + public function __call( $name, $arguments ) { + // Magic method for calling any method + return $this->send_request( $name, $arguments[0] ?? [] ); } public function list_resources() { - return $this->sendRequest( 'resources/list' ); + return $this->send_request( 'resources/list' ); } public function read_resource( $uri ) { - return $this->sendRequest( 'resources/read', [ 'uri' => $uri ] ); + return $this->send_request( 'resources/read', [ 'uri' => $uri ] ); } - public function callGemini( $contents ) { - $capabilities = $this->get_capabilities(); + // Must not have the same name as the tool, otherwise it takes precedence. + public function get_image_from_ai_service( string $prompt ) { + // See https://github.com/felixarntz/ai-services/issues/25. + add_filter( + 'map_meta_cap', + static function () { + return [ 'exist' ]; + } + ); - $tools = []; + try { + $service = ai_services()->get_available_service( + [ + 'capabilities' => [ + AI_Capability::IMAGE_GENERATION, + ], + ] + ); + $candidates = $service + ->get_model( + [ + 'feature' => 'image-generation', + 'capabilities' => [ + AI_Capability::IMAGE_GENERATION, + ], + ] + ) + ->generate_image( $prompt ); - foreach ( $capabilities['methods'] ?? [] as $tool ) { - $tools[] = [ - "name" => $tool['name'], - "description" => $tool['description'] ?? "", // Provide a description - "parameters" => $tool['inputSchema'] ?? [], // Provide the inputSchema - ]; + } catch ( Exception $e ) { + WP_CLI::error( $e->getMessage() ); } - \WP_CLI::log( 'Calling Gemini...' . json_encode( [ + $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 ); - 'contents' => $contents, - 'tools' => [ - 'function_declarations' => $tools, - ], - ] ) ); + if ( $image_blob ) { + $filename = tempnam( '/tmp', 'ai-generated-image' ); + $parts = explode( '/', $part->get_mime_type() ); + $extension = $parts[1]; + rename( $filename, $filename . '.' . $extension ); + $filename .= '.' . $extension; - $GOOGLE_API_KEY = getenv( 'GEMINI_API_KEY' ); + file_put_contents( $filename, $image_blob->get_binary_data() ); - $response = \WP_CLI\Utils\http_request( - 'POST', -// "https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=$GOOGLE_API_KEY", - "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent?key=$GOOGLE_API_KEY", - json_encode( [ - 'contents' => $contents, - 'tools' => [ - 'function_declarations' => $tools, - ], - ] - ), - [ - 'Content-Type' => 'application/json' - ] - ); + $image_url = $filename; + } - $data = json_decode( $response->body ); + break; + } - \WP_CLI::log( 'Receiving response...' . json_encode( $data ) ); + 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; + } + } - $new_contents = $contents; + // See https://github.com/felixarntz/ai-services/blob/main/docs/Accessing-AI-Services-in-PHP.md for further processing. - foreach ( $data->candidates[0]->content->parts as $part ) { - // Check for tool calls in Gemini response - if ( isset( $part->functionCall ) ) { - $name = $part->functionCall->name; - $args = (array) $part->functionCall->args; + WP_CLI::log( "Generated image: $image_url" ); - $functionResult = $this->$name( $args ); + return $image_url; + } - \WP_CLI::log( "Calling function $name... Result:" . print_r( $functionResult, true ) ); + public function call_ai_service_with_prompt( string $prompt ) { + $parts = new Parts(); + $parts->add_text_part( $prompt ); + $content = new Content( Content_Role::USER, $parts ); - $new_contents[] = [ - 'role' => 'model', - 'parts' => [ - $part - ] - ]; - $new_contents[] = [ - 'role' => 'user', - 'parts' => [ - [ - 'functionResponse' => [ - 'name' => $name, - 'response' => [ - 'name' => $name, - 'content' => $functionResult, - ] - ] - ] - ] - ]; + return $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' ]; } - } + ); + + $capabilities = $this->get_capabilities(); - if ( $new_contents !== $contents ) { - return $this->callGemini( $new_contents ); + $function_declarations = []; + + foreach ( $capabilities['methods'] ?? [] as $tool ) { + $function_declarations[] = [ + 'name' => $tool['name'], + 'description' => $tool['description'] ?? '', // Provide a description + 'parameters' => $tool['inputSchema'] ?? [], // Provide the inputSchema + ]; } - foreach ( $data->candidates[0]->content->parts as $part ) { - if ( isset( $part->text ) ) { - return $part->text; + $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, + ], + ] + ); + + \WP_CLI::log( 'Making request...' . print_r( $contents, true ) ); + + $candidates = $service + ->get_model( + [ + 'feature' => 'text-generation', + 'tools' => $tools, + 'capabilities' => [ + AI_Capability::MULTIMODAL_INPUT, + AI_Capability::TEXT_GENERATION, + AI_Capability::FUNCTION_CALLING, + ], + ] + ) + ->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 ) { + var_dump( 'call function', $part ); + $function_result = $this->{$part->get_name()}( $part->get_args() ); + + // 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; + } + } + + if ( $new_contents !== $contents ) { + return $this->call_ai_service( $new_contents ); } - } - return 'Unknown!'; + return $text; + } catch ( Exception $e ) { + WP_CLI::error( $e->getMessage() ); + } } } diff --git a/src/MCP/Server.php b/src/MCP/Server.php index 0b7ee0b..9b5b588 100644 --- a/src/MCP/Server.php +++ b/src/MCP/Server.php @@ -7,50 +7,66 @@ class Server { - private array $data = []; - private array $tools = []; + private array $data = []; + private array $tools = []; private array $resources = []; public function __construct() { // Sample data (replace with your actual data handling) $this->data['users'] = [ - [ 'id' => 1, 'name' => 'Alice', 'email' => 'alice@example.com' ], - [ 'id' => 2, 'name' => 'Bob', 'email' => 'bob@example.com' ], + [ + 'id' => 1, + 'name' => 'Alice', + 'email' => 'alice@example.com', + ], + [ + 'id' => 2, + 'name' => 'Bob', + 'email' => 'bob@example.com', + ], ]; $this->data['products'] = [ - [ 'id' => 101, 'name' => 'Product A', 'price' => 20 ], - [ 'id' => 102, 'name' => 'Product B', 'price' => 30 ], + [ + 'id' => 101, + 'name' => 'Product A', + 'price' => 20, + ], + [ + 'id' => 102, + 'name' => 'Product B', + 'price' => 30, + ], ]; } - public function registerTool( $toolDefinition ): void { - if ( ! is_array( $toolDefinition ) || ! isset( $toolDefinition['name'] ) || ! is_callable( $toolDefinition['callable'] ) ) { + public function register_tool( array $tool_definition ): void { + if ( ! isset( $tool_definition['name'] ) || ! is_callable( $tool_definition['callable'] ) ) { throw new InvalidArgumentException( "Invalid tool definition. Must be an array with 'name' and 'callable'." ); } - $name = $toolDefinition['name']; - $callable = $toolDefinition['callable']; - $description = $toolDefinition['description'] ?? null; - $inputSchema = $toolDefinition['inputSchema'] ?? null; + $name = $tool_definition['name']; + $callable = $tool_definition['callable']; + $description = $tool_definition['description'] ?? null; + $input_schema = $tool_definition['inputSchema'] ?? null; $this->tools[ $name ] = [ 'name' => $name, 'callable' => $callable, 'description' => $description, - 'inputSchema' => $inputSchema, + 'inputSchema' => $input_schema, ]; } - public function registerResource( $resourceDefinition ) { + public function register_resource( array $resource_definition ) { // Validate the resource definition (similar to tool validation) - if ( ! is_array( $resourceDefinition ) || ! isset( $resourceDefinition['name'] ) || ! isset( $resourceDefinition['uri'] ) ) { - throw new InvalidArgumentException( "Invalid resource definition." ); + if ( ! isset( $resource_definition['name'] ) || ! isset( $resource_definition['uri'] ) ) { + throw new InvalidArgumentException( 'Invalid resource definition.' ); } - $this->resources[ $resourceDefinition['name'] ] = $resourceDefinition; + $this->resources[ $resource_definition['name'] ] = $resource_definition; } - public function getCapabilities(): array { + public function get_capabilities(): array { $capabilities = [ 'version' => '1.0', // MCP version (adjust as needed) 'methods' => [], @@ -77,29 +93,29 @@ public function getCapabilities(): array { return $capabilities; } - public function handleRequest( $requestData ): false|string { - $request = json_decode( $requestData, true ); + public function handle_request( string $request_data ): false|string { + $request = json_decode( $request_data, true ); if ( json_last_error() !== JSON_ERROR_NONE ) { - return $this->createErrorResponse( null, 'Invalid JSON', - 32700 ); // Parse error + return $this->create_error_response( null, 'Invalid JSON', - 32700 ); // Parse error } - if ( ! isset( $request['jsonrpc'] ) || $request['jsonrpc'] !== '2.0' ) { - return $this->createErrorResponse( $request['id'] ?? null, 'Invalid JSON-RPC version', - 32600 ); // Invalid Request + if ( ! isset( $request['jsonrpc'] ) || '2.0' !== $request['jsonrpc'] ) { + return $this->create_error_response( $request['id'] ?? null, 'Invalid JSON-RPC version', - 32600 ); // Invalid Request } if ( ! isset( $request['method'] ) ) { - return $this->createErrorResponse( $request['id'] ?? null, 'Missing method', - 32600 ); // Invalid Request + return $this->create_error_response( $request['id'] ?? null, 'Missing method', - 32600 ); // Invalid Request } $method = $request['method']; $params = $request['params'] ?? []; $id = $request['id'] ?? null; - if ( $method === 'get_capabilities' ) { // Handle capabilities request - $capabilities = $this->getCapabilities(); + if ( 'get_capabilities' === $method ) { // Handle capabilities request + $capabilities = $this->get_capabilities(); - return $this->createSuccessResponse( $id, $capabilities ); + return $this->create_success_response( $id, $capabilities ); } try { @@ -108,48 +124,46 @@ public function handleRequest( $requestData ): false|string { $resource = substr( $method, 4 ); // Extract the resource name (e.g., "users" from "get_users") if ( isset( $this->data[ $resource ] ) ) { - $result = $this->handleGetRequest( '/' . $resource, $params ); // Re-use handleGetRequest - } else if ( isset( $this->data["{$resource}s"] ) ) { - $result = $this->handleGetRequest( '/' . "{$resource}s", $params ); // Re-use handleGetRequest + $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->createErrorResponse( $id, 'Resource not found', - 32601 ); // Method not found + return $this->create_error_response( $id, 'Resource not found', - 32601 ); // Method not found } - - } else if ( $method === 'resources/list' ) { - $result = $this->listResources(); - } elseif ( $method === 'resources/read' ) { - $result = $this->readResource( $params['uri'] ?? null ); + } 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->createErrorResponse( $id, 'Method not found', - 32601 ); + return $this->create_error_response( $id, 'Method not found', - 32601 ); } // Validate input parameters against the schema - $inputSchema = $tool['inputSchema'] ?? null; - if ( $inputSchema ) { - $isValid = $this->validateInput( $params, $inputSchema ); - if ( ! $isValid['valid'] ) { - return $this->createErrorResponse( $id, 'Invalid input parameters: ' . implode( ", ", $isValid['errors'] ), - 32602 ); // Invalid params + $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->createSuccessResponse( $id, $result ); // Return success immediately + return $this->create_success_response( $id, $result ); // Return success immediately } - return $this->createSuccessResponse( $id, $result ); + return $this->create_success_response( $id, $result ); } catch ( Exception $e ) { - return $this->createErrorResponse( $id, $e->getMessage(), - 32000 ); // Application error + return $this->create_error_response( $id, $e->getMessage(), - 32000 ); // Application error } } - private function listResources() { + private function list_resources() { $result = []; foreach ( $this->resources as $resource ) { $result[] = [ @@ -163,7 +177,7 @@ private function listResources() { return $result; } - private function readResource( $uri ) { + private function read_resource( $uri ) { // Find the resource by URI $resource = null; foreach ( $this->resources as $r ) { @@ -174,61 +188,64 @@ private function readResource( $uri ) { } if ( ! $resource ) { - throw new Exception( "Resource not found." ); + throw new Exception( 'Resource not found.' ); } // Access the resource data (replace with your actual data access logic) - $data = $this->getResourceData( $resource ); + $data = $this->get_resource_data( $resource ); // Determine if it's text or binary - $isBinary = isset( $resource['mimeType'] ) && ! str_starts_with( $resource['mimeType'], 'text/' ); + $is_binary = isset( $resource['mimeType'] ) && ! str_starts_with( $resource['mimeType'], 'text/' ); return [ - 'uri' => $resource['uri'], - 'mimeType' => $resource['mimeType'] ?? null, - ( $isBinary ? 'blob' : 'text' ) => $data, + 'uri' => $resource['uri'], + 'mimeType' => $resource['mimeType'] ?? null, + ( $is_binary ? 'blob' : 'text' ) => $data, ]; } - private function getResourceData( $resource ) { + private function get_resource_data( $mcp_resource ) { // Replace this with your actual logic to access the resource data // based on the resource definition. // Example: If the resource is a file, read the file contents. - if ( isset( $resource['filePath'] ) ) { - return file_get_contents( $resource['filePath'] ); + if ( isset( $mcp_resource['filePath'] ) ) { + return file_get_contents( $mcp_resource['filePath'] ); } // Example: If the resource is in the $data array, return the data. - if ( isset( $resource['dataKey'] ) ) { - return $this->data[ $resource['dataKey'] ]; + if ( isset( $mcp_resource['dataKey'] ) ) { + return $this->data[ $mcp_resource['dataKey'] ]; } //... other data access logic... - throw new Exception( "Unable to access resource data." ); + throw new Exception( 'Unable to access resource data.' ); } - private function validateInput( $input, $schema ): array { - // Basic input validation (you might want to use a dedicated JSON schema validator library) + // TODO: use a dedicated JSON schema validator library + private function validate_input( $input, $schema ): array { $errors = []; - foreach ( $schema['properties'] ?? [] as $paramName => $paramSchema ) { - if ( isset( $paramSchema['required'] ) && $paramSchema['required'] === true && ! isset( $input[ $paramName ] ) ) { - $errors[] = $paramName . " is required"; + 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[ $paramName ] ) && isset( $paramSchema['type'] ) ) { - $inputType = gettype( $input[ $paramName ] ); - if ( $inputType !== $paramSchema['type'] ) { - $errors[] = $paramName . " must be of type " . $paramSchema['type'] . " but " . $inputType . " was given."; + 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.'; } } } - return [ 'valid' => empty( $errors ), 'errors' => $errors ]; + return [ + 'valid' => empty( $errors ), + 'errors' => $errors, + ]; } - private function handleGetRequest( $path, $params ) { + private function handle_get_request( $path, $params ) { $parts = explode( '/', ltrim( $path, '/' ) ); $resource = $parts[0]; $id = $params['id'] ?? null; // Simplified parameter handling @@ -236,42 +253,47 @@ private function handleGetRequest( $path, $params ) { if ( isset( $this->data[ $resource ] ) ) { $data = $this->data[ $resource ]; - if ( $id !== null ) { + if ( null !== $id ) { foreach ( $data as $item ) { - if ( $item['id'] == $id ) { + if ( $item['id'] === $id ) { return $item; } } throw new Exception( 'Resource not found' ); - } else { - return $data; } - } else { - throw new Exception( 'Resource not found' ); + + return $data; } + + throw new Exception( 'Resource not found' ); } - private function createSuccessResponse( $id, $result ): false|string { - return json_encode( [ - 'jsonrpc' => '2.0', - 'result' => $result, - 'id' => $id, - ] ); + private function create_success_response( $id, $result ): false|string { + return json_encode( + [ + 'jsonrpc' => '2.0', + 'result' => $result, + 'id' => $id, + ], + JSON_THROW_ON_ERROR + ); } - private function createErrorResponse( $id, $message, $code ): false|string { - return json_encode( [ - 'jsonrpc' => '2.0', - 'error' => [ - 'code' => $code, - 'message' => $message, + private function create_error_response( $id, $message, $code ): false|string { + return json_encode( + [ + 'jsonrpc' => '2.0', + 'error' => [ + 'code' => $code, + 'message' => $message, + ], + 'id' => $id, ], - 'id' => $id, - ] ); + JSON_THROW_ON_ERROR + ); } - public function processRequest( $requestData ): false|string { - return $this->handleRequest( $requestData ); + public function process_request( $request_data ): false|string { + return $this->handle_request( $request_data ); } } - diff --git a/tests/MCP/Client/ClientTest.php b/tests/MCP/Client/ClientTest.php new file mode 100644 index 0000000..bfa29c1 --- /dev/null +++ b/tests/MCP/Client/ClientTest.php @@ -0,0 +1,11 @@ +assertTrue( true ); + } +}