diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..749150c5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +/.git* export-ignore +/examples export-ignore +/tests export-ignore +/.php-cs-fixer.dist.php export-ignore +/phpstan.dist.neon export-ignore +/phpunit.xml.dist export-ignore diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..a4781402 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @chr-hertel @Nyholm @CodeWithKyrian diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml new file mode 100644 index 00000000..583af12a --- /dev/null +++ b/.github/workflows/pipeline.yml @@ -0,0 +1,64 @@ +name: pipeline +on: pull_request + +permissions: + contents: read + pull-requests: write + +jobs: + tests: + runs-on: ubuntu-latest + strategy: + matrix: + php: ['8.1', '8.2', '8.3', '8.4'] + dependencies: ['lowest', 'highest'] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: "none" + + - name: Install Composer + uses: "ramsey/composer-install@v3" + with: + dependency-versions: "${{ matrix.dependencies }}" + + - name: Composer Validation + run: composer validate --strict + + - name: Install PHP Dependencies + run: composer install --no-scripts + + - name: Tests + run: vendor/bin/phpunit + + qa: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + coverage: "none" + + - name: Install Composer + uses: "ramsey/composer-install@v3" + + - name: Composer Validation + run: composer validate --strict + + - name: Install PHP Dependencies + run: composer install --no-scripts + + - name: Code Style PHP + run: vendor/bin/php-cs-fixer fix --dry-run + + - name: PHPStan + run: vendor/bin/phpstan analyse diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..22dd1a41 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.phpunit.cache +.php-cs-fixer.cache +composer.lock +vendor diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 00000000..36bd6480 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +if (!file_exists(__DIR__.'/src')) { + exit(0); +} + +$fileHeaderParts = [ + <<<'EOF' + This file is part of the official PHP MCP SDK. + + A collaboration between Symfony and the PHP Foundation. + + EOF, + <<<'EOF' + + For the full copyright and license information, please view the LICENSE + file that was distributed with this source code. + EOF, +]; + +return (new PhpCsFixer\Config()) + // @see https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/pull/7777 + ->setParallelConfig(PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect()) + ->setRules([ + '@Symfony' => true, + '@Symfony:risky' => true, + 'protected_to_private' => false, + 'declare_strict_types' => false, + 'header_comment' => [ + 'header' => implode('', $fileHeaderParts), + ], + 'php_unit_test_case_static_method_calls' => ['call_type' => 'this'], + ]) + ->setRiskyAllowed(true) + ->setFinder((new PhpCsFixer\Finder())->in(__DIR__)) +; diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..c484c44d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,24 @@ +CHANGELOG +========= + +0.1 +--- + + * Add Model Context Protocol (MCP) implementation for LLM-application communication + * Add JSON-RPC based protocol handling with `JsonRpcHandler` + * Add three core MCP capabilities: + - Resources: File-like data readable by clients (API responses, file contents) + - Tools: Functions callable by LLMs (with user approval) + - Prompts: Pre-written templates for specific tasks + * Add multiple transport implementations: + - Symfony Console Transport for testing and CLI applications + - Stream Transport supporting Server-Sent Events (SSE) and HTTP streaming + - STDIO transport for command-line interfaces + * Add capability chains for organizing features: + - `ToolChain` for tool management + - `ResourceChain` for resource management + - `PromptChain` for prompt template management + * Add Server component managing transport connections + * Add request/notification handlers for MCP operations + * Add standardized interface enabling LLMs to interact with external systems + * Add support for building LLM "plugins" with extra context capabilities \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..4febabbc --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..12e4f321 --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +.PHONY: deps-stable deps-low cs rector phpstan tests coverage run-examples ci ci-stable ci-lowest + +deps-stable: + composer update --prefer-stable + +deps-low: + composer update --prefer-lowest + +cs: + vendor/bin/php-cs-fixer fix --diff --verbose + +phpstan: + vendor/bin/phpstan --memory-limit=-1 + +tests: + vendor/bin/phpunit + +coverage: + XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html=coverage + +ci: ci-stable + +ci-stable: deps-stable cs phpstan tests + +ci-lowest: deps-low cs phpstan tests diff --git a/composer.json b/composer.json new file mode 100644 index 00000000..26b43d49 --- /dev/null +++ b/composer.json @@ -0,0 +1,46 @@ +{ + "name": "mcp/sdk", + "type": "library", + "description": "Model Context Protocol SDK for Client and Server applications in PHP", + "license": "MIT", + "authors": [ + { + "name": "Christopher Hertel", + "email": "mail@christopher-hertel.de" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Kyrian Obikwelu", + "email": "koshnawaza@gmail.com" + } + ], + "require": { + "php": "^8.1", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "symfony/uid": "^6.4 || ^7.0" + }, + "require-dev": { + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^10.5", + "symfony/console": "^6.4 || ^7.0", + "psr/cache": "^3.0", + "php-cs-fixer/shim": "^3.84" + }, + "suggest": { + "symfony/console": "To use SymfonyConsoleTransport for STDIO", + "psr/cache": "To use CachePoolStore with SSE Transport" + }, + "autoload": { + "psr-4": { + "Mcp\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Mcp\\Tests\\": "tests/" + } + } +} diff --git a/doc/index.rst b/doc/index.rst new file mode 100644 index 00000000..e9500b8f --- /dev/null +++ b/doc/index.rst @@ -0,0 +1,157 @@ +Model Context Protocol SDK +========================== + +The PHP MCP SDK is the low level library that enables communication between a PHP application and an LLM model. + +Installation +------------ + +Install the SDK using Composer: + +.. code-block:: terminal + + $ composer require mcp/sdk + +Usage +----- + +The `Model Context Protocol`_ is built on top of JSON-RPC. There two types of +messages. A Notification and Request. The Notification is just a status update +that something has happened. There is never a response to a Notification. A Request +is a message that expects a response. There are 3 concepts/capabilities that you +may use. These are:: + +1. **Resources**: File-like data that can be read by clients (like API responses or file contents) +1. **Tools**: Functions that can be called by the LLM (with user approval) +1. **Prompts**: Pre-written templates that help users accomplish specific tasks + +The SDK comes with NotificationHandlers and RequestHandlers which are expected +to be wired up in your application. + +JsonRpcHandler +.............. + +The ``Mcp\Server\JsonRpcHandler`` is the heart of the SDK. It is here +you inject the NotificationHandlers and RequestHandlers. It is recommended to use +the built-in handlers in ``Mcp\Server\NotificationHandlers\*`` and +``Mcp\Server\RequestHandlers\*``. + +The ``Mcp\Server\JsonRpcHandler`` is started and kept running by +the ``Mcp\Server`` + +Transports +.......... + +The SDK supports multiple transports for sending and receiving messages. The +``Mcp\Server`` is using the transport to fetch a message, then +give it to the ``Mcp\Server\JsonRpcHandler`` and finally send the +response/error back to the transport. The SDK comes with a few transports:: + +1. **Symfony Console Transport**: Good for testing and for CLI applications +1. **Stream Transport**: It uses Server Side Events (SSE) and HTTP streaming + +Capabilities +............ + +Any client would like to discover the capabilities of the server. Exactly what +the server supports is defined in the ``Mcp\Server\RequestHandler\InitializeHandler``. +When the client connects, it sees the capabilities and will ask the server to list +the tools/resource/prompts etc. When you want to add a new capability, example a +**Tool** that can tell the current time, you need to provide some metadata to the +``Mcp\Server\RequestHandler\ToolListHandler``:: + + namespace App; + + use Mcp\Capability\Tool\MetadataInterface; + + class CurrentTimeToolMetadata implements MetadataInterface + { + public function getName(): string + { + return 'Current time'; + } + + public function getDescription(): string + { + return 'Returns the current time in UTC'; + } + + public function getInputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'format' => [ + 'type' => 'string', + 'description' => 'The format of the time, e.g. "Y-m-d H:i:s"', + 'default' => 'Y-m-d H:i:s', + ], + ], + 'required' => [], + ]; + } + } + +We would also need a class to actually execute the tool:: + + namespace App; + + use Mcp\Capability\Tool\IdentifierInterface; + use Mcp\Capability\Tool\ToolCall; + use Mcp\Capability\Tool\ToolCallResult; + use Mcp\Capability\Tool\ToolExecutorInterface; + + class CurrentTimeToolExecutor implements ToolExecutorInterface, IdentifierInterface + { + public function getName(): string + { + return 'Current time'; + } + + public function call(ToolCall $input): ToolCallResult + { + $format = $input->arguments['format'] ?? 'Y-m-d H:i:s'; + + return new ToolCallResult( + (new \DateTime('now', new \DateTimeZone('UTC')))->format($format) + ); + } + } + +If you have multiple tools, you can put them in a ToolChain:: + + $tools = new ToolChain([ + new CurrentTimeToolMetadata(), + new CurrentTimeToolExecutor(), + ]); + + $jsonRpcHandler = new Mcp\Server\JsonRpcHandler( + new Mcp\Message\Factory(), + [ + new ToolCallHandler($tools), + new ToolListHandler($tools), + // Other RequestHandlers ... + ], + [ + // Other NotificationHandlers ... + ], + new NullLogger() + ); + +With this metadata and executor, the client can now call the tool. + +Extending the SDK +----------------- + +If you want to extend the SDK, you can create your own RequestHandlers and NotificationHandlers. +The provided one are very good defaults for most applications but they are not +a requirement. + +If you do decide to use them, you get the benefit of having a well-defined interfaces +and value objects to work with. They will assure that you follow the `Model Context Protocol`_. +specification. + +You also have the Transport abstraction that allows you to create your own transport +if non of the standard ones fit your needs. + +.. _`Model Context Protocol`: https://modelcontextprotocol.io/ diff --git a/examples/cli/README.md b/examples/cli/README.md new file mode 100644 index 00000000..9fc7de49 --- /dev/null +++ b/examples/cli/README.md @@ -0,0 +1,29 @@ +# Example app with CLI + +This is just for testing and debugging purposes. + + +Install and create symlink with: + +```bash +cd /path/to/your/project/examples/cli +composer update +rm -rf vendor/mcp/sdk/src +ln -s /path/to/your/project/src /path/to/your/project/examples/cli/vendor/mcp/sdk/src +``` + +Run the CLI with: + +```bash +DEBUG=1 php index.php +``` + +You will see debug outputs to help you understand what is happening. + +In this terminal you can now test add some json strings. See `example-requests.json`. + +Run with Inspector: + +```bash +npx @modelcontextprotocol/inspector php index.php +``` diff --git a/examples/cli/composer.json b/examples/cli/composer.json new file mode 100644 index 00000000..0a99e8b0 --- /dev/null +++ b/examples/cli/composer.json @@ -0,0 +1,27 @@ +{ + "name": "mcp/cli-server-example", + "description": "An example application for CLI", + "license": "MIT", + "type": "project", + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + } + ], + "require": { + "php": ">=8.1", + "mcp/sdk": "@dev", + "symfony/console": "^7.2" + }, + "minimum-stability": "stable", + "autoload": { + "psr-4": { + "App\\": "src/" + } + }, + "repositories": [ + { "type": "path", "url": "../../" } + ] +} + diff --git a/examples/cli/example-requests.json b/examples/cli/example-requests.json new file mode 100644 index 00000000..eaff960b --- /dev/null +++ b/examples/cli/example-requests.json @@ -0,0 +1,12 @@ +[ + {"jsonrpc": "2.0", "id": 1, "method": "resources/list", "params": []}, + {"jsonrpc": "2.0", "id": 2, "method": "resources/read", "params": {"uri": "file:///project/src/main.rs"}}, + + {"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, + {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "Current time"}}, + {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "Current time","arguments": {"format": "Y-m-d"}}}, + + {"jsonrpc": "2.0", "id": 1, "method": "prompts/list"}, + {"jsonrpc": "2.0", "id": 2 ,"method": "prompts/get", "params": {"name": "Greet"}}, + {"jsonrpc": "2.0", "id": 2 ,"method": "prompts/get", "params": {"name": "Greet", "arguments": { "firstName": "Tobias" }}} +] diff --git a/examples/cli/index.php b/examples/cli/index.php new file mode 100644 index 00000000..90849edc --- /dev/null +++ b/examples/cli/index.php @@ -0,0 +1,39 @@ +connect($transport); diff --git a/examples/cli/src/Builder.php b/examples/cli/src/Builder.php new file mode 100644 index 00000000..36f1d957 --- /dev/null +++ b/examples/cli/src/Builder.php @@ -0,0 +1,72 @@ + + */ +class Builder +{ + /** + * @return list + */ + public static function buildRequestHandlers(): array + { + $promptManager = new PromptChain([ + new ExamplePrompt(), + ]); + + $resourceManager = new ResourceChain([ + new ExampleResource(), + ]); + + $toolManager = new ToolChain([ + new ExampleTool(), + ]); + + return [ + new InitializeHandler(), + new PingHandler(), + new PromptListHandler($promptManager), + new PromptGetHandler($promptManager), + new ResourceListHandler($resourceManager), + new ResourceReadHandler($resourceManager), + new ToolCallHandler($toolManager), + new ToolListHandler($toolManager), + ]; + } + + /** + * @return list + */ + public static function buildNotificationHandlers(): array + { + return [ + new InitializedHandler(), + ]; + } +} diff --git a/examples/cli/src/ExamplePrompt.php b/examples/cli/src/ExamplePrompt.php new file mode 100644 index 00000000..958a1969 --- /dev/null +++ b/examples/cli/src/ExamplePrompt.php @@ -0,0 +1,58 @@ + + */ +class ExamplePrompt implements MetadataInterface, PromptGetterInterface +{ + public function get(PromptGet $input): PromptGetResult + { + $firstName = $input->arguments['first name'] ?? null; + + return new PromptGetResult( + $this->getDescription(), + [new PromptGetResultMessages( + 'user', + \sprintf('Hello %s', $firstName ?? 'World') + )] + ); + } + + public function getName(): string + { + return 'Greet'; + } + + public function getDescription(): ?string + { + return 'Greet a person with a nice message'; + } + + public function getArguments(): array + { + return [ + [ + 'name' => 'first name', + 'description' => 'The name of the person to greet', + 'required' => false, + ], + ]; + } +} diff --git a/examples/cli/src/ExampleResource.php b/examples/cli/src/ExampleResource.php new file mode 100644 index 00000000..0ab4444c --- /dev/null +++ b/examples/cli/src/ExampleResource.php @@ -0,0 +1,56 @@ + + */ +class ExampleResource implements MetadataInterface, ResourceReaderInterface +{ + public function read(ResourceRead $input): ResourceReadResult + { + return new ResourceReadResult( + 'Content of '.$this->getName(), + $this->getUri(), + ); + } + + public function getUri(): string + { + return 'file:///project/src/main.rs'; + } + + public function getName(): string + { + return 'My resource'; + } + + public function getDescription(): ?string + { + return 'This is just an example'; + } + + public function getMimeType(): ?string + { + return null; + } + + public function getSize(): ?int + { + return null; + } +} diff --git a/examples/cli/src/ExampleTool.php b/examples/cli/src/ExampleTool.php new file mode 100644 index 00000000..c1784c49 --- /dev/null +++ b/examples/cli/src/ExampleTool.php @@ -0,0 +1,57 @@ + + */ +class ExampleTool implements MetadataInterface, ToolExecutorInterface +{ + public function call(ToolCall $input): ToolCallResult + { + $format = $input->arguments['format'] ?? 'Y-m-d H:i:s'; + + return new ToolCallResult( + (new \DateTime('now', new \DateTimeZone('UTC')))->format($format) + ); + } + + public function getName(): string + { + return 'Current time'; + } + + public function getDescription(): string + { + return 'Returns the current time in UTC'; + } + + public function getInputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'format' => [ + 'type' => 'string', + 'description' => 'The format of the time, e.g. "Y-m-d H:i:s"', + 'default' => 'Y-m-d H:i:s', + ], + ], + 'required' => [], + ]; + } +} diff --git a/phpstan.dist.neon b/phpstan.dist.neon new file mode 100644 index 00000000..ac6db7e2 --- /dev/null +++ b/phpstan.dist.neon @@ -0,0 +1,9 @@ +parameters: + level: 6 + paths: + - examples/ + - src/ + - tests/ + excludePaths: + - examples/cli/vendor/* (?) + treatPhpDocTypesAsCertain: false diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 00000000..1dfe524e --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,22 @@ + + + + + + tests + + + + + + src + + + diff --git a/src/Capability/Prompt/CollectionInterface.php b/src/Capability/Prompt/CollectionInterface.php new file mode 100644 index 00000000..8498576f --- /dev/null +++ b/src/Capability/Prompt/CollectionInterface.php @@ -0,0 +1,29 @@ + + */ +interface CollectionInterface +{ + /** + * @param int $count the number of metadata items to return + * + * @return iterable + * + * @throws InvalidCursorException if no item with $lastIdentifier was found + */ + public function getMetadata(int $count, ?string $lastIdentifier = null): iterable; +} diff --git a/src/Capability/Prompt/IdentifierInterface.php b/src/Capability/Prompt/IdentifierInterface.php new file mode 100644 index 00000000..8fea7919 --- /dev/null +++ b/src/Capability/Prompt/IdentifierInterface.php @@ -0,0 +1,20 @@ + + */ +interface IdentifierInterface +{ + public function getName(): string; +} diff --git a/src/Capability/Prompt/MetadataInterface.php b/src/Capability/Prompt/MetadataInterface.php new file mode 100644 index 00000000..45c07ab7 --- /dev/null +++ b/src/Capability/Prompt/MetadataInterface.php @@ -0,0 +1,29 @@ + + */ +interface MetadataInterface extends IdentifierInterface +{ + public function getDescription(): ?string; + + /** + * @return list + */ + public function getArguments(): array; +} diff --git a/src/Capability/Prompt/PromptGet.php b/src/Capability/Prompt/PromptGet.php new file mode 100644 index 00000000..30d5fc6f --- /dev/null +++ b/src/Capability/Prompt/PromptGet.php @@ -0,0 +1,28 @@ + + */ +final class PromptGet +{ + /** + * @param array $arguments + */ + public function __construct( + public readonly string $id, + public readonly string $name, + public readonly array $arguments = [], + ) { + } +} diff --git a/src/Capability/Prompt/PromptGetResult.php b/src/Capability/Prompt/PromptGetResult.php new file mode 100644 index 00000000..cb06f33e --- /dev/null +++ b/src/Capability/Prompt/PromptGetResult.php @@ -0,0 +1,27 @@ + + */ +final class PromptGetResult +{ + /** + * @param list $messages + */ + public function __construct( + public readonly string $description, + public readonly array $messages = [], + ) { + } +} diff --git a/src/Capability/Prompt/PromptGetResultMessages.php b/src/Capability/Prompt/PromptGetResultMessages.php new file mode 100644 index 00000000..bb05a1de --- /dev/null +++ b/src/Capability/Prompt/PromptGetResultMessages.php @@ -0,0 +1,30 @@ + + */ +final class PromptGetResultMessages +{ + public function __construct( + public readonly string $role, + public readonly string $result, + /** + * @var "text"|"image"|"audio"|"resource"|non-empty-string + */ + public readonly string $type = 'text', + public readonly string $mimeType = 'text/plan', + public readonly ?string $uri = null, + ) { + } +} diff --git a/src/Capability/Prompt/PromptGetterInterface.php b/src/Capability/Prompt/PromptGetterInterface.php new file mode 100644 index 00000000..cd57e729 --- /dev/null +++ b/src/Capability/Prompt/PromptGetterInterface.php @@ -0,0 +1,27 @@ + + */ +interface PromptGetterInterface +{ + /** + * @throws PromptGetException if the prompt execution fails + * @throws PromptNotFoundException if the prompt is not found + */ + public function get(PromptGet $input): PromptGetResult; +} diff --git a/src/Capability/PromptChain.php b/src/Capability/PromptChain.php new file mode 100644 index 00000000..8ec7a74c --- /dev/null +++ b/src/Capability/PromptChain.php @@ -0,0 +1,77 @@ + + */ +class PromptChain implements PromptGetterInterface, CollectionInterface +{ + public function __construct( + /** + * @var IdentifierInterface[] + */ + private readonly array $items, + ) { + } + + public function getMetadata(int $count, ?string $lastIdentifier = null): iterable + { + $found = null === $lastIdentifier; + foreach ($this->items as $item) { + if (!$item instanceof MetadataInterface) { + continue; + } + + if (false === $found) { + $found = $item->getName() === $lastIdentifier; + continue; + } + + yield $item; + if (--$count <= 0) { + break; + } + } + + if (!$found) { + throw new InvalidCursorException($lastIdentifier); + } + } + + public function get(PromptGet $input): PromptGetResult + { + foreach ($this->items as $item) { + if ($item instanceof PromptGetterInterface && $input->name === $item->getName()) { + try { + return $item->get($input); + } catch (\Throwable $e) { + throw new PromptGetException($input, $e); + } + } + } + + throw new PromptNotFoundException($input); + } +} diff --git a/src/Capability/Resource/CollectionInterface.php b/src/Capability/Resource/CollectionInterface.php new file mode 100644 index 00000000..612b0361 --- /dev/null +++ b/src/Capability/Resource/CollectionInterface.php @@ -0,0 +1,29 @@ + + */ +interface CollectionInterface +{ + /** + * @param int $count the number of metadata items to return + * + * @return iterable + * + * @throws InvalidCursorException if no item with $lastIdentifier was found + */ + public function getMetadata(int $count, ?string $lastIdentifier = null): iterable; +} diff --git a/src/Capability/Resource/IdentifierInterface.php b/src/Capability/Resource/IdentifierInterface.php new file mode 100644 index 00000000..0daa39e8 --- /dev/null +++ b/src/Capability/Resource/IdentifierInterface.php @@ -0,0 +1,20 @@ + + */ +interface IdentifierInterface +{ + public function getUri(): string; +} diff --git a/src/Capability/Resource/MetadataInterface.php b/src/Capability/Resource/MetadataInterface.php new file mode 100644 index 00000000..757dfa1a --- /dev/null +++ b/src/Capability/Resource/MetadataInterface.php @@ -0,0 +1,29 @@ + + */ +interface MetadataInterface extends IdentifierInterface +{ + public function getName(): string; + + public function getDescription(): ?string; + + public function getMimeType(): ?string; + + /** + * Size in bytes. + */ + public function getSize(): ?int; +} diff --git a/src/Capability/Resource/ResourceRead.php b/src/Capability/Resource/ResourceRead.php new file mode 100644 index 00000000..1b60d55a --- /dev/null +++ b/src/Capability/Resource/ResourceRead.php @@ -0,0 +1,24 @@ + + */ +final class ResourceRead +{ + public function __construct( + public readonly string $id, + public readonly string $uri, + ) { + } +} diff --git a/src/Capability/Resource/ResourceReadResult.php b/src/Capability/Resource/ResourceReadResult.php new file mode 100644 index 00000000..b352cf42 --- /dev/null +++ b/src/Capability/Resource/ResourceReadResult.php @@ -0,0 +1,30 @@ + + */ +final class ResourceReadResult +{ + public function __construct( + public readonly string $result, + public readonly string $uri, + + /** + * @var "text"|"blob" + */ + public readonly string $type = 'text', + public readonly string $mimeType = 'text/plain', + ) { + } +} diff --git a/src/Capability/Resource/ResourceReaderInterface.php b/src/Capability/Resource/ResourceReaderInterface.php new file mode 100644 index 00000000..0fdf6042 --- /dev/null +++ b/src/Capability/Resource/ResourceReaderInterface.php @@ -0,0 +1,27 @@ + + */ +interface ResourceReaderInterface +{ + /** + * @throws ResourceReadException if the resource execution fails + * @throws ResourceNotFoundException if the resource is not found + */ + public function read(ResourceRead $input): ResourceReadResult; +} diff --git a/src/Capability/ResourceChain.php b/src/Capability/ResourceChain.php new file mode 100644 index 00000000..ef3b240d --- /dev/null +++ b/src/Capability/ResourceChain.php @@ -0,0 +1,77 @@ + + */ +class ResourceChain implements CollectionInterface, ResourceReaderInterface +{ + public function __construct( + /** + * @var IdentifierInterface[] + */ + private readonly array $items, + ) { + } + + public function getMetadata(int $count, ?string $lastIdentifier = null): iterable + { + $found = null === $lastIdentifier; + foreach ($this->items as $item) { + if (!$item instanceof MetadataInterface) { + continue; + } + + if (false === $found) { + $found = $item->getUri() === $lastIdentifier; + continue; + } + + yield $item; + if (--$count <= 0) { + break; + } + } + + if (!$found) { + throw new InvalidCursorException($lastIdentifier); + } + } + + public function read(ResourceRead $input): ResourceReadResult + { + foreach ($this->items as $item) { + if ($item instanceof ResourceReaderInterface && $input->uri === $item->getUri()) { + try { + return $item->read($input); + } catch (\Throwable $e) { + throw new ResourceReadException($input, $e); + } + } + } + + throw new ResourceNotFoundException($input); + } +} diff --git a/src/Capability/Tool/CollectionInterface.php b/src/Capability/Tool/CollectionInterface.php new file mode 100644 index 00000000..297e2036 --- /dev/null +++ b/src/Capability/Tool/CollectionInterface.php @@ -0,0 +1,29 @@ + + */ +interface CollectionInterface +{ + /** + * @param int $count the number of metadata items to return + * + * @return iterable + * + * @throws InvalidCursorException if no item with $lastIdentifier was found + */ + public function getMetadata(int $count, ?string $lastIdentifier = null): iterable; +} diff --git a/src/Capability/Tool/IdentifierInterface.php b/src/Capability/Tool/IdentifierInterface.php new file mode 100644 index 00000000..0ad3f8c9 --- /dev/null +++ b/src/Capability/Tool/IdentifierInterface.php @@ -0,0 +1,20 @@ + + */ +interface IdentifierInterface +{ + public function getName(): string; +} diff --git a/src/Capability/Tool/MetadataInterface.php b/src/Capability/Tool/MetadataInterface.php new file mode 100644 index 00000000..bebc48b6 --- /dev/null +++ b/src/Capability/Tool/MetadataInterface.php @@ -0,0 +1,32 @@ + + */ +interface MetadataInterface extends IdentifierInterface +{ + public function getDescription(): string; + + /** + * @return array{ + * type?: string, + * required?: list, + * properties?: array, + * } + */ + public function getInputSchema(): array; +} diff --git a/src/Capability/Tool/ToolCall.php b/src/Capability/Tool/ToolCall.php new file mode 100644 index 00000000..cd4bb85b --- /dev/null +++ b/src/Capability/Tool/ToolCall.php @@ -0,0 +1,28 @@ + + */ +final class ToolCall +{ + /** + * @param array $arguments + */ + public function __construct( + public readonly string $id, + public readonly string $name, + public readonly array $arguments = [], + ) { + } +} diff --git a/src/Capability/Tool/ToolCallResult.php b/src/Capability/Tool/ToolCallResult.php new file mode 100644 index 00000000..78933a7c --- /dev/null +++ b/src/Capability/Tool/ToolCallResult.php @@ -0,0 +1,30 @@ + + */ +final class ToolCallResult +{ + public function __construct( + public readonly string $result, + /** + * @var "text"|"image"|"audio"|"resource"|non-empty-string + */ + public readonly string $type = 'text', + public readonly string $mimeType = 'text/plan', + public readonly bool $isError = false, + public readonly ?string $uri = null, + ) { + } +} diff --git a/src/Capability/Tool/ToolCollectionInterface.php b/src/Capability/Tool/ToolCollectionInterface.php new file mode 100644 index 00000000..1c71eea1 --- /dev/null +++ b/src/Capability/Tool/ToolCollectionInterface.php @@ -0,0 +1,23 @@ + + */ +interface ToolCollectionInterface +{ + /** + * @return MetadataInterface[] + */ + public function getMetadata(): array; +} diff --git a/src/Capability/Tool/ToolExecutorInterface.php b/src/Capability/Tool/ToolExecutorInterface.php new file mode 100644 index 00000000..91150573 --- /dev/null +++ b/src/Capability/Tool/ToolExecutorInterface.php @@ -0,0 +1,27 @@ + + */ +interface ToolExecutorInterface +{ + /** + * @throws ToolExecutionException if the tool execution fails + * @throws ToolNotFoundException if the tool is not found + */ + public function call(ToolCall $input): ToolCallResult; +} diff --git a/src/Capability/ToolChain.php b/src/Capability/ToolChain.php new file mode 100644 index 00000000..800831e8 --- /dev/null +++ b/src/Capability/ToolChain.php @@ -0,0 +1,77 @@ + + */ +class ToolChain implements ToolExecutorInterface, CollectionInterface +{ + public function __construct( + /** + * @var IdentifierInterface[] $items + */ + private readonly array $items, + ) { + } + + public function getMetadata(int $count, ?string $lastIdentifier = null): iterable + { + $found = null === $lastIdentifier; + foreach ($this->items as $item) { + if (!$item instanceof MetadataInterface) { + continue; + } + + if (false === $found) { + $found = $item->getName() === $lastIdentifier; + continue; + } + + yield $item; + if (--$count <= 0) { + break; + } + } + + if (!$found) { + throw new InvalidCursorException($lastIdentifier); + } + } + + public function call(ToolCall $input): ToolCallResult + { + foreach ($this->items as $item) { + if ($item instanceof ToolExecutorInterface && $input->name === $item->getName()) { + try { + return $item->call($input); + } catch (\Throwable $e) { + throw new ToolExecutionException($input, $e); + } + } + } + + throw new ToolNotFoundException($input); + } +} diff --git a/src/Exception/ExceptionInterface.php b/src/Exception/ExceptionInterface.php new file mode 100644 index 00000000..63449305 --- /dev/null +++ b/src/Exception/ExceptionInterface.php @@ -0,0 +1,19 @@ + + */ +interface ExceptionInterface extends \Throwable +{ +} diff --git a/src/Exception/HandlerNotFoundException.php b/src/Exception/HandlerNotFoundException.php new file mode 100644 index 00000000..ef222eda --- /dev/null +++ b/src/Exception/HandlerNotFoundException.php @@ -0,0 +1,19 @@ + + */ +class HandlerNotFoundException extends \InvalidArgumentException implements NotFoundExceptionInterface +{ +} diff --git a/src/Exception/InvalidArgumentException.php b/src/Exception/InvalidArgumentException.php new file mode 100644 index 00000000..8c85a7c4 --- /dev/null +++ b/src/Exception/InvalidArgumentException.php @@ -0,0 +1,19 @@ + + */ +class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface +{ +} diff --git a/src/Exception/InvalidCursorException.php b/src/Exception/InvalidCursorException.php new file mode 100644 index 00000000..5b08743a --- /dev/null +++ b/src/Exception/InvalidCursorException.php @@ -0,0 +1,24 @@ + + */ +final class InvalidCursorException extends \InvalidArgumentException implements ExceptionInterface +{ + public function __construct( + public readonly string $cursor, + ) { + parent::__construct(\sprintf('Invalid value for pagination parameter "cursor": "%s"', $cursor)); + } +} diff --git a/src/Exception/InvalidInputMessageException.php b/src/Exception/InvalidInputMessageException.php new file mode 100644 index 00000000..4ab485a9 --- /dev/null +++ b/src/Exception/InvalidInputMessageException.php @@ -0,0 +1,19 @@ + + */ +class InvalidInputMessageException extends \InvalidArgumentException implements ExceptionInterface +{ +} diff --git a/src/Exception/NotFoundExceptionInterface.php b/src/Exception/NotFoundExceptionInterface.php new file mode 100644 index 00000000..faecad67 --- /dev/null +++ b/src/Exception/NotFoundExceptionInterface.php @@ -0,0 +1,19 @@ + + */ +interface NotFoundExceptionInterface extends ExceptionInterface +{ +} diff --git a/src/Exception/PromptGetException.php b/src/Exception/PromptGetException.php new file mode 100644 index 00000000..d73e579b --- /dev/null +++ b/src/Exception/PromptGetException.php @@ -0,0 +1,27 @@ + + */ +final class PromptGetException extends \RuntimeException implements ExceptionInterface +{ + public function __construct( + public readonly PromptGet $promptGet, + ?\Throwable $previous = null, + ) { + parent::__construct(\sprintf('Handling prompt "%s" failed with error: %s', $promptGet->name, $previous->getMessage()), previous: $previous); + } +} diff --git a/src/Exception/PromptNotFoundException.php b/src/Exception/PromptNotFoundException.php new file mode 100644 index 00000000..e8ba9d7a --- /dev/null +++ b/src/Exception/PromptNotFoundException.php @@ -0,0 +1,26 @@ + + */ +final class PromptNotFoundException extends \RuntimeException implements NotFoundExceptionInterface +{ + public function __construct( + public readonly PromptGet $promptGet, + ) { + parent::__construct(\sprintf('Prompt not found for name: "%s"', $promptGet->name)); + } +} diff --git a/src/Exception/ResourceNotFoundException.php b/src/Exception/ResourceNotFoundException.php new file mode 100644 index 00000000..46a3f472 --- /dev/null +++ b/src/Exception/ResourceNotFoundException.php @@ -0,0 +1,26 @@ + + */ +final class ResourceNotFoundException extends \RuntimeException implements NotFoundExceptionInterface +{ + public function __construct( + public readonly ResourceRead $readRequest, + ) { + parent::__construct(\sprintf('Resource not found for uri: "%s"', $readRequest->uri)); + } +} diff --git a/src/Exception/ResourceReadException.php b/src/Exception/ResourceReadException.php new file mode 100644 index 00000000..e0988463 --- /dev/null +++ b/src/Exception/ResourceReadException.php @@ -0,0 +1,27 @@ + + */ +final class ResourceReadException extends \RuntimeException implements ExceptionInterface +{ + public function __construct( + public readonly ResourceRead $readRequest, + ?\Throwable $previous = null, + ) { + parent::__construct(\sprintf('Reading resource "%s" failed with error: %s', $readRequest->uri, $previous?->getMessage() ?? ''), previous: $previous); + } +} diff --git a/src/Exception/ToolExecutionException.php b/src/Exception/ToolExecutionException.php new file mode 100644 index 00000000..09d5df2d --- /dev/null +++ b/src/Exception/ToolExecutionException.php @@ -0,0 +1,27 @@ + + */ +final class ToolExecutionException extends \RuntimeException implements ExceptionInterface +{ + public function __construct( + public readonly ToolCall $toolCall, + ?\Throwable $previous = null, + ) { + parent::__construct(\sprintf('Execution of tool "%s" failed with error: %s', $toolCall->name, $previous?->getMessage() ?? ''), previous: $previous); + } +} diff --git a/src/Exception/ToolNotFoundException.php b/src/Exception/ToolNotFoundException.php new file mode 100644 index 00000000..e9e3bf2d --- /dev/null +++ b/src/Exception/ToolNotFoundException.php @@ -0,0 +1,26 @@ + + */ +final class ToolNotFoundException extends \RuntimeException implements NotFoundExceptionInterface +{ + public function __construct( + public readonly ToolCall $toolCall, + ) { + parent::__construct(\sprintf('Tool not found for call: "%s"', $toolCall->name)); + } +} diff --git a/src/Message/Error.php b/src/Message/Error.php new file mode 100644 index 00000000..400b3555 --- /dev/null +++ b/src/Message/Error.php @@ -0,0 +1,76 @@ + + */ +final class Error implements \JsonSerializable +{ + public const INVALID_REQUEST = -32600; + public const METHOD_NOT_FOUND = -32601; + public const INVALID_PARAMS = -32602; + public const INTERNAL_ERROR = -32603; + public const PARSE_ERROR = -32700; + public const RESOURCE_NOT_FOUND = -32002; + + public function __construct( + public readonly string|int $id, + public readonly int $code, + public readonly string $message, + ) { + } + + public static function invalidRequest(string|int $id, string $message = 'Invalid Request'): self + { + return new self($id, self::INVALID_REQUEST, $message); + } + + public static function methodNotFound(string|int $id, string $message = 'Method not found'): self + { + return new self($id, self::METHOD_NOT_FOUND, $message); + } + + public static function invalidParams(string|int $id, string $message = 'Invalid params'): self + { + return new self($id, self::INVALID_PARAMS, $message); + } + + public static function internalError(string|int $id, string $message = 'Internal error'): self + { + return new self($id, self::INTERNAL_ERROR, $message); + } + + public static function parseError(string|int $id, string $message = 'Parse error'): self + { + return new self($id, self::PARSE_ERROR, $message); + } + + /** + * @return array{ + * jsonrpc: string, + * id: string|int, + * error: array{code: int, message: string} + * } + */ + public function jsonSerialize(): array + { + return [ + 'jsonrpc' => '2.0', + 'id' => $this->id, + 'error' => [ + 'code' => $this->code, + 'message' => $this->message, + ], + ]; + } +} diff --git a/src/Message/Factory.php b/src/Message/Factory.php new file mode 100644 index 00000000..0d78449c --- /dev/null +++ b/src/Message/Factory.php @@ -0,0 +1,44 @@ + + */ +final class Factory +{ + /** + * @return iterable + * + * @throws \JsonException When the input string is not valid JSON + */ + public function create(string $input): iterable + { + $data = json_decode($input, true, flags: \JSON_THROW_ON_ERROR); + + if ('{' === $input[0]) { + $data = [$data]; + } + + foreach ($data as $message) { + if (!isset($message['method'])) { + yield new InvalidInputMessageException('Invalid JSON-RPC request, missing "method".'); + } elseif (str_starts_with((string) $message['method'], 'notifications/')) { + yield Notification::from($message); + } else { + yield Request::from($message); + } + } + } +} diff --git a/src/Message/Notification.php b/src/Message/Notification.php new file mode 100644 index 00000000..76c3c4c3 --- /dev/null +++ b/src/Message/Notification.php @@ -0,0 +1,55 @@ + + */ +final class Notification implements \JsonSerializable, \Stringable +{ + /** + * @param array|null $params + */ + public function __construct( + public readonly string $method, + public readonly ?array $params = null, + ) { + } + + /** + * @param array{method: string, params?: array} $data + */ + public static function from(array $data): self + { + return new self( + $data['method'], + $data['params'] ?? null, + ); + } + + /** + * @return array{jsonrpc: string, method: string, params: array|null} + */ + public function jsonSerialize(): array + { + return [ + 'jsonrpc' => '2.0', + 'method' => $this->method, + 'params' => $this->params, + ]; + } + + public function __toString(): string + { + return \sprintf('%s', $this->method); + } +} diff --git a/src/Message/Request.php b/src/Message/Request.php new file mode 100644 index 00000000..780971af --- /dev/null +++ b/src/Message/Request.php @@ -0,0 +1,58 @@ + + */ +final class Request implements \JsonSerializable, \Stringable +{ + /** + * @param array|null $params + */ + public function __construct( + public readonly int|string $id, + public readonly string $method, + public readonly ?array $params = null, + ) { + } + + /** + * @param array{id: string|int, method: string, params?: array} $data + */ + public static function from(array $data): self + { + return new self( + $data['id'], + $data['method'], + $data['params'] ?? null, + ); + } + + /** + * @return array{jsonrpc: string, id: string|int, method: string, params: array|null} + */ + public function jsonSerialize(): array + { + return [ + 'jsonrpc' => '2.0', + 'id' => $this->id, + 'method' => $this->method, + 'params' => $this->params, + ]; + } + + public function __toString(): string + { + return \sprintf('%s: %s', $this->id, $this->method); + } +} diff --git a/src/Message/Response.php b/src/Message/Response.php new file mode 100644 index 00000000..25d3d458 --- /dev/null +++ b/src/Message/Response.php @@ -0,0 +1,39 @@ + + */ +final class Response implements \JsonSerializable +{ + /** + * @param array $result + */ + public function __construct( + public readonly string|int $id, + public readonly array $result = [], + ) { + } + + /** + * @return array{jsonrpc: string, id: string|int, result: array} + */ + public function jsonSerialize(): array + { + return [ + 'jsonrpc' => '2.0', + 'id' => $this->id, + 'result' => $this->result, + ]; + } +} diff --git a/src/Server.php b/src/Server.php new file mode 100644 index 00000000..5ae10af2 --- /dev/null +++ b/src/Server.php @@ -0,0 +1,64 @@ + + */ +final class Server +{ + public function __construct( + private readonly JsonRpcHandler $jsonRpcHandler, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + public function connect(TransportInterface $transport): void + { + $transport->initialize(); + $this->logger->info('Transport initialized'); + + while ($transport->isConnected()) { + foreach ($transport->receive() as $message) { + if (null === $message) { + continue; + } + + try { + foreach ($this->jsonRpcHandler->process($message) as $response) { + if (null === $response) { + continue; + } + + $transport->send($response); + } + } catch (\JsonException $e) { + $this->logger->error('Failed to encode response to JSON', [ + 'message' => $message, + 'exception' => $e, + ]); + continue; + } + } + + usleep(1000); + } + + $transport->close(); + $this->logger->info('Transport closed'); + } +} diff --git a/src/Server/JsonRpcHandler.php b/src/Server/JsonRpcHandler.php new file mode 100644 index 00000000..9fb9e988 --- /dev/null +++ b/src/Server/JsonRpcHandler.php @@ -0,0 +1,163 @@ + + */ +class JsonRpcHandler +{ + /** + * @var array + */ + private readonly array $requestHandlers; + + /** + * @var array + */ + private readonly array $notificationHandlers; + + /** + * @param iterable $requestHandlers + * @param iterable $notificationHandlers + */ + public function __construct( + private readonly Factory $messageFactory, + iterable $requestHandlers, + iterable $notificationHandlers, + private readonly LoggerInterface $logger, + ) { + $this->requestHandlers = $requestHandlers instanceof \Traversable ? iterator_to_array($requestHandlers) : $requestHandlers; + $this->notificationHandlers = $notificationHandlers instanceof \Traversable ? iterator_to_array($notificationHandlers) : $notificationHandlers; + } + + /** + * @return iterable + * + * @throws ExceptionInterface When a handler throws an exception during message processing + * @throws \JsonException When JSON encoding of the response fails + */ + public function process(string $input): iterable + { + $this->logger->info('Received message to process', ['message' => $input]); + + try { + $messages = $this->messageFactory->create($input); + } catch (\JsonException $e) { + $this->logger->warning('Failed to decode json message', ['exception' => $e]); + + yield $this->encodeResponse(Error::parseError($e->getMessage())); + + return; + } + + foreach ($messages as $message) { + if ($message instanceof InvalidInputMessageException) { + $this->logger->warning('Failed to create message', ['exception' => $message]); + yield $this->encodeResponse(Error::invalidRequest(0, $message->getMessage())); + continue; + } + + $this->logger->info('Decoded incoming message', ['message' => $message]); + + try { + yield $message instanceof Notification + ? $this->handleNotification($message) + : $this->encodeResponse($this->handleRequest($message)); + } catch (\DomainException) { + yield null; + } catch (NotFoundExceptionInterface $e) { + $this->logger->warning(\sprintf('Failed to create response: %s', $e->getMessage()), ['exception' => $e]); + + yield $this->encodeResponse(Error::methodNotFound($message->id, $e->getMessage())); + } catch (\InvalidArgumentException $e) { + $this->logger->warning(\sprintf('Invalid argument: %s', $e->getMessage()), ['exception' => $e]); + + yield $this->encodeResponse(Error::invalidParams($message->id, $e->getMessage())); + } catch (\Throwable $e) { + $this->logger->critical(\sprintf('Uncaught exception: %s', $e->getMessage()), ['exception' => $e]); + + yield $this->encodeResponse(Error::internalError($message->id, $e->getMessage())); + } + } + } + + /** + * @throws \JsonException When JSON encoding fails + */ + private function encodeResponse(Response|Error|null $response): ?string + { + if (null === $response) { + $this->logger->warning('Response is null'); + + return null; + } + + $this->logger->info('Encoding response', ['response' => $response]); + + if ($response instanceof Response && [] === $response->result) { + return json_encode($response, \JSON_THROW_ON_ERROR | \JSON_FORCE_OBJECT); + } + + return json_encode($response, \JSON_THROW_ON_ERROR); + } + + /** + * @return null + * + * @throws ExceptionInterface When a notification handler throws an exception + */ + private function handleNotification(Notification $notification) + { + $handled = false; + foreach ($this->notificationHandlers as $handler) { + if ($handler->supports($notification)) { + $handler->handle($notification); + $handled = true; + } + } + + if (!$handled) { + $this->logger->warning(\sprintf('No handler found for "%s".', $notification->method), ['notification' => $notification]); + } + + return null; + } + + /** + * @throws NotFoundExceptionInterface When no handler is found for the request method + * @throws ExceptionInterface When a request handler throws an exception + */ + private function handleRequest(Request $request): Response|Error + { + foreach ($this->requestHandlers as $handler) { + if ($handler->supports($request)) { + return $handler->createResponse($request); + } + } + + throw new HandlerNotFoundException(\sprintf('No handler found for method "%s".', $request->method)); + } +} diff --git a/src/Server/NotificationHandler/BaseNotificationHandler.php b/src/Server/NotificationHandler/BaseNotificationHandler.php new file mode 100644 index 00000000..0fdcf6b3 --- /dev/null +++ b/src/Server/NotificationHandler/BaseNotificationHandler.php @@ -0,0 +1,28 @@ + + */ +abstract class BaseNotificationHandler implements NotificationHandlerInterface +{ + public function supports(Notification $message): bool + { + return $message->method === \sprintf('notifications/%s', $this->supportedNotification()); + } + + abstract protected function supportedNotification(): string; +} diff --git a/src/Server/NotificationHandler/InitializedHandler.php b/src/Server/NotificationHandler/InitializedHandler.php new file mode 100644 index 00000000..33e4651e --- /dev/null +++ b/src/Server/NotificationHandler/InitializedHandler.php @@ -0,0 +1,29 @@ + + */ +final class InitializedHandler extends BaseNotificationHandler +{ + protected function supportedNotification(): string + { + return 'initialized'; + } + + public function handle(Notification $notification): void + { + } +} diff --git a/src/Server/NotificationHandlerInterface.php b/src/Server/NotificationHandlerInterface.php new file mode 100644 index 00000000..5113aff6 --- /dev/null +++ b/src/Server/NotificationHandlerInterface.php @@ -0,0 +1,28 @@ + + */ +interface NotificationHandlerInterface +{ + public function supports(Notification $message): bool; + + /** + * @throws ExceptionInterface When the handler encounters an error processing the notification + */ + public function handle(Notification $notification): void; +} diff --git a/src/Server/RequestHandler/BaseRequestHandler.php b/src/Server/RequestHandler/BaseRequestHandler.php new file mode 100644 index 00000000..5f44648d --- /dev/null +++ b/src/Server/RequestHandler/BaseRequestHandler.php @@ -0,0 +1,28 @@ + + */ +abstract class BaseRequestHandler implements RequestHandlerInterface +{ + public function supports(Request $message): bool + { + return $message->method === $this->supportedMethod(); + } + + abstract protected function supportedMethod(): string; +} diff --git a/src/Server/RequestHandler/InitializeHandler.php b/src/Server/RequestHandler/InitializeHandler.php new file mode 100644 index 00000000..85c44882 --- /dev/null +++ b/src/Server/RequestHandler/InitializeHandler.php @@ -0,0 +1,45 @@ + + */ +final class InitializeHandler extends BaseRequestHandler +{ + public function __construct( + private readonly string $name = 'app', + private readonly string $version = 'dev', + ) { + } + + public function createResponse(Request $message): Response + { + return new Response($message->id, [ + 'protocolVersion' => '2025-03-26', + 'capabilities' => [ + 'prompts' => ['listChanged' => false], + 'tools' => ['listChanged' => false], + 'resources' => ['listChanged' => false, 'subscribe' => false], + ], + 'serverInfo' => ['name' => $this->name, 'version' => $this->version], + ]); + } + + protected function supportedMethod(): string + { + return 'initialize'; + } +} diff --git a/src/Server/RequestHandler/PingHandler.php b/src/Server/RequestHandler/PingHandler.php new file mode 100644 index 00000000..ee8ec4d4 --- /dev/null +++ b/src/Server/RequestHandler/PingHandler.php @@ -0,0 +1,31 @@ + + */ +final class PingHandler extends BaseRequestHandler +{ + public function createResponse(Request $message): Response + { + return new Response($message->id, []); + } + + protected function supportedMethod(): string + { + return 'ping'; + } +} diff --git a/src/Server/RequestHandler/PromptGetHandler.php b/src/Server/RequestHandler/PromptGetHandler.php new file mode 100644 index 00000000..a7d0cdcd --- /dev/null +++ b/src/Server/RequestHandler/PromptGetHandler.php @@ -0,0 +1,83 @@ + + */ +final class PromptGetHandler extends BaseRequestHandler +{ + public function __construct( + private readonly PromptGetterInterface $getter, + ) { + } + + public function createResponse(Request $message): Response|Error + { + $name = $message->params['name']; + $arguments = $message->params['arguments'] ?? []; + + try { + $result = $this->getter->get(new PromptGet(uniqid('', true), $name, $arguments)); + } catch (ExceptionInterface) { + return Error::internalError($message->id, 'Error while handling prompt'); + } + + $messages = []; + foreach ($result->messages as $resultMessage) { + $content = match ($resultMessage->type) { + 'text' => [ + 'type' => 'text', + 'text' => $resultMessage->result, + ], + 'image', 'audio' => [ + 'type' => $resultMessage->type, + 'data' => $resultMessage->result, + 'mimeType' => $resultMessage->mimeType, + ], + 'resource' => [ + 'type' => 'resource', + 'resource' => [ + 'uri' => $resultMessage->uri, + 'mimeType' => $resultMessage->mimeType, + 'text' => $resultMessage->result, + ], + ], + // TODO better exception + default => throw new InvalidArgumentException('Unsupported PromptGet result type: '.$resultMessage->type), + }; + + $messages[] = [ + 'role' => $resultMessage->role, + 'content' => $content, + ]; + } + + return new Response($message->id, [ + 'description' => $result->description, + 'messages' => $messages, + ]); + } + + protected function supportedMethod(): string + { + return 'prompts/get'; + } +} diff --git a/src/Server/RequestHandler/PromptListHandler.php b/src/Server/RequestHandler/PromptListHandler.php new file mode 100644 index 00000000..b666fb49 --- /dev/null +++ b/src/Server/RequestHandler/PromptListHandler.php @@ -0,0 +1,85 @@ + + */ +final class PromptListHandler extends BaseRequestHandler +{ + public function __construct( + private readonly CollectionInterface $collection, + private readonly int $pageSize = 20, + ) { + } + + public function createResponse(Request $message): Response + { + $nextCursor = null; + $prompts = []; + + $metadataList = $this->collection->getMetadata( + $this->pageSize, + $message->params['cursor'] ?? null + ); + + foreach ($metadataList as $metadata) { + $nextCursor = $metadata->getName(); + $result = [ + 'name' => $metadata->getName(), + ]; + + $description = $metadata->getDescription(); + if (null !== $description) { + $result['description'] = $description; + } + + $arguments = []; + foreach ($metadata->getArguments() as $data) { + $argument = [ + 'name' => $data['name'], + 'required' => $data['required'] ?? false, + ]; + + if (isset($data['description'])) { + $argument['description'] = $data['description']; + } + $arguments[] = $argument; + } + + if ([] !== $arguments) { + $result['arguments'] = $arguments; + } + + $prompts[] = $result; + } + + $result = [ + 'prompts' => $prompts, + ]; + + if (null !== $nextCursor && \count($prompts) === $this->pageSize) { + $result['nextCursor'] = $nextCursor; + } + + return new Response($message->id, $result); + } + + protected function supportedMethod(): string + { + return 'prompts/list'; + } +} diff --git a/src/Server/RequestHandler/ResourceListHandler.php b/src/Server/RequestHandler/ResourceListHandler.php new file mode 100644 index 00000000..f91dc7dc --- /dev/null +++ b/src/Server/RequestHandler/ResourceListHandler.php @@ -0,0 +1,79 @@ + + */ +final class ResourceListHandler extends BaseRequestHandler +{ + public function __construct( + private readonly CollectionInterface $collection, + private readonly int $pageSize = 20, + ) { + } + + public function createResponse(Request $message): Response + { + $nextCursor = null; + $resources = []; + + $metadataList = $this->collection->getMetadata( + $this->pageSize, + $message->params['cursor'] ?? null + ); + + foreach ($metadataList as $metadata) { + $nextCursor = $metadata->getUri(); + $result = [ + 'uri' => $metadata->getUri(), + 'name' => $metadata->getName(), + ]; + + $description = $metadata->getDescription(); + if (null !== $description) { + $result['description'] = $description; + } + + $mimeType = $metadata->getMimeType(); + if (null !== $mimeType) { + $result['mimeType'] = $mimeType; + } + + $size = $metadata->getSize(); + if (null !== $size) { + $result['size'] = $size; + } + + $resources[] = $result; + } + + $result = [ + 'resources' => $resources, + ]; + + if (null !== $nextCursor && \count($resources) === $this->pageSize) { + $result['nextCursor'] = $nextCursor; + } + + return new Response($message->id, $result); + } + + protected function supportedMethod(): string + { + return 'resources/list'; + } +} diff --git a/src/Server/RequestHandler/ResourceReadHandler.php b/src/Server/RequestHandler/ResourceReadHandler.php new file mode 100644 index 00000000..46750b4a --- /dev/null +++ b/src/Server/RequestHandler/ResourceReadHandler.php @@ -0,0 +1,59 @@ + + */ +final class ResourceReadHandler extends BaseRequestHandler +{ + public function __construct( + private readonly ResourceReaderInterface $reader, + ) { + } + + public function createResponse(Request $message): Response|Error + { + $uri = $message->params['uri']; + + try { + $result = $this->reader->read(new ResourceRead(uniqid('', true), $uri)); + } catch (ResourceNotFoundException $e) { + return new Error($message->id, Error::RESOURCE_NOT_FOUND, $e->getMessage()); + } catch (ExceptionInterface) { + return Error::internalError($message->id, 'Error while reading resource'); + } + + return new Response($message->id, [ + 'contents' => [ + [ + 'uri' => $result->uri, + 'mimeType' => $result->mimeType, + $result->type => $result->result, + ], + ], + ]); + } + + protected function supportedMethod(): string + { + return 'resources/read'; + } +} diff --git a/src/Server/RequestHandler/ToolCallHandler.php b/src/Server/RequestHandler/ToolCallHandler.php new file mode 100644 index 00000000..984f3e83 --- /dev/null +++ b/src/Server/RequestHandler/ToolCallHandler.php @@ -0,0 +1,76 @@ + + * @author Tobias Nyholm + */ +final class ToolCallHandler extends BaseRequestHandler +{ + public function __construct( + private readonly ToolExecutorInterface $toolExecutor, + ) { + } + + public function createResponse(Request $message): Response|Error + { + $name = $message->params['name']; + $arguments = $message->params['arguments'] ?? []; + + try { + $result = $this->toolExecutor->call(new ToolCall(uniqid('', true), $name, $arguments)); + } catch (ExceptionInterface) { + return Error::internalError($message->id, 'Error while executing tool'); + } + + $content = match ($result->type) { + 'text' => [ + 'type' => 'text', + 'text' => $result->result, + ], + 'image', 'audio' => [ + 'type' => $result->type, + 'data' => $result->result, + 'mimeType' => $result->mimeType, + ], + 'resource' => [ + 'type' => 'resource', + 'resource' => [ + 'uri' => $result->uri, + 'mimeType' => $result->mimeType, + 'text' => $result->result, + ], + ], + // TODO better exception + default => throw new InvalidArgumentException('Unsupported tool result type: '.$result->type), + }; + + return new Response($message->id, [ + 'content' => [$content], // TODO: allow multiple `ToolCallResult`s in the future + 'isError' => $result->isError, + ]); + } + + protected function supportedMethod(): string + { + return 'tools/call'; + } +} diff --git a/src/Server/RequestHandler/ToolListHandler.php b/src/Server/RequestHandler/ToolListHandler.php new file mode 100644 index 00000000..5e09a704 --- /dev/null +++ b/src/Server/RequestHandler/ToolListHandler.php @@ -0,0 +1,68 @@ + + * @author Tobias Nyholm + */ +final class ToolListHandler extends BaseRequestHandler +{ + public function __construct( + private readonly CollectionInterface $collection, + private readonly int $pageSize = 20, + ) { + } + + public function createResponse(Request $message): Response + { + $nextCursor = null; + $tools = []; + + $metadataList = $this->collection->getMetadata( + $this->pageSize, + $message->params['cursor'] ?? null + ); + + foreach ($metadataList as $tool) { + $nextCursor = $tool->getName(); + $inputSchema = $tool->getInputSchema(); + $tools[] = [ + 'name' => $tool->getName(), + 'description' => $tool->getDescription(), + 'inputSchema' => [] === $inputSchema ? [ + 'type' => 'object', + '$schema' => 'http://json-schema.org/draft-07/schema#', + ] : $inputSchema, + ]; + } + + $result = [ + 'tools' => $tools, + ]; + + if (null !== $nextCursor && \count($tools) === $this->pageSize) { + $result['nextCursor'] = $nextCursor; + } + + return new Response($message->id, $result); + } + + protected function supportedMethod(): string + { + return 'tools/list'; + } +} diff --git a/src/Server/RequestHandlerInterface.php b/src/Server/RequestHandlerInterface.php new file mode 100644 index 00000000..af69b022 --- /dev/null +++ b/src/Server/RequestHandlerInterface.php @@ -0,0 +1,30 @@ + + */ +interface RequestHandlerInterface +{ + public function supports(Request $message): bool; + + /** + * @throws ExceptionInterface When the handler encounters an error processing the request + */ + public function createResponse(Request $message): Response|Error; +} diff --git a/src/Server/Transport/Sse/Store/CachePoolStore.php b/src/Server/Transport/Sse/Store/CachePoolStore.php new file mode 100644 index 00000000..68a476fb --- /dev/null +++ b/src/Server/Transport/Sse/Store/CachePoolStore.php @@ -0,0 +1,65 @@ + + */ +final class CachePoolStore implements StoreInterface +{ + public function __construct( + private readonly CacheItemPoolInterface $cachePool, + ) { + } + + public function push(Uuid $id, string $message): void + { + $item = $this->cachePool->getItem($this->getCacheKey($id)); + + $messages = $item->isHit() ? $item->get() : []; + $messages[] = $message; + $item->set($messages); + + $this->cachePool->save($item); + } + + public function pop(Uuid $id): ?string + { + $item = $this->cachePool->getItem($this->getCacheKey($id)); + + if (!$item->isHit()) { + return null; + } + + $messages = $item->get(); + $message = array_shift($messages); + + $item->set($messages); + $this->cachePool->save($item); + + return $message; + } + + public function remove(Uuid $id): void + { + $this->cachePool->deleteItem($this->getCacheKey($id)); + } + + private function getCacheKey(Uuid $id): string + { + return 'message_'.$id->toRfc4122(); + } +} diff --git a/src/Server/Transport/Sse/StoreInterface.php b/src/Server/Transport/Sse/StoreInterface.php new file mode 100644 index 00000000..e2bed2d9 --- /dev/null +++ b/src/Server/Transport/Sse/StoreInterface.php @@ -0,0 +1,26 @@ + + */ +interface StoreInterface +{ + public function push(Uuid $id, string $message): void; + + public function pop(Uuid $id): ?string; + + public function remove(Uuid $id): void; +} diff --git a/src/Server/Transport/Sse/StreamTransport.php b/src/Server/Transport/Sse/StreamTransport.php new file mode 100644 index 00000000..70a01189 --- /dev/null +++ b/src/Server/Transport/Sse/StreamTransport.php @@ -0,0 +1,65 @@ + + */ +final class StreamTransport implements TransportInterface +{ + public function __construct( + private readonly string $messageEndpoint, + private readonly StoreInterface $store, + private readonly Uuid $id, + ) { + } + + public function initialize(): void + { + ignore_user_abort(true); + $this->flushEvent('endpoint', $this->messageEndpoint); + } + + public function isConnected(): bool + { + return 0 === connection_aborted(); + } + + public function receive(): \Generator + { + yield $this->store->pop($this->id); + } + + public function send(string $data): void + { + $this->flushEvent('message', $data); + } + + public function close(): void + { + $this->store->remove($this->id); + } + + private function flushEvent(string $event, string $data): void + { + echo \sprintf('event: %s', $event).\PHP_EOL; + echo \sprintf('data: %s', $data).\PHP_EOL; + echo \PHP_EOL; + if (false !== ob_get_length()) { + ob_flush(); + } + flush(); + } +} diff --git a/src/Server/Transport/Stdio/SymfonyConsoleTransport.php b/src/Server/Transport/Stdio/SymfonyConsoleTransport.php new file mode 100644 index 00000000..17e5a371 --- /dev/null +++ b/src/Server/Transport/Stdio/SymfonyConsoleTransport.php @@ -0,0 +1,65 @@ +input instanceof StreamableInputInterface ? $this->input->getStream() ?? \STDIN : \STDIN; + $line = fgets($stream); + if (false === $line) { + return; + } + $this->buffer .= \STDIN === $stream ? rtrim($line).\PHP_EOL : $line; + if (str_contains($this->buffer, \PHP_EOL)) { + $lines = explode(\PHP_EOL, $this->buffer); + $this->buffer = array_pop($lines); + + yield from $lines; + } + } + + public function send(string $data): void + { + $this->output->writeln($data); + } + + public function close(): void + { + } +} diff --git a/src/Server/TransportInterface.php b/src/Server/TransportInterface.php new file mode 100644 index 00000000..49963a70 --- /dev/null +++ b/src/Server/TransportInterface.php @@ -0,0 +1,28 @@ + + */ +interface TransportInterface +{ + public function initialize(): void; + + public function isConnected(): bool; + + public function receive(): \Generator; + + public function send(string $data): void; + + public function close(): void; +} diff --git a/tests/Fixtures/InMemoryTransport.php b/tests/Fixtures/InMemoryTransport.php new file mode 100644 index 00000000..046abd56 --- /dev/null +++ b/tests/Fixtures/InMemoryTransport.php @@ -0,0 +1,50 @@ + $messages + */ + public function __construct( + private readonly array $messages = [], + ) { + } + + public function initialize(): void + { + } + + public function isConnected(): bool + { + return $this->connected; + } + + public function receive(): \Generator + { + yield from $this->messages; + $this->connected = false; + } + + public function send(string $data): void + { + } + + public function close(): void + { + } +} diff --git a/tests/Message/ErrorTest.php b/tests/Message/ErrorTest.php new file mode 100644 index 00000000..99d0763a --- /dev/null +++ b/tests/Message/ErrorTest.php @@ -0,0 +1,52 @@ + '2.0', + 'id' => 1, + 'error' => [ + 'code' => -32602, + 'message' => 'Another error occurred', + ], + ]; + + $this->assertSame($expected, $error->jsonSerialize()); + } + + public function testWithStringId(): void + { + $error = new Error('abc', -32602, 'Another error occurred'); + $expected = [ + 'jsonrpc' => '2.0', + 'id' => 'abc', + 'error' => [ + 'code' => -32602, + 'message' => 'Another error occurred', + ], + ]; + + $this->assertSame($expected, $error->jsonSerialize()); + } +} diff --git a/tests/Message/FactoryTest.php b/tests/Message/FactoryTest.php new file mode 100644 index 00000000..e1e13ddb --- /dev/null +++ b/tests/Message/FactoryTest.php @@ -0,0 +1,94 @@ +factory = new Factory(); + } + + /** + * @param iterable $items + */ + private function first(iterable $items): mixed + { + foreach ($items as $item) { + return $item; + } + + return null; + } + + public function testCreateRequest(): void + { + $json = '{"jsonrpc": "2.0", "method": "test_method", "params": {"foo": "bar"}, "id": 123}'; + + $result = $this->first($this->factory->create($json)); + + $this->assertInstanceOf(Request::class, $result); + $this->assertSame('test_method', $result->method); + $this->assertSame(['foo' => 'bar'], $result->params); + $this->assertSame(123, $result->id); + } + + public function testCreateNotification(): void + { + $json = '{"jsonrpc": "2.0", "method": "notifications/test_event", "params": {"foo": "bar"}}'; + + $result = $this->first($this->factory->create($json)); + + $this->assertInstanceOf(Notification::class, $result); + $this->assertSame('notifications/test_event', $result->method); + $this->assertSame(['foo' => 'bar'], $result->params); + } + + public function testInvalidJson(): void + { + $this->expectException(\JsonException::class); + + $this->first($this->factory->create('invalid json')); + } + + public function testMissingMethod(): void + { + $result = $this->first($this->factory->create('{"jsonrpc": "2.0", "params": {}, "id": 1}')); + $this->assertInstanceOf(InvalidInputMessageException::class, $result); + $this->assertEquals('Invalid JSON-RPC request, missing "method".', $result->getMessage()); + } + + public function testBatchMissingMethod(): void + { + $results = $this->factory->create('[{"jsonrpc": "2.0", "params": {}, "id": 1}, {"jsonrpc": "2.0", "method": "notifications/test_event", "params": {}, "id": 2}]'); + + $results = iterator_to_array($results); + $result = array_shift($results); + $this->assertInstanceOf(InvalidInputMessageException::class, $result); + $this->assertEquals('Invalid JSON-RPC request, missing "method".', $result->getMessage()); + + $result = array_shift($results); + $this->assertInstanceOf(Notification::class, $result); + } +} diff --git a/tests/Message/ResponseTest.php b/tests/Message/ResponseTest.php new file mode 100644 index 00000000..e087d2d3 --- /dev/null +++ b/tests/Message/ResponseTest.php @@ -0,0 +1,46 @@ + 'bar']); + $expected = [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => ['foo' => 'bar'], + ]; + + $this->assertSame($expected, $response->jsonSerialize()); + } + + public function testWithStringId(): void + { + $response = new Response('abc', ['foo' => 'bar']); + $expected = [ + 'jsonrpc' => '2.0', + 'id' => 'abc', + 'result' => ['foo' => 'bar'], + ]; + + $this->assertSame($expected, $response->jsonSerialize()); + } +} diff --git a/tests/Server/JsonRpcHandlerTest.php b/tests/Server/JsonRpcHandlerTest.php new file mode 100644 index 00000000..ef1d5dcc --- /dev/null +++ b/tests/Server/JsonRpcHandlerTest.php @@ -0,0 +1,90 @@ +getMockBuilder(NotificationHandlerInterface::class) + ->disableOriginalConstructor() + ->onlyMethods(['supports', 'handle']) + ->getMock(); + $handlerA->method('supports')->willReturn(true); + $handlerA->expects($this->once())->method('handle'); + + $handlerB = $this->getMockBuilder(NotificationHandlerInterface::class) + ->disableOriginalConstructor() + ->onlyMethods(['supports', 'handle']) + ->getMock(); + $handlerB->method('supports')->willReturn(false); + $handlerB->expects($this->never())->method('handle'); + + $handlerC = $this->getMockBuilder(NotificationHandlerInterface::class) + ->disableOriginalConstructor() + ->onlyMethods(['supports', 'handle']) + ->getMock(); + $handlerC->method('supports')->willReturn(true); + $handlerC->expects($this->once())->method('handle'); + + $jsonRpc = new JsonRpcHandler(new Factory(), [], [$handlerA, $handlerB, $handlerC], new NullLogger()); + $result = $jsonRpc->process( + '{"jsonrpc": "2.0", "id": 1, "method": "notifications/foobar"}' + ); + iterator_to_array($result); + } + + #[TestDox('Make sure a single request can NOT be handled by multiple handlers.')] + public function testHandleMultipleRequests(): void + { + $handlerA = $this->getMockBuilder(RequestHandlerInterface::class) + ->disableOriginalConstructor() + ->onlyMethods(['supports', 'createResponse']) + ->getMock(); + $handlerA->method('supports')->willReturn(true); + $handlerA->expects($this->once())->method('createResponse')->willReturn(new Response(1)); + + $handlerB = $this->getMockBuilder(RequestHandlerInterface::class) + ->disableOriginalConstructor() + ->onlyMethods(['supports', 'createResponse']) + ->getMock(); + $handlerB->method('supports')->willReturn(false); + $handlerB->expects($this->never())->method('createResponse'); + + $handlerC = $this->getMockBuilder(RequestHandlerInterface::class) + ->disableOriginalConstructor() + ->onlyMethods(['supports', 'createResponse']) + ->getMock(); + $handlerC->method('supports')->willReturn(true); + $handlerC->expects($this->never())->method('createResponse'); + + $jsonRpc = new JsonRpcHandler(new Factory(), [$handlerA, $handlerB, $handlerC], [], new NullLogger()); + $result = $jsonRpc->process( + '{"jsonrpc": "2.0", "id": 1, "method": "request/foobar"}' + ); + iterator_to_array($result); + } +} diff --git a/tests/Server/RequestHandler/PromptListHandlerTest.php b/tests/Server/RequestHandler/PromptListHandlerTest.php new file mode 100644 index 00000000..ce522c69 --- /dev/null +++ b/tests/Server/RequestHandler/PromptListHandlerTest.php @@ -0,0 +1,80 @@ +createResponse($message); + $this->assertEquals(1, $response->id); + $this->assertEquals(['prompts' => []], $response->result); + } + + public function testHandleReturnAll(): void + { + $item = self::createMetadataItem(); + $handler = new PromptListHandler(new PromptChain([$item])); + $message = new Request(1, 'prompts/list', []); + $response = $handler->createResponse($message); + $this->assertCount(1, $response->result['prompts']); + $this->assertArrayNotHasKey('nextCursor', $response->result); + } + + public function testHandlePagination(): void + { + $item = self::createMetadataItem(); + $handler = new PromptListHandler(new PromptChain([$item, $item]), 2); + $message = new Request(1, 'prompts/list', []); + $response = $handler->createResponse($message); + $this->assertCount(2, $response->result['prompts']); + $this->assertArrayHasKey('nextCursor', $response->result); + } + + private static function createMetadataItem(): MetadataInterface + { + return new class implements MetadataInterface { + public function getName(): string + { + return 'greet'; + } + + public function getDescription(): string + { + return 'Greet a person with a nice message'; + } + + public function getArguments(): array + { + return [ + [ + 'name' => 'first name', + 'description' => 'The name of the person to greet', + 'required' => false, + ], + ]; + } + }; + } +} diff --git a/tests/Server/RequestHandler/ResourceListHandlerTest.php b/tests/Server/RequestHandler/ResourceListHandlerTest.php new file mode 100644 index 00000000..a2cca7d8 --- /dev/null +++ b/tests/Server/RequestHandler/ResourceListHandlerTest.php @@ -0,0 +1,117 @@ +getMockBuilder(CollectionInterface::class) + ->disableOriginalConstructor() + ->onlyMethods(['getMetadata']) + ->getMock(); + $collection->expects($this->once())->method('getMetadata')->willReturn([]); + + $handler = new ResourceListHandler($collection); + $message = new Request(1, 'resources/list', []); + $response = $handler->createResponse($message); + $this->assertEquals(1, $response->id); + $this->assertEquals(['resources' => []], $response->result); + } + + /** + * @param iterable $metadataList + */ + #[DataProvider('metadataProvider')] + public function testHandleReturnAll(iterable $metadataList): void + { + $collection = $this->getMockBuilder(CollectionInterface::class) + ->disableOriginalConstructor() + ->onlyMethods(['getMetadata']) + ->getMock(); + $collection->expects($this->once())->method('getMetadata')->willReturn($metadataList); + $handler = new ResourceListHandler($collection); + $message = new Request(1, 'resources/list', []); + $response = $handler->createResponse($message); + $this->assertCount(1, $response->result['resources']); + $this->assertArrayNotHasKey('nextCursor', $response->result); + } + + /** + * @return array> + */ + public static function metadataProvider(): array + { + $item = self::createMetadataItem(); + + return [ + 'array' => [[$item]], + 'generator' => [(function () use ($item) { yield $item; })()], + ]; + } + + public function testHandlePagination(): void + { + $item = self::createMetadataItem(); + $collection = $this->getMockBuilder(CollectionInterface::class) + ->disableOriginalConstructor() + ->onlyMethods(['getMetadata']) + ->getMock(); + $collection->expects($this->once())->method('getMetadata')->willReturn([$item, $item]); + $handler = new ResourceListHandler($collection, 2); + $message = new Request(1, 'resources/list', []); + $response = $handler->createResponse($message); + $this->assertCount(2, $response->result['resources']); + $this->assertArrayHasKey('nextCursor', $response->result); + } + + private static function createMetadataItem(): MetadataInterface + { + return new class implements MetadataInterface { + public function getUri(): string + { + return 'file:///src/SomeFile.php'; + } + + public function getName(): string + { + return 'src/SomeFile.php'; + } + + public function getDescription(): string + { + return 'File src/SomeFile.php'; + } + + public function getMimeType(): string + { + return 'text/plain'; + } + + public function getSize(): int + { + return 1024; + } + }; + } +} diff --git a/tests/Server/RequestHandler/ToolListHandlerTest.php b/tests/Server/RequestHandler/ToolListHandlerTest.php new file mode 100644 index 00000000..00a49064 --- /dev/null +++ b/tests/Server/RequestHandler/ToolListHandlerTest.php @@ -0,0 +1,109 @@ +getMockBuilder(CollectionInterface::class) + ->disableOriginalConstructor() + ->onlyMethods(['getMetadata']) + ->getMock(); + $collection->expects($this->once())->method('getMetadata')->willReturn([]); + + $handler = new ToolListHandler($collection); + $message = new Request(1, 'tools/list', []); + $response = $handler->createResponse($message); + $this->assertEquals(1, $response->id); + $this->assertEquals(['tools' => []], $response->result); + } + + /** + * @param iterable $metadataList + */ + #[DataProvider('metadataProvider')] + public function testHandleReturnAll(iterable $metadataList): void + { + $collection = $this->getMockBuilder(CollectionInterface::class) + ->disableOriginalConstructor() + ->onlyMethods(['getMetadata']) + ->getMock(); + $collection->expects($this->once())->method('getMetadata')->willReturn($metadataList); + $handler = new ToolListHandler($collection); + $message = new Request(1, 'tools/list', []); + $response = $handler->createResponse($message); + $this->assertCount(1, $response->result['tools']); + $this->assertArrayNotHasKey('nextCursor', $response->result); + } + + /** + * @return array> + */ + public static function metadataProvider(): array + { + $item = self::createMetadataItem(); + + return [ + 'array' => [[$item]], + 'generator' => [(function () use ($item) { yield $item; })()], + ]; + } + + #[Test] + public function handlePagination(): void + { + $item = self::createMetadataItem(); + $collection = $this->getMockBuilder(CollectionInterface::class) + ->disableOriginalConstructor() + ->onlyMethods(['getMetadata']) + ->getMock(); + $collection->expects($this->once())->method('getMetadata')->willReturn([$item, $item]); + $handler = new ToolListHandler($collection, 2); + $message = new Request(1, 'tools/list', []); + $response = $handler->createResponse($message); + $this->assertCount(2, $response->result['tools']); + $this->assertArrayHasKey('nextCursor', $response->result); + } + + private static function createMetadataItem(): MetadataInterface + { + return new class implements MetadataInterface { + public function getName(): string + { + return 'test_tool'; + } + + public function getDescription(): string + { + return 'A test tool'; + } + + public function getInputSchema(): array + { + return ['type' => 'object']; + } + }; + } +} diff --git a/tests/ServerTest.php b/tests/ServerTest.php new file mode 100644 index 00000000..e656cc77 --- /dev/null +++ b/tests/ServerTest.php @@ -0,0 +1,50 @@ +getMockBuilder(NullLogger::class) + ->disableOriginalConstructor() + ->onlyMethods(['error']) + ->getMock(); + $logger->expects($this->once())->method('error'); + + $handler = $this->getMockBuilder(JsonRpcHandler::class) + ->disableOriginalConstructor() + ->onlyMethods(['process']) + ->getMock(); + $handler->expects($this->exactly(2))->method('process')->willReturnOnConsecutiveCalls(new Exception(new \JsonException('foobar')), ['success']); + + $transport = $this->getMockBuilder(InMemoryTransport::class) + ->setConstructorArgs([['foo', 'bar']]) + ->onlyMethods(['send']) + ->getMock(); + $transport->expects($this->once())->method('send')->with('success'); + + $server = new Server($handler, $logger); + $server->connect($transport); + } +}