testing setup, lint fixes

This commit is contained in:
Pascal Birchler 2025-03-10 14:47:11 +01:00
parent c75f95acd5
commit bf108dc87d
No known key found for this signature in database
GPG key ID: 0DECE73DD74E8B2F
11 changed files with 424 additions and 267 deletions

97
.github/workflows/testing.yml vendored Normal file
View file

@ -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

1
.phpunit.result.cache Normal file
View file

@ -0,0 +1 @@
{"version":1,"defects":[],"times":{"AiCommand\\Tests\\MCP\\Client\\ClientTest::test_runs":0.001}}

View file

@ -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

7
behat.yml Normal file
View file

@ -0,0 +1,7 @@
default:
suites:
default:
contexts:
- WP_CLI\Tests\Context\FeatureContext
paths:
- features

View file

@ -6,13 +6,12 @@
"license": "MIT", "license": "MIT",
"authors": [], "authors": [],
"require": { "require": {
"gemini-api-php/client": "^1.7", "php": "^8.2",
"logiscape/mcp-sdk-php": "^1.0",
"symfony/http-client": "^7.2",
"wp-cli/wp-cli": "^2.12" "wp-cli/wp-cli": "^2.12"
}, },
"require-dev": { "require-dev": {
"wp-cli/wp-cli-tests": "^v4.3.6" "roave/security-advisories": "dev-latest",
"wp-cli/wp-cli-tests": "^v4.3.9"
}, },
"config": { "config": {
"process-timeout": 7200, "process-timeout": 7200,
@ -48,6 +47,7 @@
"behat-rerun": "rerun-behat-tests", "behat-rerun": "rerun-behat-tests",
"lint": "run-linter-tests", "lint": "run-linter-tests",
"phpcs": "run-phpcs-tests", "phpcs": "run-phpcs-tests",
"phpcbf": "run-phpcbf-cleanup",
"phpunit": "run-php-unit-tests", "phpunit": "run-php-unit-tests",
"prepare-tests": "install-package-tests", "prepare-tests": "install-package-tests",
"test": [ "test": [

54
phpcs.xml.dist Normal file
View file

@ -0,0 +1,54 @@
<?xml version="1.0"?>
<ruleset name="ai-command">
<description>Custom ruleset for ai-command</description>

<!--
#############################################################################
COMMAND LINE ARGUMENTS
For help understanding this file: https://github.com/squizlabs/PHP_CodeSniffer/wiki/Annotated-ruleset.xml
For help using PHPCS: https://github.com/squizlabs/PHP_CodeSniffer/wiki/Usage
#############################################################################
-->

<!-- What to scan. -->
<file>.</file>

<!-- Show progress. -->
<arg value="p"/>

<!-- Strip the filepaths down to the relevant bit. -->
<arg name="basepath" value="./"/>

<!-- Check up to 8 files simultaneously. -->
<arg name="parallel" value="8"/>

<!--
#############################################################################
USE THE WP_CLI_CS RULESET
#############################################################################
-->

<rule ref="WP_CLI_CS"/>-

<!--
#############################################################################
PROJECT SPECIFIC CONFIGURATION FOR SNIFFS
#############################################################################
-->

<!-- For help understanding the `testVersion` configuration setting:
https://github.com/PHPCompatibility/PHPCompatibility#sniffing-your-code-for-compatibility-with-specific-php-versions -->
<config name="testVersion" value="8.2-"/>

<!-- Verify that everything in the global namespace is either namespaced or prefixed.
See: https://github.com/WordPress-Coding-Standards/WordPress-Coding-Standards/wiki/Customizable-sniff-properties#naming-conventions-prefix-everything-in-the-global-namespace -->
<rule ref="WordPress.NamingConventions.PrefixAllGlobals">
<properties>
<property name="prefixes" type="array">
<element value="WP_CLI\AiCommand"/><!-- Namespaces. -->
<element value="ai_command"/><!-- Global variables and such. -->
</property>
</properties>
</rule>

</ruleset>

26
phpunit.xml.dist Normal file
View file

@ -0,0 +1,26 @@
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/4.8/phpunit.xsd"
bootstrap="vendor/autoload.php"
backupGlobals="false"
beStrictAboutCoversAnnotation="true"
beStrictAboutOutputDuringTests="true"
beStrictAboutTestsThatDoNotTestAnything="true"
beStrictAboutTodoAnnotatedTests="true"
convertErrorsToExceptions="true"
convertWarningsToExceptions="true"
convertNoticesToExceptions="true"
convertDeprecationsToExceptions="true"
colors="true"
verbose="true">
<testsuites>
<testsuite name="ai-command tests">
<directory suffix="Test.php">tests</directory>
</testsuite>
</testsuites>

<coverage processUncoveredFiles="false">
<include>
<directory suffix=".php">src</directory>
</include>
</coverage>
</phpunit>

View file

@ -2,7 +2,6 @@


namespace WP_CLI\AiCommand; namespace WP_CLI\AiCommand;


use Exception;
use WP_CLI; use WP_CLI;
use WP_CLI_Command; use WP_CLI_Command;


@ -45,73 +44,95 @@ class AiCommand extends WP_CLI_Command {
public function __invoke( $args, $assoc_args ) { public function __invoke( $args, $assoc_args ) {
$server = new MCP\Server(); $server = new MCP\Server();


$server->registerTool( [ $server->register_tool(
'name' => 'calculate_total', [
'description' => 'Calculates the total price.', 'name' => 'calculate_total',
'inputSchema' => [ 'description' => 'Calculates the total price.',
'type' => 'object', 'inputSchema' => [
'properties' => [ 'type' => 'object',
'price' => [ 'type' => 'integer', 'description' => 'The price of the item.' ], 'properties' => [
'quantity' => [ 'type' => 'integer', 'description' => 'The quantity of items.' ], '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;
'callable' => function ( $params ) { $quantity = $params['quantity'] ?? 1;
$price = $params['price'] ?? 0;
$quantity = $params['quantity'] ?? 1;


return $price * $quantity; return $price * $quantity;
}, },
] ); ]
);


$server->registerTool( [ $server->register_tool(
'name' => 'greet', [
'description' => 'Greets the user.', 'name' => 'greet',
'inputSchema' => [ 'description' => 'Greets the user.',
'type' => 'object', 'inputSchema' => [
'properties' => [ 'type' => 'object',
'name' => [ 'type' => 'string', 'description' => 'The name of the user.' ], '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: // Register resources:
$server->registerResource( [ $server->register_resource(
'name' => 'users', [
'uri' => 'data://users', 'name' => 'users',
'description' => 'List of users', 'uri' => 'data://users',
'mimeType' => 'application/json', 'description' => 'List of users',
'dataKey' => 'users', // This tells getResourceData() to look in the $data array 'mimeType' => 'application/json',
] ); 'dataKey' => 'users', // This tells getResourceData() to look in the $data array
]
);


$server->registerResource( [ $server->register_resource(
'name' => 'product_catalog', [
'uri' => 'file://./products.json', 'name' => 'product_catalog',
'description' => 'Product catalog', 'uri' => 'file://./products.json',
'mimeType' => 'application/json', 'description' => 'Product catalog',
'filePath' => './products.json', // This tells getResourceData() to read from a file 'mimeType' => 'application/json',
] ); 'filePath' => './products.json', // This tells getResourceData() to read from a file
]
);


$client = new MCP\Client( $server ); $client = new MCP\Client( $server );


$server->registerTool( [ $server->register_tool(
'name' => 'generate_image', [
'description' => 'Generates an image.', 'name' => 'generate_image',
'inputSchema' => [ 'description' => 'Generates an image.',
'type' => 'object', 'inputSchema' => [
'properties' => [ 'type' => 'object',
'prompt' => [ 'type' => 'string', 'description' => 'The prompt for generating the image.' ], 'properties' => [
'prompt' => [
'type' => 'string',
'description' => 'The prompt for generating the image.',
],
],
'required' => [ 'prompt' ],
], ],
'required' => [ 'prompt' ], 'callable' => function ( $params ) use ( $client ) {
], return $client->get_image_from_ai_service( $params['prompt'] );
'callable' => function ( $params ) use ( $client ) { },
return $client->get_image_from_ai_service( $params['prompt'] ); ]
}, );
] );


$result = $client->call_ai_service_with_prompt( $args[0] ); $result = $client->call_ai_service_with_prompt( $args[0] );



View file

@ -23,39 +23,40 @@ class Client {
$this->server = $server; $this->server = $server;
} }


public function sendRequest( $method, $params = [] ) { public function send_request( $method, $params = [] ) {
$request = [ $request = [
'jsonrpc' => '2.0', 'jsonrpc' => '2.0',
'method' => $method, 'method' => $method,
'params' => $params, '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 ); $request_data = json_encode( $request );
$responseData = $this->server->processRequest( $requestData ); $response_data = $this->server->process_request( $request_data );
$response = json_decode( $responseData, true ); $response = json_decode( $response_data, true );


if ( json_last_error() !== JSON_ERROR_NONE ) { if ( json_last_error() !== JSON_ERROR_NONE ) {
throw new Exception( 'Invalid JSON response: ' . json_last_error_msg() ); throw new Exception( 'Invalid JSON response: ' . json_last_error_msg() );
} }


if ( isset( $response['error'] ) ) { 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']; return $response['result'];
} }


public function __call( $name, $arguments ) { // Magic method for calling any method public function __call( $name, $arguments ) {
return $this->sendRequest( $name, $arguments[0] ?? [] ); // Magic method for calling any method
return $this->send_request( $name, $arguments[0] ?? [] );
} }


public function list_resources() { public function list_resources() {
return $this->sendRequest( 'resources/list' ); return $this->send_request( 'resources/list' );
} }


public function read_resource( $uri ) { public function read_resource( $uri ) {
return $this->sendRequest( 'resources/read', [ 'uri' => $uri ] ); return $this->send_request( 'resources/read', [ 'uri' => $uri ] );
} }


// Must not have the same name as the tool, otherwise it takes precedence. // Must not have the same name as the tool, otherwise it takes precedence.
@ -69,11 +70,11 @@ class Client {
); );


try { try {
$service = ai_services()->get_available_service( $service = ai_services()->get_available_service(
[ [
'capabilities' => [ 'capabilities' => [
AI_Capability::IMAGE_GENERATION, AI_Capability::IMAGE_GENERATION,
] ],
] ]
); );
$candidates = $service $candidates = $service
@ -94,17 +95,17 @@ class Client {
$image_url = ''; $image_url = '';
foreach ( $candidates->get( 0 )->get_content()->get_parts() as $part ) { foreach ( $candidates->get( 0 )->get_content()->get_parts() as $part ) {
if ( $part instanceof Inline_Data_Part ) { if ( $part instanceof Inline_Data_Part ) {
$image_url = $part->get_base64_data(); // Data URL. $image_url = $part->get_base64_data(); // Data URL.
$image_blob = Helpers::base64_data_url_to_blob( $image_url ); $image_blob = Helpers::base64_data_url_to_blob( $image_url );


if ( $image_blob ) { if ( $image_blob ) {
$filename = tempnam("/tmp", "ai-generated-image"); $filename = tempnam( '/tmp', 'ai-generated-image' );
$parts = explode( "/", $part->get_mime_type() ); $parts = explode( '/', $part->get_mime_type() );
$extension = $parts[1]; $extension = $parts[1];
rename( $filename, $filename . "." . $extension ); rename( $filename, $filename . '.' . $extension );
$filename .= "." . $extension; $filename .= '.' . $extension;


file_put_contents($filename, $image_blob->get_binary_data() ); file_put_contents( $filename, $image_blob->get_binary_data() );


$image_url = $filename; $image_url = $filename;
} }
@ -149,9 +150,9 @@ class Client {


foreach ( $capabilities['methods'] ?? [] as $tool ) { foreach ( $capabilities['methods'] ?? [] as $tool ) {
$function_declarations[] = [ $function_declarations[] = [
"name" => $tool['name'], 'name' => $tool['name'],
"description" => $tool['description'] ?? "", // Provide a description 'description' => $tool['description'] ?? '', // Provide a description
"parameters" => $tool['inputSchema'] ?? [], // Provide the inputSchema 'parameters' => $tool['inputSchema'] ?? [], // Provide the inputSchema
]; ];
} }


@ -167,11 +168,11 @@ class Client {
AI_Capability::MULTIMODAL_INPUT, AI_Capability::MULTIMODAL_INPUT,
AI_Capability::TEXT_GENERATION, AI_Capability::TEXT_GENERATION,
AI_Capability::FUNCTION_CALLING, AI_Capability::FUNCTION_CALLING,
] ],
] ]
); );


// \WP_CLI::log( "Making request..." . print_r( $contents, true ) ); \WP_CLI::log( 'Making request...' . print_r( $contents, true ) );


$candidates = $service $candidates = $service
->get_model( ->get_model(
@ -190,12 +191,12 @@ class Client {
$text = ''; $text = '';
foreach ( $candidates->get( 0 )->get_content()->get_parts() as $part ) { foreach ( $candidates->get( 0 )->get_content()->get_parts() as $part ) {
if ( $part instanceof Text_Part ) { if ( $part instanceof Text_Part ) {
if ( $text !== '' ) { if ( '' !== $text ) {
$text .= "\n\n"; $text .= "\n\n";
} }
$text .= $part->get_text(); $text .= $part->get_text();
} elseif ( $part instanceof Function_Call_Part ) { } elseif ( $part instanceof Function_Call_Part ) {
var_dump('call function', $part); var_dump( 'call function', $part );
$function_result = $this->{$part->get_name()}( $part->get_args() ); $function_result = $this->{$part->get_name()}( $part->get_args() );


// Odd limitation of add_function_response_part(). // Odd limitation of add_function_response_part().
@ -210,8 +211,8 @@ class Client {
$new_contents[] = new Content( Content_Role::MODEL, $parts ); $new_contents[] = new Content( Content_Role::MODEL, $parts );


$parts = new Parts(); $parts = new Parts();
$parts->add_function_response_part( $part->get_id(),$part->get_name(), $function_result ); $parts->add_function_response_part( $part->get_id(), $part->get_name(), $function_result );
$content = new Content( Content_Role::USER, $parts ); $content = new Content( Content_Role::USER, $parts );
$new_contents[] = $content; $new_contents[] = $content;
} }
} }

View file

@ -7,50 +7,66 @@ use InvalidArgumentException;


class Server { class Server {


private array $data = []; private array $data = [];
private array $tools = []; private array $tools = [];
private array $resources = []; private array $resources = [];


public function __construct() { public function __construct() {
// Sample data (replace with your actual data handling) // Sample data (replace with your actual data handling)
$this->data['users'] = [ $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'] = [ $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 { public function register_tool( array $tool_definition ): void {
if ( ! is_array( $toolDefinition ) || ! isset( $toolDefinition['name'] ) || ! is_callable( $toolDefinition['callable'] ) ) { if ( ! isset( $tool_definition['name'] ) || ! is_callable( $tool_definition['callable'] ) ) {
throw new InvalidArgumentException( "Invalid tool definition. Must be an array with 'name' and 'callable'." ); throw new InvalidArgumentException( "Invalid tool definition. Must be an array with 'name' and 'callable'." );
} }


$name = $toolDefinition['name']; $name = $tool_definition['name'];
$callable = $toolDefinition['callable']; $callable = $tool_definition['callable'];
$description = $toolDefinition['description'] ?? null; $description = $tool_definition['description'] ?? null;
$inputSchema = $toolDefinition['inputSchema'] ?? null; $input_schema = $tool_definition['inputSchema'] ?? null;


$this->tools[ $name ] = [ $this->tools[ $name ] = [
'name' => $name, 'name' => $name,
'callable' => $callable, 'callable' => $callable,
'description' => $description, '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) // Validate the resource definition (similar to tool validation)
if ( ! is_array( $resourceDefinition ) || ! isset( $resourceDefinition['name'] ) || ! isset( $resourceDefinition['uri'] ) ) { if ( ! isset( $resource_definition['name'] ) || ! isset( $resource_definition['uri'] ) ) {
throw new InvalidArgumentException( "Invalid resource definition." ); 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 = [ $capabilities = [
'version' => '1.0', // MCP version (adjust as needed) 'version' => '1.0', // MCP version (adjust as needed)
'methods' => [], 'methods' => [],
@ -77,29 +93,29 @@ class Server {
return $capabilities; return $capabilities;
} }


public function handleRequest( $requestData ): false|string { public function handle_request( string $request_data ): false|string {
$request = json_decode( $requestData, true ); $request = json_decode( $request_data, true );


if ( json_last_error() !== JSON_ERROR_NONE ) { 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' ) { if ( ! isset( $request['jsonrpc'] ) || '2.0' !== $request['jsonrpc'] ) {
return $this->createErrorResponse( $request['id'] ?? null, 'Invalid JSON-RPC version', - 32600 ); // Invalid Request return $this->create_error_response( $request['id'] ?? null, 'Invalid JSON-RPC version', - 32600 ); // Invalid Request
} }


if ( ! isset( $request['method'] ) ) { 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']; $method = $request['method'];
$params = $request['params'] ?? []; $params = $request['params'] ?? [];
$id = $request['id'] ?? null; $id = $request['id'] ?? null;


if ( $method === 'get_capabilities' ) { // Handle capabilities request if ( 'get_capabilities' === $method ) { // Handle capabilities request
$capabilities = $this->getCapabilities(); $capabilities = $this->get_capabilities();


return $this->createSuccessResponse( $id, $capabilities ); return $this->create_success_response( $id, $capabilities );
} }


try { try {
@ -108,48 +124,46 @@ class Server {
$resource = substr( $method, 4 ); // Extract the resource name (e.g., "users" from "get_users") $resource = substr( $method, 4 ); // Extract the resource name (e.g., "users" from "get_users")


if ( isset( $this->data[ $resource ] ) ) { if ( isset( $this->data[ $resource ] ) ) {
$result = $this->handleGetRequest( '/' . $resource, $params ); // Re-use handleGetRequest $result = $this->handle_get_request( '/' . $resource, $params ); // Re-use handleGetRequest
} else if ( isset( $this->data["{$resource}s"] ) ) { } elseif ( isset( $this->data[ "{$resource}s" ] ) ) {
$result = $this->handleGetRequest( '/' . "{$resource}s", $params ); // Re-use handleGetRequest $result = $this->handle_get_request( '/' . "{$resource}s", $params ); // Re-use handleGetRequest
} else { } 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
} }

} elseif ( 'resources/list' === $method ) {
} else if ( $method === 'resources/list' ) { $result = $this->list_resources();
$result = $this->listResources(); } elseif ( 'resources/read' === $method ) {
} elseif ( $method === 'resources/read' ) { $result = $this->read_resource( $params['uri'] ?? null );
$result = $this->readResource( $params['uri'] ?? null );
} else { // Treat as a tool call } else { // Treat as a tool call


$tool = $this->tools[ $method ] ?? null; $tool = $this->tools[ $method ] ?? null;
if ( ! $tool ) { 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 // Validate input parameters against the schema
$inputSchema = $tool['inputSchema'] ?? null; $input_schema = $tool['inputSchema'] ?? null;
if ( $inputSchema ) { if ( $input_schema ) {
$isValid = $this->validateInput( $params, $inputSchema ); $is_valid = $this->validate_input( $params, $input_schema );
if ( ! $isValid['valid'] ) { if ( ! $is_valid['valid'] ) {
return $this->createErrorResponse( $id, 'Invalid input parameters: ' . implode( ", ", $isValid['errors'] ), - 32602 ); // Invalid params 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 $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 ) { } 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 = []; $result = [];
foreach ( $this->resources as $resource ) { foreach ( $this->resources as $resource ) {
$result[] = [ $result[] = [
@ -163,7 +177,7 @@ class Server {
return $result; return $result;
} }


private function readResource( $uri ) { private function read_resource( $uri ) {
// Find the resource by URI // Find the resource by URI
$resource = null; $resource = null;
foreach ( $this->resources as $r ) { foreach ( $this->resources as $r ) {
@ -174,61 +188,64 @@ class Server {
} }


if ( ! $resource ) { 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) // 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 // 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 [ return [
'uri' => $resource['uri'], 'uri' => $resource['uri'],
'mimeType' => $resource['mimeType'] ?? null, 'mimeType' => $resource['mimeType'] ?? null,
( $isBinary ? 'blob' : 'text' ) => $data, ( $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 // Replace this with your actual logic to access the resource data
// based on the resource definition. // based on the resource definition.


// Example: If the resource is a file, read the file contents. // Example: If the resource is a file, read the file contents.
if ( isset( $resource['filePath'] ) ) { if ( isset( $mcp_resource['filePath'] ) ) {
return file_get_contents( $resource['filePath'] ); return file_get_contents( $mcp_resource['filePath'] );
} }


// Example: If the resource is in the $data array, return the data. // Example: If the resource is in the $data array, return the data.
if ( isset( $resource['dataKey'] ) ) { if ( isset( $mcp_resource['dataKey'] ) ) {
return $this->data[ $resource['dataKey'] ]; return $this->data[ $mcp_resource['dataKey'] ];
} }


//... other data access logic... //... 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 { // TODO: use a dedicated JSON schema validator library
// Basic input validation (you might want to use a dedicated JSON schema validator library) private function validate_input( $input, $schema ): array {
$errors = []; $errors = [];
foreach ( $schema['properties'] ?? [] as $paramName => $paramSchema ) { foreach ( $schema['properties'] ?? [] as $param_name => $param_schema ) {
if ( isset( $paramSchema['required'] ) && $paramSchema['required'] === true && ! isset( $input[ $paramName ] ) ) { if ( isset( $param_schema['required'] ) && true === $param_schema['required'] && ! isset( $input[ $param_name ] ) ) {
$errors[] = $paramName . " is required"; $errors[] = $param_name . ' is required';
} }
// Add more validation rules as needed (e.g., type checking) // Add more validation rules as needed (e.g., type checking)
if ( isset( $input[ $paramName ] ) && isset( $paramSchema['type'] ) ) { if ( isset( $input[ $param_name ], $param_schema['type'] ) ) {
$inputType = gettype( $input[ $paramName ] ); $input_type = gettype( $input[ $param_name ] );
if ( $inputType !== $paramSchema['type'] ) { if ( $input_type !== $param_schema['type'] ) {
$errors[] = $paramName . " must be of type " . $paramSchema['type'] . " but " . $inputType . " was given."; $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, '/' ) ); $parts = explode( '/', ltrim( $path, '/' ) );
$resource = $parts[0]; $resource = $parts[0];
$id = $params['id'] ?? null; // Simplified parameter handling $id = $params['id'] ?? null; // Simplified parameter handling
@ -236,42 +253,47 @@ class Server {
if ( isset( $this->data[ $resource ] ) ) { if ( isset( $this->data[ $resource ] ) ) {
$data = $this->data[ $resource ]; $data = $this->data[ $resource ];


if ( $id !== null ) { if ( null !== $id ) {
foreach ( $data as $item ) { foreach ( $data as $item ) {
if ( $item['id'] == $id ) { if ( $item['id'] === $id ) {
return $item; return $item;
} }
} }
throw new Exception( 'Resource not found' ); 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 { private function create_success_response( $id, $result ): false|string {
return json_encode( [ return json_encode(
'jsonrpc' => '2.0', [
'result' => $result, 'jsonrpc' => '2.0',
'id' => $id, 'result' => $result,
] ); 'id' => $id,
}

private function createErrorResponse( $id, $message, $code ): false|string {
return json_encode( [
'jsonrpc' => '2.0',
'error' => [
'code' => $code,
'message' => $message,
], ],
'id' => $id, JSON_THROW_ON_ERROR
] ); );
} }


public function processRequest( $requestData ): false|string { private function create_error_response( $id, $message, $code ): false|string {
return $this->handleRequest( $requestData ); return json_encode(
[
'jsonrpc' => '2.0',
'error' => [
'code' => $code,
'message' => $message,
],
'id' => $id,
],
JSON_THROW_ON_ERROR
);
}

public function process_request( $request_data ): false|string {
return $this->handle_request( $request_data );
} }
} }


View file

@ -0,0 +1,11 @@
<?php

namespace WP_CLI\AiCommand\Tests\MCP\Client;

use WP_CLI\Tests\TestCase;

class ClientTest extends TestCase {
public function test_runs(): void {
$this->assertTrue( true );
}
}