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 );
+ }
+}