From af10b8540cda630d57cf2ab86e36914333420cb5 Mon Sep 17 00:00:00 2001 From: Tobias Nyholm Date: Sun, 25 May 2025 13:29:21 +0200 Subject: [PATCH] feat: add capabilities and example app --- .gitattributes | 1 + examples/cli/README.md | 23 ++++++ examples/cli/composer.json | 24 +++++++ examples/cli/example-requests.json | 12 ++++ examples/cli/index.php | 29 ++++++++ examples/cli/src/Builder.php | 52 ++++++++++++++ examples/cli/src/ExamplePrompt.php | 34 +++++++++ examples/cli/src/ExampleResource.php | 33 +++++++++ examples/cli/src/ExampleTool.php | 38 ++++++++++ examples/cli/src/Manager/PromptManager.php | 53 ++++++++++++++ examples/cli/src/Manager/ResourceManager.php | 46 ++++++++++++ examples/cli/src/Manager/ToolManager.php | 48 +++++++++++++ phpstan.dist.neon | 3 + src/Capability/Prompt/CollectionInterface.php | 11 +++ src/Capability/Prompt/MetadataInterface.php | 21 ++++++ src/Capability/Prompt/PromptGet.php | 18 +++++ src/Capability/Prompt/PromptGetResult.php | 17 +++++ .../Prompt/PromptGetResultMessages.php | 20 ++++++ .../Prompt/PromptGetterInterface.php | 15 ++++ .../Resource/CollectionInterface.php | 11 +++ src/Capability/Resource/MetadataInterface.php | 21 ++++++ src/Capability/Resource/ResourceRead.php | 14 ++++ .../Resource/ResourceReadResult.php | 20 ++++++ .../Resource/ResourceReaderInterface.php | 15 ++++ src/Capability/Tool/CollectionInterface.php | 11 +++ src/Capability/Tool/MetadataInterface.php | 9 ++- src/Capability/Tool/ToolCall.php | 24 +------ src/Capability/Tool/ToolCallResult.php | 20 ++++++ src/Capability/Tool/ToolExecutorInterface.php | 2 +- src/Exception/NotFoundExceptionInterface.php | 9 +++ src/Exception/PromptGetException.php | 17 +++++ src/Exception/PromptNotFoundException.php | 16 +++++ src/Exception/ResourceNotFoundException.php | 16 +++++ src/Exception/ResourceReadException.php | 17 +++++ src/Exception/ToolExecutionException.php | 2 +- src/Exception/ToolNotFoundException.php | 4 +- .../RequestHandler/InitializeHandler.php | 6 +- .../RequestHandler/PromptGetHandler.php | 72 +++++++++++++++++++ .../RequestHandler/PromptListHandler.php | 59 +++++++++++++++ .../RequestHandler/ResourceListHandler.php | 53 ++++++++++++++ .../RequestHandler/ResourceReadHandler.php | 46 ++++++++++++ src/Server/RequestHandler/ToolCallHandler.php | 27 ++++++- src/Server/RequestHandler/ToolListHandler.php | 4 +- 43 files changed, 958 insertions(+), 35 deletions(-) create mode 100644 examples/cli/README.md create mode 100644 examples/cli/composer.json create mode 100644 examples/cli/example-requests.json create mode 100644 examples/cli/index.php create mode 100644 examples/cli/src/Builder.php create mode 100644 examples/cli/src/ExamplePrompt.php create mode 100644 examples/cli/src/ExampleResource.php create mode 100644 examples/cli/src/ExampleTool.php create mode 100644 examples/cli/src/Manager/PromptManager.php create mode 100644 examples/cli/src/Manager/ResourceManager.php create mode 100644 examples/cli/src/Manager/ToolManager.php create mode 100644 src/Capability/Prompt/CollectionInterface.php create mode 100644 src/Capability/Prompt/MetadataInterface.php create mode 100644 src/Capability/Prompt/PromptGet.php create mode 100644 src/Capability/Prompt/PromptGetResult.php create mode 100644 src/Capability/Prompt/PromptGetResultMessages.php create mode 100644 src/Capability/Prompt/PromptGetterInterface.php create mode 100644 src/Capability/Resource/CollectionInterface.php create mode 100644 src/Capability/Resource/MetadataInterface.php create mode 100644 src/Capability/Resource/ResourceRead.php create mode 100644 src/Capability/Resource/ResourceReadResult.php create mode 100644 src/Capability/Resource/ResourceReaderInterface.php create mode 100644 src/Capability/Tool/CollectionInterface.php create mode 100644 src/Capability/Tool/ToolCallResult.php create mode 100644 src/Exception/NotFoundExceptionInterface.php create mode 100644 src/Exception/PromptGetException.php create mode 100644 src/Exception/PromptNotFoundException.php create mode 100644 src/Exception/ResourceNotFoundException.php create mode 100644 src/Exception/ResourceReadException.php create mode 100644 src/Server/RequestHandler/PromptGetHandler.php create mode 100644 src/Server/RequestHandler/PromptListHandler.php create mode 100644 src/Server/RequestHandler/ResourceListHandler.php create mode 100644 src/Server/RequestHandler/ResourceReadHandler.php diff --git a/.gitattributes b/.gitattributes index 8d326bc..2cede68 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,5 @@ /.git* export-ignore +/examples export-ignore /tests export-ignore /.php-cs-fixer.dist.php export-ignore /phpstan.dist.neon export-ignore diff --git a/examples/cli/README.md b/examples/cli/README.md new file mode 100644 index 0000000..772e74a --- /dev/null +++ b/examples/cli/README.md @@ -0,0 +1,23 @@ +# 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/php-llm/mcp-sdk/src +ln -s /path/to/your/project/src /path/to/your/project/examples/cli/vendor/php-llm/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`. diff --git a/examples/cli/composer.json b/examples/cli/composer.json new file mode 100644 index 0000000..05a875b --- /dev/null +++ b/examples/cli/composer.json @@ -0,0 +1,24 @@ +{ + "name": "php-llm/mcp-cli-example", + "description": "An example applicationf for CLI", + "license": "MIT", + "type": "project", + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + } + ], + "require": { + "php": ">=8.2", + "php-llm/mcp-sdk": "@dev", + "symfony/console": "^7.2" + }, + "minimum-stability": "stable", + "autoload": { + "psr-4": { + "App\\": "src/" + } + } +} + diff --git a/examples/cli/example-requests.json b/examples/cli/example-requests.json new file mode 100644 index 0000000..b2b72f8 --- /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" }}} +] \ No newline at end of file diff --git a/examples/cli/index.php b/examples/cli/index.php new file mode 100644 index 0000000..13cdfc9 --- /dev/null +++ b/examples/cli/index.php @@ -0,0 +1,29 @@ +connect($transport); diff --git a/examples/cli/src/Builder.php b/examples/cli/src/Builder.php new file mode 100644 index 0000000..20126e8 --- /dev/null +++ b/examples/cli/src/Builder.php @@ -0,0 +1,52 @@ + + */ + public static function buildRequestHandlers(): array + { + $promptManager = new PromptManager(); + $resourceManager = new ResourceManager(); + $toolManager = new ToolManager(); + + 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 0000000..b46de9c --- /dev/null +++ b/examples/cli/src/ExamplePrompt.php @@ -0,0 +1,34 @@ + 'firstName', + '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 0000000..28830c4 --- /dev/null +++ b/examples/cli/src/ExampleResource.php @@ -0,0 +1,33 @@ +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/examples/cli/src/Manager/PromptManager.php b/examples/cli/src/Manager/PromptManager.php new file mode 100644 index 0000000..0c09159 --- /dev/null +++ b/examples/cli/src/Manager/PromptManager.php @@ -0,0 +1,53 @@ +items = [ + new ExamplePrompt(), + ]; + } + + public function getMetadata(): array + { + return $this->items; + } + + public function get(PromptGet $request): PromptGetResult + { + foreach ($this->items as $item) { + if ($request->name === $item->getName()) { + try { + return new PromptGetResult( + $item->getDescription(), + [new PromptGetResultMessages( + 'user', + $item->__invoke(...$request->arguments), + )] + ); + } catch (\Throwable $e) { + throw new PromptGetException($request, $e); + } + } + } + + throw new PromptNotFoundException($request); + } +} diff --git a/examples/cli/src/Manager/ResourceManager.php b/examples/cli/src/Manager/ResourceManager.php new file mode 100644 index 0000000..64aa0e9 --- /dev/null +++ b/examples/cli/src/Manager/ResourceManager.php @@ -0,0 +1,46 @@ +items = [ + new ExampleResource(), + ]; + } + + public function getMetadata(): array + { + return $this->items; + } + + public function read(ResourceRead $request): ResourceReadResult + { + foreach ($this->items as $resource) { + if ($request->uri === $resource->getUri()) { + // In a real implementation, you would read the resource from its URI. + // Here we just return a dummy string for demonstration purposes. + return new ResourceReadResult( + 'Content of '.$resource->getName(), + $resource->getUri(), + ); + } + } + + throw new ResourceNotFoundException($request); + } +} diff --git a/examples/cli/src/Manager/ToolManager.php b/examples/cli/src/Manager/ToolManager.php new file mode 100644 index 0000000..3b2188a --- /dev/null +++ b/examples/cli/src/Manager/ToolManager.php @@ -0,0 +1,48 @@ +items = [ + new ExampleTool(), + ]; + } + + public function getMetadata(): array + { + return $this->items; + } + + public function execute(ToolCall $toolCall): ToolCallResult + { + foreach ($this->items as $tool) { + if ($toolCall->name === $tool->getName()) { + try { + return new ToolCallResult( + $tool->__invoke(...$toolCall->arguments), + ); + } catch (\Throwable $e) { + throw new ToolExecutionException($toolCall, $e); + } + } + } + + throw new ToolNotFoundException($toolCall); + } +} diff --git a/phpstan.dist.neon b/phpstan.dist.neon index d268c2f..5ffb720 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -1,5 +1,8 @@ parameters: level: 6 paths: + - examples/ - src/ - tests/ + excludePaths: + - examples/cli/vendor (?) diff --git a/src/Capability/Prompt/CollectionInterface.php b/src/Capability/Prompt/CollectionInterface.php new file mode 100644 index 0000000..377585c --- /dev/null +++ b/src/Capability/Prompt/CollectionInterface.php @@ -0,0 +1,11 @@ + + */ + public function getArguments(): array; +} diff --git a/src/Capability/Prompt/PromptGet.php b/src/Capability/Prompt/PromptGet.php new file mode 100644 index 0000000..22c64f4 --- /dev/null +++ b/src/Capability/Prompt/PromptGet.php @@ -0,0 +1,18 @@ + $arguments + */ + public function __construct( + public string $id, + public string $name, + public array $arguments = [], + ) { + } +} diff --git a/src/Capability/Prompt/PromptGetResult.php b/src/Capability/Prompt/PromptGetResult.php new file mode 100644 index 0000000..5362680 --- /dev/null +++ b/src/Capability/Prompt/PromptGetResult.php @@ -0,0 +1,17 @@ + $messages + */ + public function __construct( + public string $description, + public array $messages = [], + ) { + } +} diff --git a/src/Capability/Prompt/PromptGetResultMessages.php b/src/Capability/Prompt/PromptGetResultMessages.php new file mode 100644 index 0000000..48f8531 --- /dev/null +++ b/src/Capability/Prompt/PromptGetResultMessages.php @@ -0,0 +1,20 @@ + + * @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 index f89f830..d438dfc 100644 --- a/src/Capability/Tool/ToolCall.php +++ b/src/Capability/Tool/ToolCall.php @@ -4,7 +4,7 @@ namespace PhpLlm\McpSdk\Capability\Tool; -final readonly class ToolCall implements \JsonSerializable +final readonly class ToolCall { /** * @param array $arguments @@ -15,26 +15,4 @@ public function __construct( public array $arguments = [], ) { } - - /** - * @return array{ - * id: string, - * type: 'function', - * function: array{ - * name: string, - * arguments: string - * } - * } - */ - public function jsonSerialize(): array - { - return [ - 'id' => $this->id, - 'type' => 'function', - 'function' => [ - 'name' => $this->name, - 'arguments' => json_encode($this->arguments), - ], - ]; - } } diff --git a/src/Capability/Tool/ToolCallResult.php b/src/Capability/Tool/ToolCallResult.php new file mode 100644 index 0000000..b4b4951 --- /dev/null +++ b/src/Capability/Tool/ToolCallResult.php @@ -0,0 +1,20 @@ +name, $previous->getMessage()), previous: $previous); + } +} diff --git a/src/Exception/PromptNotFoundException.php b/src/Exception/PromptNotFoundException.php new file mode 100644 index 0000000..fb163b3 --- /dev/null +++ b/src/Exception/PromptNotFoundException.php @@ -0,0 +1,16 @@ +name)); + } +} diff --git a/src/Exception/ResourceNotFoundException.php b/src/Exception/ResourceNotFoundException.php new file mode 100644 index 0000000..6cc1cd7 --- /dev/null +++ b/src/Exception/ResourceNotFoundException.php @@ -0,0 +1,16 @@ +uri)); + } +} diff --git a/src/Exception/ResourceReadException.php b/src/Exception/ResourceReadException.php new file mode 100644 index 0000000..669d60a --- /dev/null +++ b/src/Exception/ResourceReadException.php @@ -0,0 +1,17 @@ +uri, $previous?->getMessage() ?? ''), previous: $previous); + } +} diff --git a/src/Exception/ToolExecutionException.php b/src/Exception/ToolExecutionException.php index 5235e87..04c7efa 100644 --- a/src/Exception/ToolExecutionException.php +++ b/src/Exception/ToolExecutionException.php @@ -12,6 +12,6 @@ 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); + 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 index 26c943d..e8118cf 100644 --- a/src/Exception/ToolNotFoundException.php +++ b/src/Exception/ToolNotFoundException.php @@ -6,11 +6,11 @@ use PhpLlm\McpSdk\Capability\Tool\ToolCall; -final class ToolNotFoundException extends \RuntimeException implements ExceptionInterface +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)); + parent::__construct(sprintf('Tool not found for call: "%s"', $toolCall->name)); } } diff --git a/src/Server/RequestHandler/InitializeHandler.php b/src/Server/RequestHandler/InitializeHandler.php index 52da6d2..4740d6d 100644 --- a/src/Server/RequestHandler/InitializeHandler.php +++ b/src/Server/RequestHandler/InitializeHandler.php @@ -18,9 +18,11 @@ public function __construct( public function createResponse(Request $message): Response { return new Response($message->id, [ - 'protocolVersion' => '2024-11-05', + 'protocolVersion' => '2025-03-26', 'capabilities' => [ - 'tools' => ['listChanged' => true], + 'prompts' => ['listChanged' => false], + 'tools' => ['listChanged' => false], + 'resources' => ['listChanged' => false, 'subscribe' => false], ], 'serverInfo' => ['name' => $this->name, 'version' => $this->version], ]); diff --git a/src/Server/RequestHandler/PromptGetHandler.php b/src/Server/RequestHandler/PromptGetHandler.php new file mode 100644 index 0000000..a956e25 --- /dev/null +++ b/src/Server/RequestHandler/PromptGetHandler.php @@ -0,0 +1,72 @@ +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 0000000..0fb09f7 --- /dev/null +++ b/src/Server/RequestHandler/PromptListHandler.php @@ -0,0 +1,59 @@ +id, [ + 'resources' => array_map(function (MetadataInterface $metadata) { + $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; + } + + return $result; + }, $this->collection->getMetadata()), + ]); + } + + 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 0000000..dd8aed0 --- /dev/null +++ b/src/Server/RequestHandler/ResourceListHandler.php @@ -0,0 +1,53 @@ +id, [ + 'resources' => array_map(function (MetadataInterface $metadata) { + $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; + } + + return $result; + }, $this->collection->getMetadata()), + ]); + } + + 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 0000000..b02740d --- /dev/null +++ b/src/Server/RequestHandler/ResourceReadHandler.php @@ -0,0 +1,46 @@ +params['uri']; + + try { + $result = $this->reader->read(new ResourceRead(uniqid('', true), $uri)); + } 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 index 065ca48..35015a9 100644 --- a/src/Server/RequestHandler/ToolCallHandler.php +++ b/src/Server/RequestHandler/ToolCallHandler.php @@ -29,10 +29,31 @@ public function createResponse(Request $message): Response|Error return Error::internalError($message->id, 'Error while executing tool'); } - return new Response($message->id, [ - 'content' => [ - ['type' => 'text', 'text' => $result], + $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, + 'isError' => $result->isError, ]); } diff --git a/src/Server/RequestHandler/ToolListHandler.php b/src/Server/RequestHandler/ToolListHandler.php index d4c4263..fd4f717 100644 --- a/src/Server/RequestHandler/ToolListHandler.php +++ b/src/Server/RequestHandler/ToolListHandler.php @@ -4,15 +4,15 @@ namespace PhpLlm\McpSdk\Server\RequestHandler; +use PhpLlm\McpSdk\Capability\Tool\CollectionInterface; use PhpLlm\McpSdk\Capability\Tool\MetadataInterface; -use PhpLlm\McpSdk\Capability\Tool\ToolCollectionInterface; use PhpLlm\McpSdk\Message\Request; use PhpLlm\McpSdk\Message\Response; final class ToolListHandler extends BaseRequestHandler { public function __construct( - private readonly ToolCollectionInterface $toolCollection, + private readonly CollectionInterface $toolCollection, ) { }