diff --git a/examples/09-standalone-cli/src/ExampleTool.php b/examples/09-standalone-cli/src/ExampleTool.php index 0eb9010..559de51 100644 --- a/examples/09-standalone-cli/src/ExampleTool.php +++ b/examples/09-standalone-cli/src/ExampleTool.php @@ -12,7 +12,7 @@ namespace App; use Mcp\Capability\Tool\MetadataInterface; -use Mcp\Capability\Tool\ToolExecutorInterface; +use Mcp\Capability\Tool\ToolCallerInterface; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\Result\CallToolResult; @@ -20,7 +20,7 @@ /** * @author Tobias Nyholm */ -class ExampleTool implements MetadataInterface, ToolExecutorInterface +class ExampleTool implements MetadataInterface, ToolCallerInterface { public function call(CallToolRequest $request): CallToolResult { diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index c550bc0..1ac806b 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -367,124 +367,46 @@ parameters: path: examples/08-schema-showcase-streamable/server.php - - message: '#^Parameter \#1 \$registry of class Mcp\\Server\\RequestHandler\\CallToolHandler constructor expects Mcp\\Capability\\Registry, Mcp\\Capability\\ToolChain given\.$#' + message: '#^Parameter \#1 \$registry of class Mcp\\Server\\RequestHandler\\ListPromptsHandler constructor expects Mcp\\Capability\\Registry\\ReferenceProviderInterface, Mcp\\Capability\\PromptChain given\.$#' identifier: argument.type count: 1 path: examples/09-standalone-cli/src/Builder.php - - message: '#^Parameter \#1 \$registry of class Mcp\\Server\\RequestHandler\\GetPromptHandler constructor expects Mcp\\Capability\\Registry, Mcp\\Capability\\PromptChain given\.$#' + message: '#^Parameter \#1 \$registry of class Mcp\\Server\\RequestHandler\\ListResourcesHandler constructor expects Mcp\\Capability\\Registry\\ReferenceProviderInterface, Mcp\\Capability\\ResourceChain given\.$#' identifier: argument.type count: 1 path: examples/09-standalone-cli/src/Builder.php - - message: '#^Parameter \#1 \$registry of class Mcp\\Server\\RequestHandler\\ListPromptsHandler constructor expects Mcp\\Capability\\Registry, Mcp\\Capability\\PromptChain given\.$#' + message: '#^Parameter \#1 \$registry of class Mcp\\Server\\RequestHandler\\ListToolsHandler constructor expects Mcp\\Capability\\Registry\\ReferenceProviderInterface, Mcp\\Capability\\ToolChain given\.$#' identifier: argument.type count: 1 path: examples/09-standalone-cli/src/Builder.php - - message: '#^Parameter \#1 \$registry of class Mcp\\Server\\RequestHandler\\ListResourcesHandler constructor expects Mcp\\Capability\\Registry, Mcp\\Capability\\ResourceChain given\.$#' - identifier: argument.type - count: 1 - path: examples/09-standalone-cli/src/Builder.php - - - - message: '#^Parameter \#1 \$registry of class Mcp\\Server\\RequestHandler\\ListToolsHandler constructor expects Mcp\\Capability\\Registry, Mcp\\Capability\\ToolChain given\.$#' - identifier: argument.type - count: 1 - path: examples/09-standalone-cli/src/Builder.php - - - - message: '#^Parameter \#1 \$registry of class Mcp\\Server\\RequestHandler\\ReadResourceHandler constructor expects Mcp\\Capability\\Registry, Mcp\\Capability\\ResourceChain given\.$#' - identifier: argument.type - count: 1 - path: examples/09-standalone-cli/src/Builder.php - - - - message: '#^Call to protected method formatResult\(\) of class Mcp\\Capability\\Registry\\ResourceReference\.$#' - identifier: method.protected - count: 1 - path: src/Capability/Registry.php - - - - message: '#^Cannot import type alias CallableArray\: type alias does not exist in Mcp\\Capability\\Registry\\ElementReference\.$#' - identifier: typeAlias.notFound - count: 1 - path: src/Capability/Registry.php - - - - message: '#^Method Mcp\\Capability\\Registry\:\:handleCallTool\(\) has parameter \$arguments with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Capability/Registry.php - - - - message: '#^Method Mcp\\Capability\\Registry\:\:handleCallTool\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Capability/Registry.php - - - - message: '#^Method Mcp\\Capability\\Registry\:\:handleGetPrompt\(\) has parameter \$arguments with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Capability/Registry.php - - - - message: '#^Method Mcp\\Capability\\Registry\:\:registerPrompt\(\) has parameter \$handler with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Capability/Registry.php - - - - message: '#^Method Mcp\\Capability\\Registry\:\:registerResource\(\) has parameter \$handler with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Capability/Registry.php - - - - message: '#^Method Mcp\\Capability\\Registry\:\:registerResourceTemplate\(\) has parameter \$handler with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Capability/Registry.php - - - - message: '#^Method Mcp\\Capability\\Registry\:\:registerTool\(\) has parameter \$handler with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Capability/Registry.php - - - - message: '#^PHPDoc tag @param for parameter \$handler with type \(callable\)\|Mcp\\Capability\\CallableArray\|string is not subtype of native type array\|\(callable\)\|string\.$#' - identifier: parameter.phpDocType - count: 4 - path: src/Capability/Registry.php - - - - message: '#^Parameter \$handler of method Mcp\\Capability\\Registry\:\:registerPrompt\(\) has invalid type Mcp\\Capability\\CallableArray\.$#' - identifier: class.notFound + message: '#^PHPDoc tag @return with type array is incompatible with native type object\.$#' + identifier: return.phpDocType count: 1 - path: src/Capability/Registry.php + path: src/Schema/Result/EmptyResult.php - - message: '#^Parameter \$handler of method Mcp\\Capability\\Registry\:\:registerResource\(\) has invalid type Mcp\\Capability\\CallableArray\.$#' - identifier: class.notFound + message: '#^Method Mcp\\Schema\\Result\\ReadResourceResult\:\:jsonSerialize\(\) should return array\{contents\: array\\} but returns array\{contents\: array\\}\.$#' + identifier: return.type count: 1 - path: src/Capability/Registry.php + path: src/Schema/Result/ReadResourceResult.php - - message: '#^Parameter \$handler of method Mcp\\Capability\\Registry\:\:registerResourceTemplate\(\) has invalid type Mcp\\Capability\\CallableArray\.$#' - identifier: class.notFound + message: '#^Result of && is always false\.$#' + identifier: booleanAnd.alwaysFalse count: 1 - path: src/Capability/Registry.php + path: src/Server/RequestHandler/ListResourcesHandler.php - - message: '#^Parameter \$handler of method Mcp\\Capability\\Registry\:\:registerTool\(\) has invalid type Mcp\\Capability\\CallableArray\.$#' - identifier: class.notFound + message: '#^Method Mcp\\Capability\\Registry\\ReferenceProviderInterface\:\:getPrompts\(\) invoked with 2 parameters, 0 required\.$#' + identifier: arguments.count count: 1 - path: src/Capability/Registry.php + path: src/Server/RequestHandler/ListPromptsHandler.php - message: '#^Call to an undefined method Mcp\\Capability\\Registry\\ResourceTemplateReference\:\:handle\(\)\.$#' @@ -492,24 +414,6 @@ parameters: count: 1 path: src/Capability/Registry/ResourceTemplateReference.php - - - message: '#^PHPDoc tag @return with type array is incompatible with native type object\.$#' - identifier: return.phpDocType - count: 1 - path: src/Schema/Result/EmptyResult.php - - - - message: '#^Method Mcp\\Schema\\Result\\ReadResourceResult\:\:jsonSerialize\(\) should return array\{contents\: array\\} but returns array\{contents\: array\\}\.$#' - identifier: return.type - count: 1 - path: src/Schema/Result/ReadResourceResult.php - - - - message: '#^Method Mcp\\Capability\\Registry\:\:getPrompts\(\) invoked with 2 parameters, 0 required\.$#' - identifier: arguments.count - count: 1 - path: src/Server/RequestHandler/ListPromptsHandler.php - - message: '#^Result of && is always false\.$#' identifier: booleanAnd.alwaysFalse @@ -523,16 +427,16 @@ parameters: path: src/Server/RequestHandler/ListPromptsHandler.php - - message: '#^Method Mcp\\Capability\\Registry\:\:getResources\(\) invoked with 2 parameters, 0 required\.$#' + message: '#^Method Mcp\\Capability\\Registry\\ReferenceProviderInterface\:\:getResources\(\) invoked with 2 parameters, 0 required\.$#' identifier: arguments.count count: 1 path: src/Server/RequestHandler/ListResourcesHandler.php - - message: '#^Result of && is always false\.$#' - identifier: booleanAnd.alwaysFalse + message: '#^Method Mcp\\Capability\\Registry\\ReferenceProviderInterface\:\:getTools\(\) invoked with 2 parameters, 0 required\.$#' + identifier: arguments.count count: 1 - path: src/Server/RequestHandler/ListResourcesHandler.php + path: src/Server/RequestHandler/ListToolsHandler.php - message: '#^Strict comparison using \!\=\= between null and null will always evaluate to false\.$#' @@ -540,12 +444,6 @@ parameters: count: 1 path: src/Server/RequestHandler/ListResourcesHandler.php - - - message: '#^Method Mcp\\Capability\\Registry\:\:getTools\(\) invoked with 2 parameters, 0 required\.$#' - identifier: arguments.count - count: 1 - path: src/Server/RequestHandler/ListToolsHandler.php - - message: '#^Result of && is always false\.$#' identifier: booleanAnd.alwaysFalse diff --git a/src/Capability/Discovery/Discoverer.php b/src/Capability/Discovery/Discoverer.php index 4d9651c..a2bdd3c 100644 --- a/src/Capability/Discovery/Discoverer.php +++ b/src/Capability/Discovery/Discoverer.php @@ -19,7 +19,7 @@ use Mcp\Capability\Prompt\Completion\EnumCompletionProvider; use Mcp\Capability\Prompt\Completion\ListCompletionProvider; use Mcp\Capability\Prompt\Completion\ProviderInterface; -use Mcp\Capability\Registry; +use Mcp\Capability\Registry\ReferenceRegistryInterface; use Mcp\Exception\ExceptionInterface; use Mcp\Schema\Prompt; use Mcp\Schema\PromptArgument; @@ -44,7 +44,7 @@ class Discoverer { public function __construct( - private readonly Registry $registry, + private readonly ReferenceRegistryInterface $registry, private readonly LoggerInterface $logger = new NullLogger(), private ?DocBlockParser $docBlockParser = null, private ?SchemaGenerator $schemaGenerator = null, diff --git a/src/Capability/Prompt/PromptGetter.php b/src/Capability/Prompt/PromptGetter.php new file mode 100644 index 0000000..ec5ac31 --- /dev/null +++ b/src/Capability/Prompt/PromptGetter.php @@ -0,0 +1,69 @@ + + */ +final class PromptGetter implements PromptGetterInterface +{ + public function __construct( + private readonly ReferenceProviderInterface $referenceProvider, + private readonly ReferenceHandlerInterface $referenceHandler, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + public function get(GetPromptRequest $request): GetPromptResult + { + $promptName = $request->name; + $arguments = $request->arguments ?? []; + + $this->logger->debug('Getting prompt', ['name' => $promptName, 'arguments' => $arguments]); + + $reference = $this->referenceProvider->getPrompt($promptName); + + if (null === $reference) { + $this->logger->warning('Prompt not found', ['name' => $promptName]); + throw new PromptNotFoundException($request); + } + + try { + $result = $this->referenceHandler->handle($reference, $arguments); + $formattedResult = $reference->formatResult($result); + + $this->logger->debug('Prompt retrieved successfully', [ + 'name' => $promptName, + 'result_type' => \gettype($result), + ]); + + return new GetPromptResult($formattedResult); + } catch (\Throwable $e) { + $this->logger->error('Prompt retrieval failed', [ + 'name' => $promptName, + 'exception' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + throw new PromptGetException($request, $e); + } + } +} diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index f0db654..f9e6582 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -11,9 +11,9 @@ namespace Mcp\Capability; -use Mcp\Capability\Registry\ElementReference; use Mcp\Capability\Registry\PromptReference; -use Mcp\Capability\Registry\ReferenceHandler; +use Mcp\Capability\Registry\ReferenceProviderInterface; +use Mcp\Capability\Registry\ReferenceRegistryInterface; use Mcp\Capability\Registry\ResourceReference; use Mcp\Capability\Registry\ResourceTemplateReference; use Mcp\Capability\Registry\ToolReference; @@ -21,9 +21,6 @@ use Mcp\Event\ResourceListChangedEvent; use Mcp\Event\ResourceTemplateListChangedEvent; use Mcp\Event\ToolListChangedEvent; -use Mcp\Exception\InvalidArgumentException; -use Mcp\Schema\Content\PromptMessage; -use Mcp\Schema\Content\ResourceContents; use Mcp\Schema\Prompt; use Mcp\Schema\Resource; use Mcp\Schema\ResourceTemplate; @@ -34,11 +31,14 @@ use Psr\Log\NullLogger; /** - * @phpstan-import-type CallableArray from ElementReference + * Registry implementation that manages MCP element registration and access. + * Implements both ReferenceProvider (for access) and ReferenceRegistry (for registration) + * following the Interface Segregation Principle. * * @author Kyrian Obikwelu + * @author Pavel Buchnev */ -class Registry +final class Registry implements ReferenceProviderInterface, ReferenceRegistryInterface { /** * @var array @@ -61,7 +61,6 @@ class Registry private array $resourceTemplates = []; public function __construct( - private readonly ReferenceHandler $referenceHandler = new ReferenceHandler(), private readonly ?EventDispatcherInterface $eventDispatcher = null, private readonly LoggerInterface $logger = new NullLogger(), ) { @@ -74,28 +73,27 @@ public function getCapabilities(): ServerCapabilities } return new ServerCapabilities( - tools: true, // [] !== $this->tools, + tools: [] !== $this->tools, toolsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, resources: [] !== $this->resources || [] !== $this->resourceTemplates, resourcesSubscribe: false, resourcesListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, prompts: [] !== $this->prompts, promptsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, - logging: false, // true, + logging: false, completions: true, ); } - /** - * @param callable|CallableArray|string $handler - */ public function registerTool(Tool $tool, callable|array|string $handler, bool $isManual = false): void { $toolName = $tool->name; $existing = $this->tools[$toolName] ?? null; if ($existing && !$isManual && $existing->isManual) { - $this->logger->debug("Ignoring discovered tool '{$toolName}' as it conflicts with a manually registered one."); + $this->logger->debug( + "Ignoring discovered tool '{$toolName}' as it conflicts with a manually registered one.", + ); return; } @@ -105,16 +103,15 @@ public function registerTool(Tool $tool, callable|array|string $handler, bool $i $this->eventDispatcher?->dispatch(new ToolListChangedEvent()); } - /** - * @param callable|CallableArray|string $handler - */ public function registerResource(Resource $resource, callable|array|string $handler, bool $isManual = false): void { $uri = $resource->uri; $existing = $this->resources[$uri] ?? null; if ($existing && !$isManual && $existing->isManual) { - $this->logger->debug("Ignoring discovered resource '{$uri}' as it conflicts with a manually registered one."); + $this->logger->debug( + "Ignoring discovered resource '{$uri}' as it conflicts with a manually registered one.", + ); return; } @@ -124,10 +121,6 @@ public function registerResource(Resource $resource, callable|array|string $hand $this->eventDispatcher?->dispatch(new ResourceListChangedEvent()); } - /** - * @param callable|CallableArray|string $handler - * @param array $completionProviders - */ public function registerResourceTemplate( ResourceTemplate $template, callable|array|string $handler, @@ -138,20 +131,23 @@ public function registerResourceTemplate( $existing = $this->resourceTemplates[$uriTemplate] ?? null; if ($existing && !$isManual && $existing->isManual) { - $this->logger->debug("Ignoring discovered template '{$uriTemplate}' as it conflicts with a manually registered one."); + $this->logger->debug( + "Ignoring discovered template '{$uriTemplate}' as it conflicts with a manually registered one.", + ); return; } - $this->resourceTemplates[$uriTemplate] = new ResourceTemplateReference($template, $handler, $isManual, $completionProviders); + $this->resourceTemplates[$uriTemplate] = new ResourceTemplateReference( + $template, + $handler, + $isManual, + $completionProviders, + ); $this->eventDispatcher?->dispatch(new ResourceTemplateListChangedEvent()); } - /** - * @param callable|CallableArray|string $handler - * @param array $completionProviders - */ public function registerPrompt( Prompt $prompt, callable|array|string $handler, @@ -162,7 +158,9 @@ public function registerPrompt( $existing = $this->prompts[$promptName] ?? null; if ($existing && !$isManual && $existing->isManual) { - $this->logger->debug("Ignoring discovered prompt '{$promptName}' as it conflicts with a manually registered one."); + $this->logger->debug( + "Ignoring discovered prompt '{$promptName}' as it conflicts with a manually registered one.", + ); return; } @@ -172,20 +170,6 @@ public function registerPrompt( $this->eventDispatcher?->dispatch(new PromptListChangedEvent()); } - /** - * Checks if any elements (manual or discovered) are currently registered. - */ - public function hasElements(): bool - { - return !empty($this->tools) - || !empty($this->resources) - || !empty($this->prompts) - || !empty($this->resourceTemplates); - } - - /** - * Clear discovered elements from registry. - */ public function clear(): void { $clearCount = 0; @@ -220,43 +204,15 @@ public function clear(): void } } - public function handleCallTool(string $name, array $arguments): array - { - $reference = $this->getTool($name); - - if (null === $reference) { - throw new InvalidArgumentException(\sprintf('Tool "%s" is not registered.', $name)); - } - - return $reference->formatResult( - $this->referenceHandler->handle($reference, $arguments) - ); - } - public function getTool(string $name): ?ToolReference { return $this->tools[$name] ?? null; } - /** - * @return ResourceContents[] - */ - public function handleReadResource(string $uri): array - { - $reference = $this->getResource($uri); - - if (null === $reference) { - throw new InvalidArgumentException(\sprintf('Resource "%s" is not registered.', $uri)); - } - - return $reference->formatResult( - $this->referenceHandler->handle($reference, ['uri' => $uri]), - $uri, - ); - } - - public function getResource(string $uri, bool $includeTemplates = true): ResourceReference|ResourceTemplateReference|null - { + public function getResource( + string $uri, + bool $includeTemplates = true, + ): ResourceReference|ResourceTemplateReference|null { $registration = $this->resources[$uri] ?? null; if ($registration) { return $registration; @@ -282,54 +238,37 @@ public function getResourceTemplate(string $uriTemplate): ?ResourceTemplateRefer return $this->resourceTemplates[$uriTemplate] ?? null; } - /** - * @return PromptMessage[] - */ - public function handleGetPrompt(string $name, ?array $arguments): array - { - $reference = $this->getPrompt($name); - - if (null === $reference) { - throw new InvalidArgumentException(\sprintf('Prompt "%s" is not registered.', $name)); - } - - return $reference->formatResult( - $this->referenceHandler->handle($reference, $arguments) - ); - } - public function getPrompt(string $name): ?PromptReference { return $this->prompts[$name] ?? null; } - /** - * @return array - */ public function getTools(): array { return array_map(fn (ToolReference $tool) => $tool->tool, $this->tools); } - /** - * @return array - */ public function getResources(): array { return array_map(fn (ResourceReference $resource) => $resource->schema, $this->resources); } - /** - * @return array - */ public function getPrompts(): array { return array_map(fn (PromptReference $prompt) => $prompt->prompt, $this->prompts); } - /** @return array */ public function getResourceTemplates(): array { - return array_map(fn ($template) => $template->resourceTemplate, $this->resourceTemplates); + return array_map(fn (ResourceTemplateReference $template) => $template->resourceTemplate, + $this->resourceTemplates); + } + + public function hasElements(): bool + { + return !empty($this->tools) + || !empty($this->resources) + || !empty($this->prompts) + || !empty($this->resourceTemplates); } } diff --git a/src/Capability/Registry/ReferenceHandler.php b/src/Capability/Registry/ReferenceHandler.php index d4af316..b033378 100644 --- a/src/Capability/Registry/ReferenceHandler.php +++ b/src/Capability/Registry/ReferenceHandler.php @@ -18,7 +18,7 @@ /** * @author Kyrian Obikwelu */ -class ReferenceHandler +final class ReferenceHandler implements ReferenceHandlerInterface { public function __construct( private readonly ?ContainerInterface $container = null, diff --git a/src/Capability/Registry/ReferenceHandlerInterface.php b/src/Capability/Registry/ReferenceHandlerInterface.php new file mode 100644 index 0000000..b8f565a --- /dev/null +++ b/src/Capability/Registry/ReferenceHandlerInterface.php @@ -0,0 +1,34 @@ + + */ +interface ReferenceHandlerInterface +{ + /** + * Handles execution of an MCP element reference. + * + * @param ElementReference $reference the element reference to execute + * @param array $arguments arguments to pass to the handler + * + * @return mixed the result of the element execution + * + * @throws \Mcp\Exception\InvalidArgumentException if the handler is invalid + * @throws \Mcp\Exception\RegistryException if execution fails + */ + public function handle(ElementReference $reference, array $arguments): mixed; +} diff --git a/src/Capability/Registry/ReferenceProviderInterface.php b/src/Capability/Registry/ReferenceProviderInterface.php new file mode 100644 index 0000000..6b26400 --- /dev/null +++ b/src/Capability/Registry/ReferenceProviderInterface.php @@ -0,0 +1,78 @@ + + */ +interface ReferenceProviderInterface +{ + /** + * Gets a tool reference by name. + */ + public function getTool(string $name): ?ToolReference; + + /** + * Gets a resource reference by URI (includes template matching if enabled). + */ + public function getResource(string $uri, bool $includeTemplates = true): ResourceReference|ResourceTemplateReference|null; + + /** + * Gets a resource template reference by URI template. + */ + public function getResourceTemplate(string $uriTemplate): ?ResourceTemplateReference; + + /** + * Gets a prompt reference by name. + */ + public function getPrompt(string $name): ?PromptReference; + + /** + * Gets all registered tools. + * + * @return array + */ + public function getTools(): array; + + /** + * Gets all registered resources. + * + * @return array + */ + public function getResources(): array; + + /** + * Gets all registered prompts. + * + * @return array + */ + public function getPrompts(): array; + + /** + * Gets all registered resource templates. + * + * @return array + */ + public function getResourceTemplates(): array; + + /** + * Checks if any elements (manual or discovered) are currently registered. + */ + public function hasElements(): bool; +} diff --git a/src/Capability/Registry/ReferenceRegistryInterface.php b/src/Capability/Registry/ReferenceRegistryInterface.php new file mode 100644 index 0000000..2d90de7 --- /dev/null +++ b/src/Capability/Registry/ReferenceRegistryInterface.php @@ -0,0 +1,79 @@ + + */ +interface ReferenceRegistryInterface +{ + /** + * Gets server capabilities based on registered elements. + */ + public function getCapabilities(): ServerCapabilities; + + /** + * Registers a tool with its handler. + * + * @param Handler $handler + */ + public function registerTool(Tool $tool, callable|array|string $handler, bool $isManual = false): void; + + /** + * Registers a resource with its handler. + * + * @param Handler $handler + */ + public function registerResource(Resource $resource, callable|array|string $handler, bool $isManual = false): void; + + /** + * Registers a resource template with its handler and completion providers. + * + * @param Handler $handler + * @param array $completionProviders + */ + public function registerResourceTemplate( + ResourceTemplate $template, + callable|array|string $handler, + array $completionProviders = [], + bool $isManual = false, + ): void; + + /** + * Registers a prompt with its handler and completion providers. + * + * @param Handler $handler + * @param array $completionProviders + */ + public function registerPrompt( + Prompt $prompt, + callable|array|string $handler, + array $completionProviders = [], + bool $isManual = false, + ): void; + + /** + * Clear discovered elements from registry. + */ + public function clear(): void; +} diff --git a/src/Capability/Registry/ResourceTemplateReference.php b/src/Capability/Registry/ResourceTemplateReference.php index 3ef1d6c..322e6a5 100644 --- a/src/Capability/Registry/ResourceTemplateReference.php +++ b/src/Capability/Registry/ResourceTemplateReference.php @@ -55,7 +55,8 @@ public function __construct( } /** - * Gets the resource template. + * @deprecated + * Gets the resource template * * @return array array of ResourceContents objects */ @@ -162,7 +163,7 @@ private function compileTemplate(): void * - array: Converted to JSON if MIME type is application/json or contains 'json' * For other MIME types, will try to convert to JSON with a warning */ - protected function formatResult(mixed $readResult, string $uri, ?string $mimeType = null): array + public function formatResult(mixed $readResult, string $uri, ?string $mimeType = null): array { if ($readResult instanceof ResourceContents) { return [$readResult]; diff --git a/src/Capability/Resource/ResourceReadResult.php b/src/Capability/Resource/ResourceReadResult.php deleted file mode 100644 index b352cf4..0000000 --- a/src/Capability/Resource/ResourceReadResult.php +++ /dev/null @@ -1,30 +0,0 @@ - - */ -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/ResourceReader.php b/src/Capability/Resource/ResourceReader.php new file mode 100644 index 0000000..2496cfa --- /dev/null +++ b/src/Capability/Resource/ResourceReader.php @@ -0,0 +1,68 @@ + + */ +final class ResourceReader implements ResourceReaderInterface +{ + public function __construct( + private readonly ReferenceProviderInterface $referenceProvider, + private readonly ReferenceHandlerInterface $referenceHandler, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + public function read(ReadResourceRequest $request): ReadResourceResult + { + $uri = $request->uri; + + $this->logger->debug('Reading resource', ['uri' => $uri]); + + $reference = $this->referenceProvider->getResource($uri); + + if (null === $reference) { + $this->logger->warning('Resource not found', ['uri' => $uri]); + throw new ResourceNotFoundException($request); + } + + try { + $result = $this->referenceHandler->handle($reference, ['uri' => $uri]); + $formattedResult = $reference->formatResult($result, $uri); + + $this->logger->debug('Resource read successfully', [ + 'uri' => $uri, + 'result_type' => \gettype($result), + ]); + + return new ReadResourceResult($formattedResult); + } catch (\Throwable $e) { + $this->logger->error('Resource read failed', [ + 'uri' => $uri, + 'exception' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + throw new ResourceReadException($request, $e); + } + } +} diff --git a/src/Capability/Tool/ToolCaller.php b/src/Capability/Tool/ToolCaller.php new file mode 100644 index 0000000..24bc399 --- /dev/null +++ b/src/Capability/Tool/ToolCaller.php @@ -0,0 +1,81 @@ + + */ +final class ToolCaller implements ToolCallerInterface +{ + public function __construct( + private readonly ReferenceProviderInterface $referenceProvider, + private readonly ReferenceHandlerInterface $referenceHandler, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + /** + * @throws ToolCallException if the tool execution fails + * @throws ToolNotFoundException if the tool is not found + */ + public function call(CallToolRequest $request): CallToolResult + { + $toolName = $request->name; + $arguments = $request->arguments ?? []; + + $this->logger->debug('Executing tool', ['name' => $toolName, 'arguments' => $arguments]); + + $toolReference = $this->referenceProvider->getTool($toolName); + + if (null === $toolReference) { + $this->logger->warning('Tool not found', ['name' => $toolName]); + throw new ToolNotFoundException($request); + } + + try { + $result = $this->referenceHandler->handle($toolReference, $arguments); + /** @var TextContent[]|ImageContent[]|EmbeddedResource[]|AudioContent[] $formattedResult */ + $formattedResult = $toolReference->formatResult($result); + + $this->logger->debug('Tool executed successfully', [ + 'name' => $toolName, + 'result_type' => \gettype($result), + ]); + + return new CallToolResult($formattedResult); + } catch (\Throwable $e) { + $this->logger->error('Tool execution failed', [ + 'name' => $toolName, + 'exception' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + throw new ToolCallException($request, $e); + } + } +} diff --git a/src/Capability/Tool/ToolExecutorInterface.php b/src/Capability/Tool/ToolCallerInterface.php similarity index 73% rename from src/Capability/Tool/ToolExecutorInterface.php rename to src/Capability/Tool/ToolCallerInterface.php index c72b134..1ef7ffe 100644 --- a/src/Capability/Tool/ToolExecutorInterface.php +++ b/src/Capability/Tool/ToolCallerInterface.php @@ -11,7 +11,7 @@ namespace Mcp\Capability\Tool; -use Mcp\Exception\ToolExecutionException; +use Mcp\Exception\ToolCallException; use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\Result\CallToolResult; @@ -19,11 +19,11 @@ /** * @author Tobias Nyholm */ -interface ToolExecutorInterface +interface ToolCallerInterface { /** - * @throws ToolExecutionException if the tool execution fails - * @throws ToolNotFoundException if the tool is not found + * @throws ToolCallException if the tool execution fails + * @throws ToolNotFoundException if the tool is not found */ public function call(CallToolRequest $request): CallToolResult; } diff --git a/src/Capability/ToolChain.php b/src/Capability/ToolChain.php index 7baeee6..e500ff0 100644 --- a/src/Capability/ToolChain.php +++ b/src/Capability/ToolChain.php @@ -14,9 +14,9 @@ use Mcp\Capability\Tool\CollectionInterface; use Mcp\Capability\Tool\IdentifierInterface; use Mcp\Capability\Tool\MetadataInterface; -use Mcp\Capability\Tool\ToolExecutorInterface; +use Mcp\Capability\Tool\ToolCallerInterface; use Mcp\Exception\InvalidCursorException; -use Mcp\Exception\ToolExecutionException; +use Mcp\Exception\ToolCallException; use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\Result\CallToolResult; @@ -26,7 +26,7 @@ * * @author Tobias Nyholm */ -class ToolChain implements ToolExecutorInterface, CollectionInterface +class ToolChain implements ToolCallerInterface, CollectionInterface { public function __construct( /** @@ -63,11 +63,11 @@ public function getMetadata(int $count, ?string $lastIdentifier = null): iterabl public function call(CallToolRequest $request): CallToolResult { foreach ($this->items as $item) { - if ($item instanceof ToolExecutorInterface && $request->name === $item->getName()) { + if ($item instanceof ToolCallerInterface && $request->name === $item->getName()) { try { return $item->call($request); } catch (\Throwable $e) { - throw new ToolExecutionException($request, $e); + throw new ToolCallException($request, $e); } } } diff --git a/src/Exception/ToolExecutionException.php b/src/Exception/ToolCallException.php similarity index 67% rename from src/Exception/ToolExecutionException.php rename to src/Exception/ToolCallException.php index f2df936..71978d9 100644 --- a/src/Exception/ToolExecutionException.php +++ b/src/Exception/ToolCallException.php @@ -16,12 +16,12 @@ /** * @author Tobias Nyholm */ -final class ToolExecutionException extends \RuntimeException implements ExceptionInterface +final class ToolCallException extends \RuntimeException implements ExceptionInterface { public function __construct( public readonly CallToolRequest $request, ?\Throwable $previous = null, ) { - parent::__construct(\sprintf('Execution of tool "%s" failed with error: "%s".', $request->name, $previous?->getMessage() ?? ''), previous: $previous); + parent::__construct(\sprintf('Tool call "%s" failed with error: "%s".', $request->name, $previous?->getMessage() ?? ''), previous: $previous); } } diff --git a/src/JsonRpc/Handler.php b/src/JsonRpc/Handler.php index 3cff864..8699eab 100644 --- a/src/JsonRpc/Handler.php +++ b/src/JsonRpc/Handler.php @@ -11,7 +11,11 @@ namespace Mcp\JsonRpc; -use Mcp\Capability\Registry; +use Mcp\Capability\Prompt\PromptGetterInterface; +use Mcp\Capability\Registry\ReferenceProviderInterface; +use Mcp\Capability\Registry\ReferenceRegistryInterface; +use Mcp\Capability\Resource\ResourceReaderInterface; +use Mcp\Capability\Tool\ToolCallerInterface; use Mcp\Exception\ExceptionInterface; use Mcp\Exception\HandlerNotFoundException; use Mcp\Exception\InvalidInputMessageException; @@ -46,28 +50,34 @@ public function __construct( iterable $methodHandlers, private readonly LoggerInterface $logger = new NullLogger(), ) { - $this->methodHandlers = $methodHandlers instanceof \Traversable ? iterator_to_array($methodHandlers) : $methodHandlers; + $this->methodHandlers = $methodHandlers instanceof \Traversable ? iterator_to_array( + $methodHandlers, + ) : $methodHandlers; } public static function make( - Registry $registry, + ReferenceRegistryInterface $registry, + ReferenceProviderInterface $referenceProvider, Implementation $implementation, + ToolCallerInterface $toolCaller, + ResourceReaderInterface $resourceReader, + PromptGetterInterface $promptGetter, LoggerInterface $logger = new NullLogger(), ): self { return new self( - MessageFactory::make(), - [ + messageFactory: MessageFactory::make(), + methodHandlers: [ new NotificationHandler\InitializedHandler(), new RequestHandler\InitializeHandler($registry->getCapabilities(), $implementation), new RequestHandler\PingHandler(), - new RequestHandler\ListPromptsHandler($registry), - new RequestHandler\GetPromptHandler($registry), - new RequestHandler\ListResourcesHandler($registry), - new RequestHandler\ReadResourceHandler($registry), - new RequestHandler\CallToolHandler($registry, $logger), - new RequestHandler\ListToolsHandler($registry), + new RequestHandler\ListPromptsHandler($referenceProvider), + new RequestHandler\GetPromptHandler($promptGetter), + new RequestHandler\ListResourcesHandler($referenceProvider), + new RequestHandler\ReadResourceHandler($resourceReader), + new RequestHandler\CallToolHandler($toolCaller, $logger), + new RequestHandler\ListToolsHandler($referenceProvider), ], - $logger, + logger: $logger, ); } @@ -107,7 +117,8 @@ public function process(string $input): iterable } catch (\DomainException) { yield null; } catch (NotFoundExceptionInterface $e) { - $this->logger->warning(\sprintf('Failed to create response: %s', $e->getMessage()), ['exception' => $e]); + $this->logger->warning(\sprintf('Failed to create response: %s', $e->getMessage()), ['exception' => $e], + ); yield $this->encodeResponse(Error::forMethodNotFound($e->getMessage())); } catch (\InvalidArgumentException $e) { diff --git a/src/Schema/ServerCapabilities.php b/src/Schema/ServerCapabilities.php index fffc265..89eec18 100644 --- a/src/Schema/ServerCapabilities.php +++ b/src/Schema/ServerCapabilities.php @@ -51,7 +51,7 @@ public function __construct( * completions?: mixed, * prompts?: array{listChanged?: bool}|object, * resources?: array{listChanged?: bool, subscribe?: bool}|object, - * tools?: object, + * tools?: object|array{listChanged?: bool}, * experimental?: array, * } $data */ @@ -106,7 +106,7 @@ public static function fromArray(array $data): self promptsListChanged: $promptsListChanged, logging: $loggingEnabled, completions: $completionsEnabled, - experimental: $data['experimental'] ?? null + experimental: $data['experimental'] ?? null, ); } diff --git a/src/Server/RequestHandler/CallToolHandler.php b/src/Server/RequestHandler/CallToolHandler.php index a4dfebd..28aab38 100644 --- a/src/Server/RequestHandler/CallToolHandler.php +++ b/src/Server/RequestHandler/CallToolHandler.php @@ -11,13 +11,12 @@ namespace Mcp\Server\RequestHandler; -use Mcp\Capability\Registry; +use Mcp\Capability\Tool\ToolCallerInterface; use Mcp\Exception\ExceptionInterface; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\CallToolRequest; -use Mcp\Schema\Result\CallToolResult; use Mcp\Server\MethodHandlerInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -29,7 +28,7 @@ final class CallToolHandler implements MethodHandlerInterface { public function __construct( - private readonly Registry $registry, + private readonly ToolCallerInterface $toolCaller, private readonly LoggerInterface $logger = new NullLogger(), ) { } @@ -44,16 +43,19 @@ public function handle(CallToolRequest|HasMethodInterface $message): Response|Er \assert($message instanceof CallToolRequest); try { - $content = $this->registry->handleCallTool($message->name, $message->arguments); + $content = $this->toolCaller->call($message); } catch (ExceptionInterface $exception) { - $this->logger->error(\sprintf('Error while executing tool "%s": "%s".', $message->name, $exception->getMessage()), [ - 'tool' => $message->name, - 'arguments' => $message->arguments, - ]); + $this->logger->error( + \sprintf('Error while executing tool "%s": "%s".', $message->name, $exception->getMessage()), + [ + 'tool' => $message->name, + 'arguments' => $message->arguments, + ], + ); return Error::forInternalError('Error while executing tool', $message->getId()); } - return new Response($message->getId(), new CallToolResult($content)); + return new Response($message->getId(), $content); } } diff --git a/src/Server/RequestHandler/GetPromptHandler.php b/src/Server/RequestHandler/GetPromptHandler.php index c74044d..1ac0a3f 100644 --- a/src/Server/RequestHandler/GetPromptHandler.php +++ b/src/Server/RequestHandler/GetPromptHandler.php @@ -11,13 +11,12 @@ namespace Mcp\Server\RequestHandler; -use Mcp\Capability\Registry; +use Mcp\Capability\Prompt\PromptGetterInterface; use Mcp\Exception\ExceptionInterface; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\GetPromptRequest; -use Mcp\Schema\Result\GetPromptResult; use Mcp\Server\MethodHandlerInterface; /** @@ -26,7 +25,7 @@ final class GetPromptHandler implements MethodHandlerInterface { public function __construct( - private readonly Registry $registry, + private readonly PromptGetterInterface $promptGetter, ) { } @@ -40,11 +39,11 @@ public function handle(GetPromptRequest|HasMethodInterface $message): Response|E \assert($message instanceof GetPromptRequest); try { - $messages = $this->registry->handleGetPrompt($message->name, $message->arguments); + $messages = $this->promptGetter->get($message); } catch (ExceptionInterface) { return Error::forInternalError('Error while handling prompt', $message->getId()); } - return new Response($message->getId(), new GetPromptResult($messages)); + return new Response($message->getId(), $messages); } } diff --git a/src/Server/RequestHandler/ListPromptsHandler.php b/src/Server/RequestHandler/ListPromptsHandler.php index 942550a..2bf479c 100644 --- a/src/Server/RequestHandler/ListPromptsHandler.php +++ b/src/Server/RequestHandler/ListPromptsHandler.php @@ -11,7 +11,7 @@ namespace Mcp\Server\RequestHandler; -use Mcp\Capability\Registry; +use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ListPromptsRequest; @@ -24,7 +24,7 @@ final class ListPromptsHandler implements MethodHandlerInterface { public function __construct( - private readonly Registry $registry, + private readonly ReferenceProviderInterface $registry, private readonly int $pageSize = 20, ) { } diff --git a/src/Server/RequestHandler/ListResourcesHandler.php b/src/Server/RequestHandler/ListResourcesHandler.php index 75804d8..212f4f0 100644 --- a/src/Server/RequestHandler/ListResourcesHandler.php +++ b/src/Server/RequestHandler/ListResourcesHandler.php @@ -11,7 +11,7 @@ namespace Mcp\Server\RequestHandler; -use Mcp\Capability\Registry; +use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ListResourcesRequest; @@ -24,7 +24,7 @@ final class ListResourcesHandler implements MethodHandlerInterface { public function __construct( - private readonly Registry $registry, + private readonly ReferenceProviderInterface $registry, private readonly int $pageSize = 20, ) { } diff --git a/src/Server/RequestHandler/ListToolsHandler.php b/src/Server/RequestHandler/ListToolsHandler.php index ef35fa8..eb49e0d 100644 --- a/src/Server/RequestHandler/ListToolsHandler.php +++ b/src/Server/RequestHandler/ListToolsHandler.php @@ -11,7 +11,7 @@ namespace Mcp\Server\RequestHandler; -use Mcp\Capability\Registry; +use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ListToolsRequest; @@ -25,7 +25,7 @@ final class ListToolsHandler implements MethodHandlerInterface { public function __construct( - private readonly Registry $registry, + private readonly ReferenceProviderInterface $registry, private readonly int $pageSize = 20, ) { } diff --git a/src/Server/RequestHandler/ReadResourceHandler.php b/src/Server/RequestHandler/ReadResourceHandler.php index 40746a6..9c80d2b 100644 --- a/src/Server/RequestHandler/ReadResourceHandler.php +++ b/src/Server/RequestHandler/ReadResourceHandler.php @@ -11,14 +11,13 @@ namespace Mcp\Server\RequestHandler; -use Mcp\Capability\Registry; +use Mcp\Capability\Resource\ResourceReaderInterface; use Mcp\Exception\ExceptionInterface; use Mcp\Exception\ResourceNotFoundException; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ReadResourceRequest; -use Mcp\Schema\Result\ReadResourceResult; use Mcp\Server\MethodHandlerInterface; /** @@ -27,7 +26,7 @@ final class ReadResourceHandler implements MethodHandlerInterface { public function __construct( - private readonly Registry $registry, + private readonly ResourceReaderInterface $resourceReader, ) { } @@ -41,13 +40,13 @@ public function handle(ReadResourceRequest|HasMethodInterface $message): Respons \assert($message instanceof ReadResourceRequest); try { - $contents = $this->registry->handleReadResource($message->uri); + $contents = $this->resourceReader->read($message); } catch (ResourceNotFoundException $e) { return new Error($message->getId(), Error::RESOURCE_NOT_FOUND, $e->getMessage()); } catch (ExceptionInterface) { return Error::forInternalError('Error while reading resource', $message->getId()); } - return new Response($message->getId(), new ReadResourceResult($contents)); + return new Response($message->getId(), $contents); } } diff --git a/src/Server/ServerBuilder.php b/src/Server/ServerBuilder.php index f886756..ede6c77 100644 --- a/src/Server/ServerBuilder.php +++ b/src/Server/ServerBuilder.php @@ -18,9 +18,15 @@ use Mcp\Capability\Discovery\SchemaGenerator; use Mcp\Capability\Prompt\Completion\EnumCompletionProvider; use Mcp\Capability\Prompt\Completion\ListCompletionProvider; +use Mcp\Capability\Prompt\PromptGetter; +use Mcp\Capability\Prompt\PromptGetterInterface; use Mcp\Capability\Registry; use Mcp\Capability\Registry\Container; use Mcp\Capability\Registry\ReferenceHandler; +use Mcp\Capability\Resource\ResourceReader; +use Mcp\Capability\Resource\ResourceReaderInterface; +use Mcp\Capability\Tool\ToolCaller; +use Mcp\Capability\Tool\ToolCallerInterface; use Mcp\Exception\ConfigurationException; use Mcp\JsonRpc\Handler; use Mcp\Schema\Annotations; @@ -49,6 +55,12 @@ final class ServerBuilder private ?CacheInterface $cache = null; + private ?ToolCallerInterface $toolCaller = null; + + private ?ResourceReaderInterface $resourceReader = null; + + private ?PromptGetterInterface $promptGetter = null; + private ?EventDispatcherInterface $eventDispatcher = null; private ?ContainerInterface $container = null; @@ -149,6 +161,27 @@ public function withEventDispatcher(EventDispatcherInterface $eventDispatcher): return $this; } + public function withToolCaller(ToolCallerInterface $toolCaller): self + { + $this->toolCaller = $toolCaller; + + return $this; + } + + public function withResourceReader(ResourceReaderInterface $resourceReader): self + { + $this->resourceReader = $resourceReader; + + return $this; + } + + public function withPromptGetter(PromptGetterInterface $promptGetter): self + { + $this->promptGetter = $promptGetter; + + return $this; + } + /** * Provides a PSR-11 DI container, primarily for resolving user-defined handler classes. * Defaults to a basic internal container. @@ -175,8 +208,13 @@ public function withDiscovery( /** * Manually registers a tool handler. */ - public function withTool(callable|array|string $handler, ?string $name = null, ?string $description = null, ?ToolAnnotations $annotations = null, ?array $inputSchema = null): self - { + public function withTool( + callable|array|string $handler, + ?string $name = null, + ?string $description = null, + ?ToolAnnotations $annotations = null, + ?array $inputSchema = null, + ): self { $this->manualTools[] = compact('handler', 'name', 'description', 'annotations', 'inputSchema'); return $this; @@ -185,8 +223,15 @@ public function withTool(callable|array|string $handler, ?string $name = null, ? /** * Manually registers a resource handler. */ - public function withResource(callable|array|string $handler, string $uri, ?string $name = null, ?string $description = null, ?string $mimeType = null, ?int $size = null, ?Annotations $annotations = null): self - { + public function withResource( + callable|array|string $handler, + string $uri, + ?string $name = null, + ?string $description = null, + ?string $mimeType = null, + ?int $size = null, + ?Annotations $annotations = null, + ): self { $this->manualResources[] = compact('handler', 'uri', 'name', 'description', 'mimeType', 'size', 'annotations'); return $this; @@ -195,9 +240,22 @@ public function withResource(callable|array|string $handler, string $uri, ?strin /** * Manually registers a resource template handler. */ - public function withResourceTemplate(callable|array|string $handler, string $uriTemplate, ?string $name = null, ?string $description = null, ?string $mimeType = null, ?Annotations $annotations = null): self - { - $this->manualResourceTemplates[] = compact('handler', 'uriTemplate', 'name', 'description', 'mimeType', 'annotations'); + public function withResourceTemplate( + callable|array|string $handler, + string $uriTemplate, + ?string $name = null, + ?string $description = null, + ?string $mimeType = null, + ?Annotations $annotations = null, + ): self { + $this->manualResourceTemplates[] = compact( + 'handler', + 'uriTemplate', + 'name', + 'description', + 'mimeType', + 'annotations', + ); return $this; } @@ -220,7 +278,12 @@ public function build(): Server $logger = $this->logger ?? new NullLogger(); $container = $this->container ?? new Container(); - $registry = new Registry(new ReferenceHandler($container), $this->eventDispatcher, $logger); + $registry = new Registry($this->eventDispatcher, $logger); + + $referenceHandler = new ReferenceHandler($container); + $toolCaller = $this->toolCaller ??= new ToolCaller($registry, $referenceHandler, $logger); + $resourceReader = $this->resourceReader ??= new ResourceReader($registry, $referenceHandler, $logger); + $promptGetter = $this->promptGetter ??= new PromptGetter($registry, $referenceHandler, $logger); $this->registerManualElements($registry, $logger); @@ -230,8 +293,16 @@ public function build(): Server } return new Server( - Handler::make($registry, $this->serverInfo, $logger), - $logger, + jsonRpcHandler: Handler::make( + registry: $registry, + referenceProvider: $registry, + implementation: $this->serverInfo, + toolCaller: $toolCaller, + resourceReader: $resourceReader, + promptGetter: $promptGetter, + logger: $logger, + ), + logger: $logger, ); } @@ -239,8 +310,10 @@ public function build(): Server * Helper to perform the actual registration based on stored data. * Moved into the builder. */ - private function registerManualElements(Registry $registry, LoggerInterface $logger = new NullLogger()): void - { + private function registerManualElements( + Registry\ReferenceRegistryInterface $registry, + LoggerInterface $logger = new NullLogger(), + ): void { if (empty($this->manualTools) && empty($this->manualResources) && empty($this->manualResourceTemplates) && empty($this->manualPrompts)) { return; } @@ -270,10 +343,15 @@ private function registerManualElements(Registry $registry, LoggerInterface $log $tool = new Tool($name, $inputSchema, $description, $data['annotations']); $registry->registerTool($tool, $data['handler'], true); - $handlerDesc = $data['handler'] instanceof \Closure ? 'Closure' : (\is_array($data['handler']) ? implode('::', $data['handler']) : $data['handler']); + $handlerDesc = $data['handler'] instanceof \Closure ? 'Closure' : (\is_array( + $data['handler'], + ) ? implode('::', $data['handler']) : $data['handler']); $logger->debug("Registered manual tool {$name} from handler {$handlerDesc}"); } catch (\Throwable $e) { - $logger->error('Failed to register manual tool', ['handler' => $data['handler'], 'name' => $data['name'], 'exception' => $e]); + $logger->error( + 'Failed to register manual tool', + ['handler' => $data['handler'], 'name' => $data['name'], 'exception' => $e], + ); throw new ConfigurationException("Error registering manual tool '{$data['name']}': {$e->getMessage()}", 0, $e); } } @@ -303,10 +381,15 @@ private function registerManualElements(Registry $registry, LoggerInterface $log $resource = new Resource($uri, $name, $description, $mimeType, $annotations, $size); $registry->registerResource($resource, $data['handler'], true); - $handlerDesc = $data['handler'] instanceof \Closure ? 'Closure' : (\is_array($data['handler']) ? implode('::', $data['handler']) : $data['handler']); + $handlerDesc = $data['handler'] instanceof \Closure ? 'Closure' : (\is_array( + $data['handler'], + ) ? implode('::', $data['handler']) : $data['handler']); $logger->debug("Registered manual resource {$name} from handler {$handlerDesc}"); } catch (\Throwable $e) { - $logger->error('Failed to register manual resource', ['handler' => $data['handler'], 'uri' => $data['uri'], 'exception' => $e]); + $logger->error( + 'Failed to register manual resource', + ['handler' => $data['handler'], 'uri' => $data['uri'], 'exception' => $e], + ); throw new ConfigurationException("Error registering manual resource '{$data['uri']}': {$e->getMessage()}", 0, $e); } } @@ -336,10 +419,15 @@ private function registerManualElements(Registry $registry, LoggerInterface $log $completionProviders = $this->getCompletionProviders($reflection); $registry->registerResourceTemplate($template, $data['handler'], $completionProviders, true); - $handlerDesc = $data['handler'] instanceof \Closure ? 'Closure' : (\is_array($data['handler']) ? implode('::', $data['handler']) : $data['handler']); + $handlerDesc = $data['handler'] instanceof \Closure ? 'Closure' : (\is_array( + $data['handler'], + ) ? implode('::', $data['handler']) : $data['handler']); $logger->debug("Registered manual template {$name} from handler {$handlerDesc}"); } catch (\Throwable $e) { - $logger->error('Failed to register manual template', ['handler' => $data['handler'], 'uriTemplate' => $data['uriTemplate'], 'exception' => $e]); + $logger->error( + 'Failed to register manual template', + ['handler' => $data['handler'], 'uriTemplate' => $data['uriTemplate'], 'exception' => $e], + ); throw new ConfigurationException("Error registering manual resource template '{$data['uriTemplate']}': {$e->getMessage()}", 0, $e); } } @@ -362,7 +450,9 @@ private function registerManualElements(Registry $registry, LoggerInterface $log } $arguments = []; - $paramTags = $reflection instanceof \ReflectionMethod ? $docBlockParser->getParamTags($docBlockParser->parseDocBlock($reflection->getDocComment() ?? null)) : []; + $paramTags = $reflection instanceof \ReflectionMethod ? $docBlockParser->getParamTags( + $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null), + ) : []; foreach ($reflection->getParameters() as $param) { $reflectionType = $param->getType(); @@ -375,7 +465,7 @@ private function registerManualElements(Registry $registry, LoggerInterface $log $arguments[] = new PromptArgument( $param->getName(), $paramTag ? trim((string) $paramTag->getDescription()) : null, - !$param->isOptional() && !$param->isDefaultValueAvailable() + !$param->isOptional() && !$param->isDefaultValueAvailable(), ); } @@ -383,10 +473,15 @@ private function registerManualElements(Registry $registry, LoggerInterface $log $completionProviders = $this->getCompletionProviders($reflection); $registry->registerPrompt($prompt, $data['handler'], $completionProviders, true); - $handlerDesc = $data['handler'] instanceof \Closure ? 'Closure' : (\is_array($data['handler']) ? implode('::', $data['handler']) : $data['handler']); + $handlerDesc = $data['handler'] instanceof \Closure ? 'Closure' : (\is_array( + $data['handler'], + ) ? implode('::', $data['handler']) : $data['handler']); $logger->debug("Registered manual prompt {$name} from handler {$handlerDesc}"); } catch (\Throwable $e) { - $logger->error('Failed to register manual prompt', ['handler' => $data['handler'], 'name' => $data['name'], 'exception' => $e]); + $logger->error( + 'Failed to register manual prompt', + ['handler' => $data['handler'], 'name' => $data['name'], 'exception' => $e], + ); throw new ConfigurationException("Error registering manual prompt '{$data['name']}': {$e->getMessage()}", 0, $e); } } @@ -403,7 +498,10 @@ private function getCompletionProviders(\ReflectionMethod|\ReflectionFunction $r continue; } - $completionAttributes = $param->getAttributes(CompletionProvider::class, \ReflectionAttribute::IS_INSTANCEOF); + $completionAttributes = $param->getAttributes( + CompletionProvider::class, + \ReflectionAttribute::IS_INSTANCEOF, + ); if (!empty($completionAttributes)) { $attributeInstance = $completionAttributes[0]->newInstance(); diff --git a/tests/Capability/Prompt/PromptGetterTest.php b/tests/Capability/Prompt/PromptGetterTest.php new file mode 100644 index 0000000..46bee1c --- /dev/null +++ b/tests/Capability/Prompt/PromptGetterTest.php @@ -0,0 +1,638 @@ +referenceProvider = $this->createMock(ReferenceProviderInterface::class); + $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); + + $this->promptGetter = new PromptGetter( + $this->referenceProvider, + $this->referenceHandler, + ); + } + + public function testGetExecutesPromptSuccessfully(): void + { + $request = new GetPromptRequest('test_prompt', ['param' => 'value']); + $prompt = $this->createValidPrompt('test_prompt'); + $promptReference = new PromptReference($prompt, fn () => 'test result'); + $handlerResult = [ + 'role' => 'user', + 'content' => 'Generated prompt content', + ]; + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('test_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, ['param' => 'value']) + ->willReturn($handlerResult); + + $result = $this->promptGetter->get($request); + + $this->assertInstanceOf(GetPromptResult::class, $result); + $this->assertCount(1, $result->messages); + $this->assertInstanceOf(PromptMessage::class, $result->messages[0]); + $this->assertEquals(Role::User, $result->messages[0]->role); + $this->assertInstanceOf(TextContent::class, $result->messages[0]->content); + $this->assertEquals('Generated prompt content', $result->messages[0]->content->text); + } + + public function testGetWithEmptyArguments(): void + { + $request = new GetPromptRequest('empty_args_prompt', []); + $prompt = $this->createValidPrompt('empty_args_prompt'); + $promptReference = new PromptReference($prompt, fn () => 'Empty args content'); + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('empty_args_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, []) + ->willReturn([ + 'role' => 'user', + 'content' => 'Empty args content', + ]); + + $result = $this->promptGetter->get($request); + + $this->assertInstanceOf(GetPromptResult::class, $result); + $this->assertCount(1, $result->messages); + } + + public function testGetWithComplexArguments(): void + { + $arguments = [ + 'string_param' => 'value', + 'int_param' => 42, + 'bool_param' => true, + 'array_param' => ['nested' => 'data'], + 'null_param' => null, + ]; + $request = new GetPromptRequest('complex_prompt', $arguments); + $prompt = $this->createValidPrompt('complex_prompt'); + $promptReference = new PromptReference($prompt, fn () => 'Complex content'); + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('complex_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, $arguments) + ->willReturn([ + 'role' => 'assistant', + 'content' => 'Complex response', + ]); + + $result = $this->promptGetter->get($request); + + $this->assertInstanceOf(GetPromptResult::class, $result); + $this->assertCount(1, $result->messages); + } + + public function testGetThrowsInvalidArgumentExceptionWhenPromptNotFound(): void + { + $request = new GetPromptRequest('nonexistent_prompt', ['param' => 'value']); + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('nonexistent_prompt') + ->willReturn(null); + + $this->referenceHandler + ->expects($this->never()) + ->method('handle'); + + $this->expectException(PromptNotFoundException::class); + $this->expectExceptionMessage('Prompt not found for name: "nonexistent_prompt".'); + + $this->promptGetter->get($request); + } + + public function testGetThrowsRegistryExceptionWhenHandlerFails(): void + { + $request = new GetPromptRequest('failing_prompt', ['param' => 'value']); + $prompt = $this->createValidPrompt('failing_prompt'); + $promptReference = new PromptReference($prompt, fn () => throw new \RuntimeException('Handler failed')); + $handlerException = RegistryException::internalError('Handler failed'); + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('failing_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, ['param' => 'value']) + ->willThrowException($handlerException); + + $this->expectException(PromptGetException::class); + + $this->promptGetter->get($request); + } + + public function testGetHandlesJsonExceptionDuringFormatting(): void + { + $request = new GetPromptRequest('json_error_prompt', []); + $prompt = $this->createValidPrompt('json_error_prompt'); + + // Create a mock PromptReference that will throw JsonException during formatResult + $promptReference = $this->createMock(PromptReference::class); + $promptReference->expects($this->once()) + ->method('formatResult') + ->willThrowException(new \JsonException('JSON encoding failed')); + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('json_error_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, []) + ->willReturn('some result'); + + $this->expectException(PromptGetException::class); + $this->expectExceptionMessage('JSON encoding failed'); + + $this->promptGetter->get($request); + } + + public function testGetHandlesArrayOfMessages(): void + { + $request = new GetPromptRequest('multi_message_prompt', ['context' => 'test']); + $prompt = $this->createValidPrompt('multi_message_prompt'); + $promptReference = new PromptReference($prompt, fn () => 'Multiple messages'); + $handlerResult = [ + [ + 'role' => 'user', + 'content' => 'First message', + ], + [ + 'role' => 'assistant', + 'content' => 'Second message', + ], + ]; + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('multi_message_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, ['context' => 'test']) + ->willReturn($handlerResult); + + $result = $this->promptGetter->get($request); + + $this->assertInstanceOf(GetPromptResult::class, $result); + $this->assertCount(2, $result->messages); + $this->assertEquals(Role::User, $result->messages[0]->role); + $this->assertEquals('First message', $result->messages[0]->content->text); + $this->assertEquals(Role::Assistant, $result->messages[1]->role); + $this->assertEquals('Second message', $result->messages[1]->content->text); + } + + public function testGetHandlesPromptMessageObjects(): void + { + $request = new GetPromptRequest('prompt_message_prompt', []); + $prompt = $this->createValidPrompt('prompt_message_prompt'); + $promptMessage = new PromptMessage( + Role::User, + new TextContent('Direct prompt message') + ); + $promptReference = new PromptReference($prompt, fn () => $promptMessage); + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('prompt_message_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, []) + ->willReturn($promptMessage); + + $result = $this->promptGetter->get($request); + + $this->assertInstanceOf(GetPromptResult::class, $result); + $this->assertCount(1, $result->messages); + $this->assertSame($promptMessage, $result->messages[0]); + } + + public function testGetHandlesUserAssistantStructure(): void + { + $request = new GetPromptRequest('user_assistant_prompt', []); + $prompt = $this->createValidPrompt('user_assistant_prompt'); + $promptReference = new PromptReference($prompt, fn () => 'Conversation content'); + $handlerResult = [ + 'user' => 'What is the weather?', + 'assistant' => 'I can help you check the weather.', + ]; + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('user_assistant_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, []) + ->willReturn($handlerResult); + + $result = $this->promptGetter->get($request); + + $this->assertInstanceOf(GetPromptResult::class, $result); + $this->assertCount(2, $result->messages); + $this->assertEquals(Role::User, $result->messages[0]->role); + $this->assertEquals('What is the weather?', $result->messages[0]->content->text); + $this->assertEquals(Role::Assistant, $result->messages[1]->role); + $this->assertEquals('I can help you check the weather.', $result->messages[1]->content->text); + } + + public function testGetHandlesEmptyArrayResult(): void + { + $request = new GetPromptRequest('empty_array_prompt', []); + $prompt = $this->createValidPrompt('empty_array_prompt'); + $promptReference = new PromptReference($prompt, fn () => []); + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('empty_array_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, []) + ->willReturn([]); + + $result = $this->promptGetter->get($request); + + $this->assertInstanceOf(GetPromptResult::class, $result); + $this->assertCount(0, $result->messages); + } + + public function testGetWithTypedContentStructure(): void + { + $request = new GetPromptRequest('typed_content_prompt', []); + $prompt = $this->createValidPrompt('typed_content_prompt'); + $promptReference = new PromptReference($prompt, fn () => 'Typed content'); + $handlerResult = [ + 'role' => 'user', + 'content' => [ + 'type' => 'text', + 'text' => 'Typed text content', + ], + ]; + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('typed_content_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, []) + ->willReturn($handlerResult); + + $result = $this->promptGetter->get($request); + + $this->assertInstanceOf(GetPromptResult::class, $result); + $this->assertCount(1, $result->messages); + $this->assertEquals(Role::User, $result->messages[0]->role); + $this->assertEquals('Typed text content', $result->messages[0]->content->text); + } + + public function testGetWithPromptReferenceHavingCompletionProviders(): void + { + $request = new GetPromptRequest('completion_prompt', ['param' => 'value']); + $prompt = $this->createValidPrompt('completion_prompt'); + $completionProviders = ['param' => EnumCompletionProvider::class]; + $promptReference = new PromptReference( + $prompt, + fn () => 'Completion content', + false, + $completionProviders + ); + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('completion_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, ['param' => 'value']) + ->willReturn([ + 'role' => 'user', + 'content' => 'Completion content', + ]); + + $result = $this->promptGetter->get($request); + + $this->assertInstanceOf(GetPromptResult::class, $result); + $this->assertCount(1, $result->messages); + } + + public function testGetHandlesMixedMessageArray(): void + { + $request = new GetPromptRequest('mixed_prompt', []); + $prompt = $this->createValidPrompt('mixed_prompt'); + $promptMessage = new PromptMessage(Role::Assistant, new TextContent('Direct message')); + $promptReference = new PromptReference($prompt, fn () => 'Mixed content'); + $handlerResult = [ + $promptMessage, + [ + 'role' => 'user', + 'content' => 'Regular message', + ], + ]; + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('mixed_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, []) + ->willReturn($handlerResult); + + $result = $this->promptGetter->get($request); + + $this->assertInstanceOf(GetPromptResult::class, $result); + $this->assertCount(2, $result->messages); + $this->assertSame($promptMessage, $result->messages[0]); + $this->assertEquals(Role::User, $result->messages[1]->role); + $this->assertEquals('Regular message', $result->messages[1]->content->text); + } + + public function testGetReflectsFormattedMessagesCorrectly(): void + { + $request = new GetPromptRequest('format_test_prompt', []); + $prompt = $this->createValidPrompt('format_test_prompt'); + $promptReference = new PromptReference($prompt, fn () => 'Format test'); + + // Test that the formatted result from PromptReference.formatResult is properly returned + $handlerResult = [ + 'role' => 'user', + 'content' => 'Test formatting', + ]; + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('format_test_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, []) + ->willReturn($handlerResult); + + $result = $this->promptGetter->get($request); + + $this->assertInstanceOf(GetPromptResult::class, $result); + $this->assertCount(1, $result->messages); + $this->assertEquals('Test formatting', $result->messages[0]->content->text); + $this->assertEquals(Role::User, $result->messages[0]->role); + } + + /** + * Test that invalid handler results throw RuntimeException from PromptReference.formatResult(). + */ + public function testGetThrowsRuntimeExceptionForInvalidHandlerResult(): void + { + $request = new GetPromptRequest('invalid_prompt', []); + $prompt = $this->createValidPrompt('invalid_prompt'); + $promptReference = new PromptReference($prompt, fn () => 'Invalid content'); + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('invalid_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, []) + ->willReturn('This is not a valid prompt format'); + + $this->expectException(PromptGetException::class); + $this->expectExceptionMessage('Prompt generator method must return an array of messages.'); + + $this->promptGetter->get($request); + } + + /** + * Test that null result from handler throws RuntimeException. + */ + public function testGetThrowsRuntimeExceptionForNullHandlerResult(): void + { + $request = new GetPromptRequest('null_prompt', []); + $prompt = $this->createValidPrompt('null_prompt'); + $promptReference = new PromptReference($prompt, fn () => null); + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('null_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, []) + ->willReturn(null); + + $this->expectException(PromptGetException::class); + $this->expectExceptionMessage('Prompt generator method must return an array of messages.'); + + $this->promptGetter->get($request); + } + + /** + * Test that scalar result from handler throws RuntimeException. + */ + public function testGetThrowsRuntimeExceptionForScalarHandlerResult(): void + { + $request = new GetPromptRequest('scalar_prompt', []); + $prompt = $this->createValidPrompt('scalar_prompt'); + $promptReference = new PromptReference($prompt, fn () => 42); + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('scalar_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, []) + ->willReturn(42); + + $this->expectException(PromptGetException::class); + $this->expectExceptionMessage('Prompt generator method must return an array of messages.'); + + $this->promptGetter->get($request); + } + + /** + * Test that boolean result from handler throws RuntimeException. + */ + public function testGetThrowsRuntimeExceptionForBooleanHandlerResult(): void + { + $request = new GetPromptRequest('boolean_prompt', []); + $prompt = $this->createValidPrompt('boolean_prompt'); + $promptReference = new PromptReference($prompt, fn () => true); + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('boolean_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, []) + ->willReturn(true); + + $this->expectException(PromptGetException::class); + $this->expectExceptionMessage('Prompt generator method must return an array of messages.'); + + $this->promptGetter->get($request); + } + + /** + * Test that object result from handler throws RuntimeException. + */ + public function testGetThrowsRuntimeExceptionForObjectHandlerResult(): void + { + $request = new GetPromptRequest('object_prompt', []); + $prompt = $this->createValidPrompt('object_prompt'); + $objectResult = new \stdClass(); + $objectResult->property = 'value'; + $promptReference = new PromptReference($prompt, fn () => $objectResult); + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('object_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, []) + ->willReturn($objectResult); + + $this->expectException(PromptGetException::class); + $this->expectExceptionMessage('Prompt generator method must return an array of messages.'); + + $this->promptGetter->get($request); + } + + public function testConstructorWithDefaultLogger(): void + { + $promptGetter = new PromptGetter( + $this->referenceProvider, + $this->referenceHandler, + ); + + $this->assertInstanceOf(PromptGetter::class, $promptGetter); + } + + public function testConstructorWithCustomLogger(): void + { + $logger = $this->createMock(LoggerInterface::class); + $promptGetter = new PromptGetter( + $this->referenceProvider, + $this->referenceHandler, + $logger, + ); + + $this->assertInstanceOf(PromptGetter::class, $promptGetter); + } + + private function createValidPrompt(string $name): Prompt + { + return new Prompt( + name: $name, + description: "Test prompt: {$name}", + arguments: null, + ); + } +} diff --git a/tests/Capability/Registry/RegistryProviderTest.php b/tests/Capability/Registry/RegistryProviderTest.php new file mode 100644 index 0000000..8669afa --- /dev/null +++ b/tests/Capability/Registry/RegistryProviderTest.php @@ -0,0 +1,313 @@ +registry = new Registry(); + } + + public function testGetToolReturnsRegisteredTool(): void + { + $tool = $this->createValidTool('test_tool'); + $handler = fn () => 'result'; + + $this->registry->registerTool($tool, $handler); + + $toolRef = $this->registry->getTool('test_tool'); + $this->assertInstanceOf(ToolReference::class, $toolRef); + $this->assertEquals($tool->name, $toolRef->tool->name); + $this->assertEquals($handler, $toolRef->handler); + $this->assertFalse($toolRef->isManual); + } + + public function testGetToolReturnsNullForUnregisteredTool(): void + { + $toolRef = $this->registry->getTool('non_existent_tool'); + $this->assertNull($toolRef); + } + + public function testGetResourceReturnsRegisteredResource(): void + { + $resource = $this->createValidResource('test://resource'); + $handler = fn () => 'content'; + + $this->registry->registerResource($resource, $handler); + + $resourceRef = $this->registry->getResource('test://resource'); + $this->assertInstanceOf(ResourceReference::class, $resourceRef); + $this->assertEquals($resource->uri, $resourceRef->schema->uri); + $this->assertEquals($handler, $resourceRef->handler); + $this->assertFalse($resourceRef->isManual); + } + + public function testGetResourceReturnsNullForUnregisteredResource(): void + { + $resourceRef = $this->registry->getResource('test://non_existent'); + $this->assertNull($resourceRef); + } + + public function testGetResourceMatchesResourceTemplate(): void + { + $template = $this->createValidResourceTemplate('test://{id}'); + $handler = fn (string $id) => "content for {$id}"; + + $this->registry->registerResourceTemplate($template, $handler); + + $resourceRef = $this->registry->getResource('test://123'); + $this->assertInstanceOf(ResourceTemplateReference::class, $resourceRef); + $this->assertEquals($template->uriTemplate, $resourceRef->resourceTemplate->uriTemplate); + $this->assertEquals($handler, $resourceRef->handler); + } + + public function testGetResourceWithIncludeTemplatesFalse(): void + { + $template = $this->createValidResourceTemplate('test://{id}'); + $handler = fn (string $id) => "content for {$id}"; + + $this->registry->registerResourceTemplate($template, $handler); + + $resourceRef = $this->registry->getResource('test://123', false); + $this->assertNull($resourceRef); + } + + public function testGetResourcePrefersDirectResourceOverTemplate(): void + { + $resource = $this->createValidResource('test://123'); + $resourceHandler = fn () => 'direct resource'; + + $template = $this->createValidResourceTemplate('test://{id}'); + $templateHandler = fn (string $id) => "template for {$id}"; + + $this->registry->registerResource($resource, $resourceHandler); + $this->registry->registerResourceTemplate($template, $templateHandler); + + $resourceRef = $this->registry->getResource('test://123'); + $this->assertInstanceOf(ResourceReference::class, $resourceRef); + $this->assertEquals($resource->uri, $resourceRef->schema->uri); + } + + public function testGetResourceTemplateReturnsRegisteredTemplate(): void + { + $template = $this->createValidResourceTemplate('test://{id}'); + $handler = fn (string $id) => "content for {$id}"; + + $this->registry->registerResourceTemplate($template, $handler); + + $templateRef = $this->registry->getResourceTemplate('test://{id}'); + $this->assertInstanceOf(ResourceTemplateReference::class, $templateRef); + $this->assertEquals($template->uriTemplate, $templateRef->resourceTemplate->uriTemplate); + $this->assertEquals($handler, $templateRef->handler); + $this->assertFalse($templateRef->isManual); + } + + public function testGetResourceTemplateReturnsNullForUnregisteredTemplate(): void + { + $templateRef = $this->registry->getResourceTemplate('test://{non_existent}'); + $this->assertNull($templateRef); + } + + public function testGetPromptReturnsRegisteredPrompt(): void + { + $prompt = $this->createValidPrompt('test_prompt'); + $handler = fn () => ['role' => 'user', 'content' => 'test message']; + + $this->registry->registerPrompt($prompt, $handler); + + $promptRef = $this->registry->getPrompt('test_prompt'); + $this->assertInstanceOf(PromptReference::class, $promptRef); + $this->assertEquals($prompt->name, $promptRef->prompt->name); + $this->assertEquals($handler, $promptRef->handler); + $this->assertFalse($promptRef->isManual); + } + + public function testGetPromptReturnsNullForUnregisteredPrompt(): void + { + $promptRef = $this->registry->getPrompt('non_existent_prompt'); + $this->assertNull($promptRef); + } + + public function testGetToolsReturnsAllRegisteredTools(): void + { + $tool1 = $this->createValidTool('tool1'); + $tool2 = $this->createValidTool('tool2'); + + $this->registry->registerTool($tool1, fn () => 'result1'); + $this->registry->registerTool($tool2, fn () => 'result2'); + + $tools = $this->registry->getTools(); + $this->assertCount(2, $tools); + $this->assertArrayHasKey('tool1', $tools); + $this->assertArrayHasKey('tool2', $tools); + $this->assertInstanceOf(Tool::class, $tools['tool1']); + $this->assertInstanceOf(Tool::class, $tools['tool2']); + } + + public function testGetResourcesReturnsAllRegisteredResources(): void + { + $resource1 = $this->createValidResource('test://resource1'); + $resource2 = $this->createValidResource('test://resource2'); + + $this->registry->registerResource($resource1, fn () => 'content1'); + $this->registry->registerResource($resource2, fn () => 'content2'); + + $resources = $this->registry->getResources(); + $this->assertCount(2, $resources); + $this->assertArrayHasKey('test://resource1', $resources); + $this->assertArrayHasKey('test://resource2', $resources); + $this->assertInstanceOf(Resource::class, $resources['test://resource1']); + $this->assertInstanceOf(Resource::class, $resources['test://resource2']); + } + + public function testGetPromptsReturnsAllRegisteredPrompts(): void + { + $prompt1 = $this->createValidPrompt('prompt1'); + $prompt2 = $this->createValidPrompt('prompt2'); + + $this->registry->registerPrompt($prompt1, fn () => []); + $this->registry->registerPrompt($prompt2, fn () => []); + + $prompts = $this->registry->getPrompts(); + $this->assertCount(2, $prompts); + $this->assertArrayHasKey('prompt1', $prompts); + $this->assertArrayHasKey('prompt2', $prompts); + $this->assertInstanceOf(Prompt::class, $prompts['prompt1']); + $this->assertInstanceOf(Prompt::class, $prompts['prompt2']); + } + + public function testGetResourceTemplatesReturnsAllRegisteredTemplates(): void + { + $template1 = $this->createValidResourceTemplate('test1://{id}'); + $template2 = $this->createValidResourceTemplate('test2://{category}'); + + $this->registry->registerResourceTemplate($template1, fn () => 'content1'); + $this->registry->registerResourceTemplate($template2, fn () => 'content2'); + + $templates = $this->registry->getResourceTemplates(); + $this->assertCount(2, $templates); + $this->assertArrayHasKey('test1://{id}', $templates); + $this->assertArrayHasKey('test2://{category}', $templates); + $this->assertInstanceOf(ResourceTemplate::class, $templates['test1://{id}']); + $this->assertInstanceOf(ResourceTemplate::class, $templates['test2://{category}']); + } + + public function testHasElementsReturnsFalseForEmptyRegistry(): void + { + $this->assertFalse($this->registry->hasElements()); + } + + public function testHasElementsReturnsTrueWhenToolIsRegistered(): void + { + $tool = $this->createValidTool('test_tool'); + $this->registry->registerTool($tool, fn () => 'result'); + + $this->assertTrue($this->registry->hasElements()); + } + + public function testHasElementsReturnsTrueWhenResourceIsRegistered(): void + { + $resource = $this->createValidResource('test://resource'); + $this->registry->registerResource($resource, fn () => 'content'); + + $this->assertTrue($this->registry->hasElements()); + } + + public function testHasElementsReturnsTrueWhenPromptIsRegistered(): void + { + $prompt = $this->createValidPrompt('test_prompt'); + $this->registry->registerPrompt($prompt, fn () => []); + + $this->assertTrue($this->registry->hasElements()); + } + + public function testHasElementsReturnsTrueWhenResourceTemplateIsRegistered(): void + { + $template = $this->createValidResourceTemplate('test://{id}'); + $this->registry->registerResourceTemplate($template, fn () => 'content'); + + $this->assertTrue($this->registry->hasElements()); + } + + public function testResourceTemplateMatchingPrefersMoreSpecificMatches(): void + { + $specificTemplate = $this->createValidResourceTemplate('test://users/{userId}/profile'); + $genericTemplate = $this->createValidResourceTemplate('test://users/{userId}'); + + $this->registry->registerResourceTemplate($genericTemplate, fn () => 'generic'); + $this->registry->registerResourceTemplate($specificTemplate, fn () => 'specific'); + + // Should match the more specific template first + $resourceRef = $this->registry->getResource('test://users/123/profile'); + $this->assertInstanceOf(ResourceTemplateReference::class, $resourceRef); + $this->assertEquals('test://users/{userId}/profile', $resourceRef->resourceTemplate->uriTemplate); + } + + private function createValidTool(string $name): Tool + { + return new Tool( + name: $name, + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'param' => ['type' => 'string'], + ], + 'required' => null, + ], + description: "Test tool: {$name}", + annotations: null + ); + } + + private function createValidResource(string $uri): Resource + { + return new Resource( + uri: $uri, + name: 'test_resource', + description: 'Test resource', + mimeType: 'text/plain' + ); + } + + private function createValidResourceTemplate(string $uriTemplate): ResourceTemplate + { + return new ResourceTemplate( + uriTemplate: $uriTemplate, + name: 'test_template', + description: 'Test resource template', + mimeType: 'text/plain' + ); + } + + private function createValidPrompt(string $name): Prompt + { + return new Prompt( + name: $name, + description: "Test prompt: {$name}", + arguments: [] + ); + } +} diff --git a/tests/Capability/Registry/RegistryTest.php b/tests/Capability/Registry/RegistryTest.php new file mode 100644 index 0000000..14548b6 --- /dev/null +++ b/tests/Capability/Registry/RegistryTest.php @@ -0,0 +1,354 @@ +logger = $this->createMock(LoggerInterface::class); + $this->registry = new Registry(null, $this->logger); + } + + public function testConstructorWithDefaults(): void + { + $registry = new Registry(); + $capabilities = $registry->getCapabilities(); + + $this->assertInstanceOf(ServerCapabilities::class, $capabilities); + $this->assertFalse($capabilities->toolsListChanged); + $this->assertFalse($capabilities->resourcesListChanged); + $this->assertFalse($capabilities->promptsListChanged); + } + + public function testGetCapabilitiesWhenEmpty(): void + { + $this->logger + ->expects($this->once()) + ->method('info') + ->with('No capabilities registered on server.'); + + $capabilities = $this->registry->getCapabilities(); + + $this->assertFalse($capabilities->tools); + $this->assertFalse($capabilities->resources); + $this->assertFalse($capabilities->prompts); + } + + public function testGetCapabilitiesWhenPopulated(): void + { + $tool = $this->createValidTool('test_tool'); + $resource = $this->createValidResource('test://resource'); + $prompt = $this->createValidPrompt('test_prompt'); + $template = $this->createValidResourceTemplate('test://{id}'); + + $this->registry->registerTool($tool, fn () => 'result'); + $this->registry->registerResource($resource, fn () => 'content'); + $this->registry->registerPrompt($prompt, fn () => []); + $this->registry->registerResourceTemplate($template, fn () => 'template'); + + $capabilities = $this->registry->getCapabilities(); + + $this->assertTrue($capabilities->tools); + $this->assertTrue($capabilities->resources); + $this->assertTrue($capabilities->prompts); + $this->assertTrue($capabilities->completions); + $this->assertFalse($capabilities->resourcesSubscribe); + $this->assertFalse($capabilities->logging); + } + + public function testRegisterToolWithManualFlag(): void + { + $tool = $this->createValidTool('test_tool'); + $handler = fn () => 'result'; + + $this->registry->registerTool($tool, $handler, true); + + $toolRef = $this->registry->getTool('test_tool'); + $this->assertTrue($toolRef->isManual); + } + + public function testRegisterToolIgnoresDiscoveredWhenManualExists(): void + { + $manualTool = $this->createValidTool('test_tool'); + $discoveredTool = $this->createValidTool('test_tool'); + + $this->registry->registerTool($manualTool, fn () => 'manual', true); + + $this->logger + ->expects($this->once()) + ->method('debug') + ->with("Ignoring discovered tool 'test_tool' as it conflicts with a manually registered one."); + + $this->registry->registerTool($discoveredTool, fn () => 'discovered', false); + + $toolRef = $this->registry->getTool('test_tool'); + $this->assertTrue($toolRef->isManual); + } + + public function testRegisterToolOverridesDiscoveredWithManual(): void + { + $discoveredTool = $this->createValidTool('test_tool'); + $manualTool = $this->createValidTool('test_tool'); + + $this->registry->registerTool($discoveredTool, fn () => 'discovered', false); + $this->registry->registerTool($manualTool, fn () => 'manual', true); + + $toolRef = $this->registry->getTool('test_tool'); + $this->assertTrue($toolRef->isManual); + } + + public function testRegisterResourceWithManualFlag(): void + { + $resource = $this->createValidResource('test://resource'); + $handler = fn () => 'content'; + + $this->registry->registerResource($resource, $handler, true); + + $resourceRef = $this->registry->getResource('test://resource'); + $this->assertTrue($resourceRef->isManual); + } + + public function testRegisterResourceIgnoresDiscoveredWhenManualExists(): void + { + $manualResource = $this->createValidResource('test://resource'); + $discoveredResource = $this->createValidResource('test://resource'); + + $this->registry->registerResource($manualResource, fn () => 'manual', true); + + $this->logger + ->expects($this->once()) + ->method('debug') + ->with("Ignoring discovered resource 'test://resource' as it conflicts with a manually registered one."); + + $this->registry->registerResource($discoveredResource, fn () => 'discovered', false); + + $resourceRef = $this->registry->getResource('test://resource'); + $this->assertTrue($resourceRef->isManual); + } + + public function testRegisterResourceTemplateWithCompletionProviders(): void + { + $template = $this->createValidResourceTemplate('test://{id}'); + $completionProviders = ['id' => EnumCompletionProvider::class]; + + $this->registry->registerResourceTemplate($template, fn () => 'content', $completionProviders); + + $templateRef = $this->registry->getResourceTemplate('test://{id}'); + $this->assertEquals($completionProviders, $templateRef->completionProviders); + } + + public function testRegisterResourceTemplateIgnoresDiscoveredWhenManualExists(): void + { + $manualTemplate = $this->createValidResourceTemplate('test://{id}'); + $discoveredTemplate = $this->createValidResourceTemplate('test://{id}'); + + $this->registry->registerResourceTemplate($manualTemplate, fn () => 'manual', [], true); + + $this->logger + ->expects($this->once()) + ->method('debug') + ->with("Ignoring discovered template 'test://{id}' as it conflicts with a manually registered one."); + + $this->registry->registerResourceTemplate($discoveredTemplate, fn () => 'discovered', [], false); + + $templateRef = $this->registry->getResourceTemplate('test://{id}'); + $this->assertTrue($templateRef->isManual); + } + + public function testRegisterPromptWithCompletionProviders(): void + { + $prompt = $this->createValidPrompt('test_prompt'); + $completionProviders = ['param' => EnumCompletionProvider::class]; + + $this->registry->registerPrompt($prompt, fn () => [], $completionProviders); + + $promptRef = $this->registry->getPrompt('test_prompt'); + $this->assertEquals($completionProviders, $promptRef->completionProviders); + } + + public function testRegisterPromptIgnoresDiscoveredWhenManualExists(): void + { + $manualPrompt = $this->createValidPrompt('test_prompt'); + $discoveredPrompt = $this->createValidPrompt('test_prompt'); + + $this->registry->registerPrompt($manualPrompt, fn () => 'manual', [], true); + + $this->logger + ->expects($this->once()) + ->method('debug') + ->with("Ignoring discovered prompt 'test_prompt' as it conflicts with a manually registered one."); + + $this->registry->registerPrompt($discoveredPrompt, fn () => 'discovered', [], false); + + $promptRef = $this->registry->getPrompt('test_prompt'); + $this->assertTrue($promptRef->isManual); + } + + public function testClearRemovesOnlyDiscoveredElements(): void + { + $manualTool = $this->createValidTool('manual_tool'); + $discoveredTool = $this->createValidTool('discovered_tool'); + $manualResource = $this->createValidResource('test://manual'); + $discoveredResource = $this->createValidResource('test://discovered'); + $manualPrompt = $this->createValidPrompt('manual_prompt'); + $discoveredPrompt = $this->createValidPrompt('discovered_prompt'); + $manualTemplate = $this->createValidResourceTemplate('manual://{id}'); + $discoveredTemplate = $this->createValidResourceTemplate('discovered://{id}'); + + $this->registry->registerTool($manualTool, fn () => 'manual', true); + $this->registry->registerTool($discoveredTool, fn () => 'discovered', false); + $this->registry->registerResource($manualResource, fn () => 'manual', true); + $this->registry->registerResource($discoveredResource, fn () => 'discovered', false); + $this->registry->registerPrompt($manualPrompt, fn () => [], [], true); + $this->registry->registerPrompt($discoveredPrompt, fn () => [], [], false); + $this->registry->registerResourceTemplate($manualTemplate, fn () => 'manual', [], true); + $this->registry->registerResourceTemplate($discoveredTemplate, fn () => 'discovered', [], false); + + $this->logger + ->expects($this->once()) + ->method('debug') + ->with('Removed 4 discovered elements from internal registry.'); + + $this->registry->clear(); + + $this->assertNotNull($this->registry->getTool('manual_tool')); + $this->assertNull($this->registry->getTool('discovered_tool')); + $this->assertNotNull($this->registry->getResource('test://manual')); + $this->assertNull( + $this->registry->getResource('test://discovered', false), + ); // Don't include templates to avoid debug log + $this->assertNotNull($this->registry->getPrompt('manual_prompt')); + $this->assertNull($this->registry->getPrompt('discovered_prompt')); + $this->assertNotNull($this->registry->getResourceTemplate('manual://{id}')); + $this->assertNull($this->registry->getResourceTemplate('discovered://{id}')); + } + + public function testClearLogsNothingWhenNoDiscoveredElements(): void + { + $manualTool = $this->createValidTool('manual_tool'); + $this->registry->registerTool($manualTool, fn () => 'manual', true); + + $this->logger + ->expects($this->never()) + ->method('debug'); + + $this->registry->clear(); + + $this->assertNotNull($this->registry->getTool('manual_tool')); + } + + public function testRegisterToolHandlesStringHandler(): void + { + $tool = $this->createValidTool('test_tool'); + $handler = 'TestClass::testMethod'; + + $this->registry->registerTool($tool, $handler); + + $toolRef = $this->registry->getTool('test_tool'); + $this->assertEquals($handler, $toolRef->handler); + } + + public function testRegisterToolHandlesArrayHandler(): void + { + $tool = $this->createValidTool('test_tool'); + $handler = ['TestClass', 'testMethod']; + + $this->registry->registerTool($tool, $handler); + + $toolRef = $this->registry->getTool('test_tool'); + $this->assertEquals($handler, $toolRef->handler); + } + + public function testRegisterResourceHandlesCallableHandler(): void + { + $resource = $this->createValidResource('test://resource'); + $handler = fn () => 'content'; + + $this->registry->registerResource($resource, $handler); + + $resourceRef = $this->registry->getResource('test://resource'); + $this->assertEquals($handler, $resourceRef->handler); + } + + public function testMultipleRegistrationsOfSameElementWithSameType(): void + { + $tool1 = $this->createValidTool('test_tool'); + $tool2 = $this->createValidTool('test_tool'); + + $this->registry->registerTool($tool1, fn () => 'first', false); + $this->registry->registerTool($tool2, fn () => 'second', false); + + // Second registration should override the first + $toolRef = $this->registry->getTool('test_tool'); + $this->assertEquals('second', ($toolRef->handler)()); + } + + private function createValidTool(string $name): Tool + { + return new Tool( + name: $name, + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'param' => ['type' => 'string'], + ], + 'required' => null, + ], + description: "Test tool: {$name}", + annotations: null, + ); + } + + private function createValidResource(string $uri): Resource + { + return new Resource( + uri: $uri, + name: 'test_resource', + description: 'Test resource', + mimeType: 'text/plain', + ); + } + + private function createValidResourceTemplate(string $uriTemplate): ResourceTemplate + { + return new ResourceTemplate( + uriTemplate: $uriTemplate, + name: 'test_template', + description: 'Test resource template', + mimeType: 'text/plain', + ); + } + + private function createValidPrompt(string $name): Prompt + { + return new Prompt( + name: $name, + description: "Test prompt: {$name}", + arguments: [], + ); + } +} diff --git a/tests/Capability/Resource/ResourceReaderTest.php b/tests/Capability/Resource/ResourceReaderTest.php new file mode 100644 index 0000000..2206ce9 --- /dev/null +++ b/tests/Capability/Resource/ResourceReaderTest.php @@ -0,0 +1,522 @@ +referenceProvider = $this->createMock(ReferenceProviderInterface::class); + $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); + + $this->resourceReader = new ResourceReader( + $this->referenceProvider, + $this->referenceHandler, + ); + } + + public function testReadResourceSuccessfullyWithStringResult(): void + { + $request = new ReadResourceRequest('file://test.txt'); + $resource = $this->createValidResource('file://test.txt', 'test', 'text/plain'); + $resourceReference = new ResourceReference($resource, fn () => 'test content'); + $handlerResult = 'test content'; + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with('file://test.txt') + ->willReturn($resourceReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($resourceReference, ['uri' => 'file://test.txt']) + ->willReturn($handlerResult); + + $result = $this->resourceReader->read($request); + + $this->assertInstanceOf(ReadResourceResult::class, $result); + $this->assertCount(1, $result->contents); + $this->assertInstanceOf(TextResourceContents::class, $result->contents[0]); + $this->assertEquals('test content', $result->contents[0]->text); + $this->assertEquals('file://test.txt', $result->contents[0]->uri); + $this->assertEquals('text/plain', $result->contents[0]->mimeType); + } + + public function testReadResourceSuccessfullyWithArrayResult(): void + { + $request = new ReadResourceRequest('api://data'); + $resource = $this->createValidResource('api://data', 'data', 'application/json'); + $resourceReference = new ResourceReference($resource, fn () => ['key' => 'value', 'count' => 42]); + $handlerResult = ['key' => 'value', 'count' => 42]; + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with('api://data') + ->willReturn($resourceReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($resourceReference, ['uri' => 'api://data']) + ->willReturn($handlerResult); + + $result = $this->resourceReader->read($request); + + $this->assertInstanceOf(ReadResourceResult::class, $result); + $this->assertCount(1, $result->contents); + $this->assertInstanceOf(TextResourceContents::class, $result->contents[0]); + $this->assertJsonStringEqualsJsonString( + json_encode($handlerResult, \JSON_PRETTY_PRINT), + $result->contents[0]->text, + ); + $this->assertEquals('api://data', $result->contents[0]->uri); + $this->assertEquals('application/json', $result->contents[0]->mimeType); + } + + public function testReadResourceSuccessfullyWithBlobResult(): void + { + $request = new ReadResourceRequest('file://image.png'); + $resource = $this->createValidResource('file://image.png', 'image', 'image/png'); + + $handlerResult = [ + 'blob' => base64_encode('binary data'), + 'mimeType' => 'image/png', + ]; + + $resourceReference = new ResourceReference($resource, fn () => $handlerResult); + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with('file://image.png') + ->willReturn($resourceReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($resourceReference, ['uri' => 'file://image.png']) + ->willReturn($handlerResult); + + $result = $this->resourceReader->read($request); + + $this->assertInstanceOf(ReadResourceResult::class, $result); + $this->assertCount(1, $result->contents); + $this->assertInstanceOf(BlobResourceContents::class, $result->contents[0]); + $this->assertEquals(base64_encode('binary data'), $result->contents[0]->blob); + $this->assertEquals('file://image.png', $result->contents[0]->uri); + $this->assertEquals('image/png', $result->contents[0]->mimeType); + } + + public function testReadResourceSuccessfullyWithResourceContentResult(): void + { + $request = new ReadResourceRequest('custom://resource'); + $resource = $this->createValidResource('custom://resource', 'resource', 'text/plain'); + $textContent = new TextResourceContents('custom://resource', 'text/plain', 'direct content'); + $resourceReference = new ResourceReference($resource, fn () => $textContent); + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with('custom://resource') + ->willReturn($resourceReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($resourceReference, ['uri' => 'custom://resource']) + ->willReturn($textContent); + + $result = $this->resourceReader->read($request); + + $this->assertInstanceOf(ReadResourceResult::class, $result); + $this->assertCount(1, $result->contents); + $this->assertSame($textContent, $result->contents[0]); + } + + public function testReadResourceSuccessfullyWithMultipleContentResults(): void + { + $request = new ReadResourceRequest('multi://content'); + $resource = $this->createValidResource('multi://content', 'content', 'application/json'); + $content1 = new TextResourceContents('multi://content', 'text/plain', 'first content'); + $content2 = new TextResourceContents('multi://content', 'text/plain', 'second content'); + $handlerResult = [$content1, $content2]; + $resourceReference = new ResourceReference($resource, fn () => $handlerResult); + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with('multi://content') + ->willReturn($resourceReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($resourceReference, ['uri' => 'multi://content']) + ->willReturn($handlerResult); + + $result = $this->resourceReader->read($request); + + $this->assertInstanceOf(ReadResourceResult::class, $result); + $this->assertCount(2, $result->contents); + $this->assertSame($content1, $result->contents[0]); + $this->assertSame($content2, $result->contents[1]); + } + + public function testReadResourceTemplate(): void + { + $request = new ReadResourceRequest('users://123'); + $resourceTemplate = $this->createValidResourceTemplate('users://{id}', 'user_template'); + $templateReference = new ResourceTemplateReference( + $resourceTemplate, + fn () => ['id' => 123, 'name' => 'Test User'], + ); + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with('users://123') + ->willReturn($templateReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($templateReference, ['uri' => 'users://123']) + ->willReturn(['id' => 123, 'name' => 'Test User']); + + $result = $this->resourceReader->read($request); + + $this->assertInstanceOf(ReadResourceResult::class, $result); + $this->assertCount(1, $result->contents); + $this->assertInstanceOf(TextResourceContents::class, $result->contents[0]); + $this->assertJsonStringEqualsJsonString( + json_encode(['id' => 123, 'name' => 'Test User'], \JSON_PRETTY_PRINT), + $result->contents[0]->text, + ); + } + + public function testReadResourceThrowsExceptionWhenResourceNotFound(): void + { + $request = new ReadResourceRequest('nonexistent://resource'); + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with('nonexistent://resource') + ->willReturn(null); + + $this->referenceHandler + ->expects($this->never()) + ->method('handle'); + + $this->expectException(ResourceNotFoundException::class); + $this->expectExceptionMessage('Resource not found for uri: "nonexistent://resource".'); + + $this->resourceReader->read($request); + } + + public function testReadResourceThrowsRegistryExceptionWhenHandlerFails(): void + { + $request = new ReadResourceRequest('failing://resource'); + $resource = $this->createValidResource('failing://resource', 'failing', 'text/plain'); + $resourceReference = new ResourceReference($resource, fn () => throw new \RuntimeException('Handler failed')); + $handlerException = new RegistryException('Handler execution failed'); + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with('failing://resource') + ->willReturn($resourceReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($resourceReference, ['uri' => 'failing://resource']) + ->willThrowException($handlerException); + + $this->expectException(ResourceReadException::class); + $this->expectExceptionMessage('Handler execution failed'); + + $this->resourceReader->read($request); + } + + public function testReadResourcePassesCorrectArgumentsToHandler(): void + { + $request = new ReadResourceRequest('test://resource'); + $resource = $this->createValidResource('test://resource', 'test', 'text/plain'); + $resourceReference = new ResourceReference($resource, fn () => 'test'); + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with('test://resource') + ->willReturn($resourceReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with( + $this->identicalTo($resourceReference), + $this->equalTo(['uri' => 'test://resource']), + ) + ->willReturn('test'); + + $result = $this->resourceReader->read($request); + + $this->assertInstanceOf(ReadResourceResult::class, $result); + } + + public function testReadResourceWithEmptyStringResult(): void + { + $request = new ReadResourceRequest('empty://resource'); + $resource = $this->createValidResource('empty://resource', 'empty', 'text/plain'); + $resourceReference = new ResourceReference($resource, fn () => ''); + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with('empty://resource') + ->willReturn($resourceReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($resourceReference, ['uri' => 'empty://resource']) + ->willReturn(''); + + $result = $this->resourceReader->read($request); + + $this->assertInstanceOf(ReadResourceResult::class, $result); + $this->assertCount(1, $result->contents); + $this->assertInstanceOf(TextResourceContents::class, $result->contents[0]); + $this->assertEquals('', $result->contents[0]->text); + } + + public function testReadResourceWithEmptyArrayResult(): void + { + $request = new ReadResourceRequest('empty://array'); + $resource = $this->createValidResource('empty://array', 'array', 'application/json'); + $resourceReference = new ResourceReference($resource, fn () => []); + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with('empty://array') + ->willReturn($resourceReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($resourceReference, ['uri' => 'empty://array']) + ->willReturn([]); + + $result = $this->resourceReader->read($request); + + $this->assertInstanceOf(ReadResourceResult::class, $result); + $this->assertCount(1, $result->contents); + $this->assertInstanceOf(TextResourceContents::class, $result->contents[0]); + $this->assertEquals('[]', $result->contents[0]->text); + $this->assertEquals('application/json', $result->contents[0]->mimeType); + } + + public function testReadResourceWithNullResult(): void + { + $request = new ReadResourceRequest('null://resource'); + $resource = $this->createValidResource('null://resource', 'null', 'text/plain'); + $resourceReference = new ResourceReference($resource, fn () => null); + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with('null://resource') + ->willReturn($resourceReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($resourceReference, ['uri' => 'null://resource']) + ->willReturn(null); + + // The formatResult method in ResourceReference should handle null values + $this->expectException(\RuntimeException::class); + + $this->resourceReader->read($request); + } + + public function testReadResourceWithDifferentMimeTypes(): void + { + $request = new ReadResourceRequest('xml://data'); + $resource = $this->createValidResource('xml://data', 'data', 'application/xml'); + $resourceReference = new ResourceReference($resource, fn () => 'value'); + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with('xml://data') + ->willReturn($resourceReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($resourceReference, ['uri' => 'xml://data']) + ->willReturn('value'); + + $result = $this->resourceReader->read($request); + + $this->assertInstanceOf(ReadResourceResult::class, $result); + $this->assertCount(1, $result->contents); + $this->assertInstanceOf(TextResourceContents::class, $result->contents[0]); + // The MIME type should be guessed from content since formatResult handles the conversion + $this->assertEquals('value', $result->contents[0]->text); + } + + public function testReadResourceWithJsonMimeTypeAndArrayResult(): void + { + $request = new ReadResourceRequest('api://json'); + $resource = $this->createValidResource('api://json', 'json', 'application/json'); + $resourceReference = new ResourceReference($resource, fn () => ['formatted' => true, 'data' => [1, 2, 3]]); + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with('api://json') + ->willReturn($resourceReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($resourceReference, ['uri' => 'api://json']) + ->willReturn(['formatted' => true, 'data' => [1, 2, 3]]); + + $result = $this->resourceReader->read($request); + + $this->assertInstanceOf(ReadResourceResult::class, $result); + $this->assertCount(1, $result->contents); + $this->assertInstanceOf(TextResourceContents::class, $result->contents[0]); + $this->assertEquals('application/json', $result->contents[0]->mimeType); + $this->assertJsonStringEqualsJsonString( + json_encode(['formatted' => true, 'data' => [1, 2, 3]], \JSON_PRETTY_PRINT), + $result->contents[0]->text, + ); + } + + public function testReadResourceCallsFormatResultOnReference(): void + { + $request = new ReadResourceRequest('format://test'); + $resource = $this->createValidResource('format://test', 'format', 'text/plain'); + + // Create a mock ResourceReference to verify formatResult is called + $resourceReference = $this + ->getMockBuilder(ResourceReference::class) + ->setConstructorArgs([$resource, fn () => 'test', false]) + ->onlyMethods(['formatResult']) + ->getMock(); + + $handlerResult = 'test result'; + $formattedResult = [new TextResourceContents('format://test', 'text/plain', 'formatted content')]; + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with('format://test') + ->willReturn($resourceReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($resourceReference, ['uri' => 'format://test']) + ->willReturn($handlerResult); + + $resourceReference + ->expects($this->once()) + ->method('formatResult') + ->with($handlerResult, 'format://test') + ->willReturn($formattedResult); + + $result = $this->resourceReader->read($request); + + $this->assertInstanceOf(ReadResourceResult::class, $result); + $this->assertSame($formattedResult, $result->contents); + } + + public function testConstructorWithDefaultLogger(): void + { + $resourceReader = new ResourceReader( + $this->referenceProvider, + $this->referenceHandler, + ); + + $this->assertInstanceOf(ResourceReader::class, $resourceReader); + } + + public function testConstructorWithCustomLogger(): void + { + $logger = $this->createMock(LoggerInterface::class); + $resourceReader = new ResourceReader( + $this->referenceProvider, + $this->referenceHandler, + $logger, + ); + + $this->assertInstanceOf(ResourceReader::class, $resourceReader); + } + + private function createValidResource(string $uri, string $name, ?string $mimeType = null): Resource + { + return new Resource( + uri: $uri, + name: $name, + description: "Test resource: {$name}", + mimeType: $mimeType, + size: null, + annotations: null, + ); + } + + private function createValidResourceTemplate( + string $uriTemplate, + string $name, + ?string $mimeType = null, + ): ResourceTemplate { + return new ResourceTemplate( + uriTemplate: $uriTemplate, + name: $name, + description: "Test resource template: {$name}", + mimeType: $mimeType, + annotations: null, + ); + } +} diff --git a/tests/Capability/Tool/ToolCallerTest.php b/tests/Capability/Tool/ToolCallerTest.php new file mode 100644 index 0000000..8894dc9 --- /dev/null +++ b/tests/Capability/Tool/ToolCallerTest.php @@ -0,0 +1,628 @@ +referenceProvider = $this->createMock(ReferenceProviderInterface::class); + $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->toolCaller = new ToolCaller( + $this->referenceProvider, + $this->referenceHandler, + $this->logger, + ); + } + + public function testCallExecutesToolSuccessfully(): void + { + $request = new CallToolRequest('test_tool', ['param' => 'value']); + $tool = $this->createValidTool('test_tool'); + $toolReference = new ToolReference($tool, fn () => 'test result'); + $handlerResult = 'test result'; + + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('test_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, ['param' => 'value']) + ->willReturn($handlerResult); + + $this->logger + ->expects($this->exactly(2)) + ->method('debug') + ->with( + $this->logicalOr( + $this->equalTo('Executing tool'), + $this->equalTo('Tool executed successfully') + ), + $this->logicalOr( + $this->equalTo(['name' => 'test_tool', 'arguments' => ['param' => 'value']]), + $this->equalTo(['name' => 'test_tool', 'result_type' => 'string']) + ) + ); + + $result = $this->toolCaller->call($request); + + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertCount(1, $result->content); + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertEquals('test result', $result->content[0]->text); + $this->assertFalse($result->isError); + } + + public function testCallWithEmptyArguments(): void + { + $request = new CallToolRequest('test_tool', []); + $tool = $this->createValidTool('test_tool'); + $toolReference = new ToolReference($tool, fn () => 'result'); + + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('test_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, []) + ->willReturn('result'); + + $this->logger + ->expects($this->exactly(2)) + ->method('debug'); + + $result = $this->toolCaller->call($request); + + $this->assertInstanceOf(CallToolResult::class, $result); + } + + public function testCallWithComplexArguments(): void + { + $arguments = [ + 'string_param' => 'value', + 'int_param' => 42, + 'bool_param' => true, + 'array_param' => ['nested' => 'data'], + 'null_param' => null, + ]; + $request = new CallToolRequest('complex_tool', $arguments); + $tool = $this->createValidTool('complex_tool'); + $toolReference = new ToolReference($tool, fn () => ['processed' => true]); + + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('complex_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, $arguments) + ->willReturn(['processed' => true]); + + $result = $this->toolCaller->call($request); + + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertCount(1, $result->content); + } + + public function testCallThrowsToolNotFoundExceptionWhenToolNotFound(): void + { + $request = new CallToolRequest('nonexistent_tool', ['param' => 'value']); + + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('nonexistent_tool') + ->willReturn(null); + + $this->referenceHandler + ->expects($this->never()) + ->method('handle'); + + $this->logger + ->expects($this->once()) + ->method('debug') + ->with('Executing tool', ['name' => 'nonexistent_tool', 'arguments' => ['param' => 'value']]); + + $this->logger + ->expects($this->once()) + ->method('warning') + ->with('Tool not found', ['name' => 'nonexistent_tool']); + + $this->expectException(ToolNotFoundException::class); + $this->expectExceptionMessage('Tool not found for call: "nonexistent_tool".'); + + $this->toolCaller->call($request); + } + + public function testCallThrowsToolExecutionExceptionWhenHandlerThrowsException(): void + { + $request = new CallToolRequest('failing_tool', ['param' => 'value']); + $tool = $this->createValidTool('failing_tool'); + $toolReference = new ToolReference($tool, fn () => throw new \RuntimeException('Handler failed')); + $handlerException = new \RuntimeException('Handler failed'); + + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('failing_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, ['param' => 'value']) + ->willThrowException($handlerException); + + $this->logger + ->expects($this->once()) + ->method('debug') + ->with('Executing tool', ['name' => 'failing_tool', 'arguments' => ['param' => 'value']]); + + $this->logger + ->expects($this->once()) + ->method('error') + ->with( + 'Tool execution failed', + $this->callback(function ($context) { + return 'failing_tool' === $context['name'] + && 'Handler failed' === $context['exception'] + && isset($context['trace']); + }) + ); + + $this->expectException(ToolCallException::class); + $this->expectExceptionMessage('Tool call "failing_tool" failed with error: "Handler failed".'); + + $thrownException = null; + try { + $this->toolCaller->call($request); + } catch (ToolCallException $e) { + $thrownException = $e; + throw $e; + } finally { + if ($thrownException) { + $this->assertSame($request, $thrownException->request); + $this->assertSame($handlerException, $thrownException->getPrevious()); + } + } + } + + public function testCallHandlesNullResult(): void + { + $request = new CallToolRequest('null_tool', []); + $tool = $this->createValidTool('null_tool'); + $toolReference = new ToolReference($tool, fn () => null); + + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('null_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, []) + ->willReturn(null); + + $this->logger + ->expects($this->exactly(2)) + ->method('debug'); + + $result = $this->toolCaller->call($request); + + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertCount(1, $result->content); + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertEquals('(null)', $result->content[0]->text); + } + + public function testCallHandlesBooleanResults(): void + { + $request = new CallToolRequest('bool_tool', []); + $tool = $this->createValidTool('bool_tool'); + $toolReference = new ToolReference($tool, fn () => true); + + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('bool_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, []) + ->willReturn(true); + + $result = $this->toolCaller->call($request); + + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertCount(1, $result->content); + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertEquals('true', $result->content[0]->text); + } + + public function testCallHandlesArrayResults(): void + { + $request = new CallToolRequest('array_tool', []); + $tool = $this->createValidTool('array_tool'); + $toolReference = new ToolReference($tool, fn () => ['key' => 'value', 'number' => 42]); + $arrayResult = ['key' => 'value', 'number' => 42]; + + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('array_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, []) + ->willReturn($arrayResult); + + $result = $this->toolCaller->call($request); + + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertCount(1, $result->content); + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertJsonStringEqualsJsonString( + json_encode($arrayResult, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE), + $result->content[0]->text + ); + } + + public function testCallHandlesContentObjectResults(): void + { + $request = new CallToolRequest('content_tool', []); + $tool = $this->createValidTool('content_tool'); + $toolReference = new ToolReference($tool, fn () => new TextContent('Direct content')); + $contentResult = new TextContent('Direct content'); + + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('content_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, []) + ->willReturn($contentResult); + + $result = $this->toolCaller->call($request); + + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertCount(1, $result->content); + $this->assertSame($contentResult, $result->content[0]); + } + + public function testCallHandlesArrayOfContentResults(): void + { + $request = new CallToolRequest('content_array_tool', []); + $tool = $this->createValidTool('content_array_tool'); + $toolReference = new ToolReference($tool, fn () => [ + new TextContent('First content'), + new TextContent('Second content'), + ]); + $contentArray = [ + new TextContent('First content'), + new TextContent('Second content'), + ]; + + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('content_array_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, []) + ->willReturn($contentArray); + + $result = $this->toolCaller->call($request); + + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertCount(2, $result->content); + $this->assertSame($contentArray[0], $result->content[0]); + $this->assertSame($contentArray[1], $result->content[1]); + } + + public function testCallWithDifferentExceptionTypes(): void + { + $request = new CallToolRequest('error_tool', []); + $tool = $this->createValidTool('error_tool'); + $toolReference = new ToolReference($tool, fn () => throw new \InvalidArgumentException('Invalid input')); + $handlerException = new \InvalidArgumentException('Invalid input'); + + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('error_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, []) + ->willThrowException($handlerException); + + $this->logger + ->expects($this->once()) + ->method('error') + ->with( + 'Tool execution failed', + $this->callback(function ($context) { + return 'error_tool' === $context['name'] + && 'Invalid input' === $context['exception'] + && isset($context['trace']); + }) + ); + + $this->expectException(ToolCallException::class); + $this->expectExceptionMessage('Tool call "error_tool" failed with error: "Invalid input".'); + + $this->toolCaller->call($request); + } + + public function testCallLogsResultTypeCorrectlyForString(): void + { + $request = new CallToolRequest('string_tool', []); + $tool = $this->createValidTool('string_tool'); + $toolReference = new ToolReference($tool, fn () => 'string result'); + + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('string_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, []) + ->willReturn('string result'); + + $this->logger + ->expects($this->exactly(2)) + ->method('debug'); + + $result = $this->toolCaller->call($request); + + $this->assertInstanceOf(CallToolResult::class, $result); + } + + public function testCallLogsResultTypeCorrectlyForInteger(): void + { + $request = new CallToolRequest('int_tool', []); + $tool = $this->createValidTool('int_tool'); + $toolReference = new ToolReference($tool, fn () => 42); + + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('int_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, []) + ->willReturn(42); + + $this->logger + ->expects($this->exactly(2)) + ->method('debug'); + + $result = $this->toolCaller->call($request); + + $this->assertInstanceOf(CallToolResult::class, $result); + } + + public function testCallLogsResultTypeCorrectlyForArray(): void + { + $request = new CallToolRequest('array_tool', []); + $tool = $this->createValidTool('array_tool'); + $toolReference = new ToolReference($tool, fn () => ['test']); + + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('array_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, []) + ->willReturn(['test']); + + $this->logger + ->expects($this->exactly(2)) + ->method('debug'); + + $result = $this->toolCaller->call($request); + + $this->assertInstanceOf(CallToolResult::class, $result); + } + + public function testConstructorWithDefaultLogger(): void + { + $executor = new ToolCaller($this->referenceProvider, $this->referenceHandler); + + // Verify it's constructed without throwing exceptions + $this->assertInstanceOf(ToolCaller::class, $executor); + } + + public function testCallHandlesEmptyArrayResult(): void + { + $request = new CallToolRequest('empty_array_tool', []); + $tool = $this->createValidTool('empty_array_tool'); + $toolReference = new ToolReference($tool, fn () => []); + + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('empty_array_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, []) + ->willReturn([]); + + $result = $this->toolCaller->call($request); + + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertCount(1, $result->content); + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertEquals('[]', $result->content[0]->text); + } + + public function testCallHandlesMixedContentAndNonContentArray(): void + { + $request = new CallToolRequest('mixed_tool', []); + $tool = $this->createValidTool('mixed_tool'); + $mixedResult = [ + new TextContent('First content'), + 'plain string', + 42, + new TextContent('Second content'), + ]; + $toolReference = new ToolReference($tool, fn () => $mixedResult); + + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('mixed_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, []) + ->willReturn($mixedResult); + + $result = $this->toolCaller->call($request); + + $this->assertInstanceOf(CallToolResult::class, $result); + // The ToolReference.formatResult should handle this mixed array + $this->assertGreaterThan(1, \count($result->content)); + } + + public function testCallHandlesStdClassResult(): void + { + $request = new CallToolRequest('object_tool', []); + $tool = $this->createValidTool('object_tool'); + $objectResult = new \stdClass(); + $objectResult->property = 'value'; + $toolReference = new ToolReference($tool, fn () => $objectResult); + + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('object_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, []) + ->willReturn($objectResult); + + $result = $this->toolCaller->call($request); + + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertCount(1, $result->content); + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertStringContainsString('"property": "value"', $result->content[0]->text); + } + + public function testCallHandlesBooleanFalseResult(): void + { + $request = new CallToolRequest('false_tool', []); + $tool = $this->createValidTool('false_tool'); + $toolReference = new ToolReference($tool, fn () => false); + + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('false_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, []) + ->willReturn(false); + + $result = $this->toolCaller->call($request); + + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertCount(1, $result->content); + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertEquals('false', $result->content[0]->text); + } + + private function createValidTool(string $name): Tool + { + return new Tool( + name: $name, + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'param' => ['type' => 'string'], + ], + 'required' => null, + ], + description: "Test tool: {$name}", + annotations: null, + ); + } +} diff --git a/tests/Schema/ServerCapabilitiesTest.php b/tests/Schema/ServerCapabilitiesTest.php new file mode 100644 index 0000000..3a9f2b9 --- /dev/null +++ b/tests/Schema/ServerCapabilitiesTest.php @@ -0,0 +1,406 @@ +assertTrue($capabilities->tools); + $this->assertFalse($capabilities->toolsListChanged); + $this->assertTrue($capabilities->resources); + $this->assertFalse($capabilities->resourcesSubscribe); + $this->assertFalse($capabilities->resourcesListChanged); + $this->assertTrue($capabilities->prompts); + $this->assertFalse($capabilities->promptsListChanged); + $this->assertFalse($capabilities->logging); + $this->assertFalse($capabilities->completions); + $this->assertNull($capabilities->experimental); + } + + public function testConstructorWithAllParameters(): void + { + $experimental = ['feature1' => true, 'feature2' => 'enabled']; + + $capabilities = new ServerCapabilities( + tools: false, + toolsListChanged: true, + resources: false, + resourcesSubscribe: true, + resourcesListChanged: true, + prompts: false, + promptsListChanged: true, + logging: true, + completions: true, + experimental: $experimental + ); + + $this->assertFalse($capabilities->tools); + $this->assertTrue($capabilities->toolsListChanged); + $this->assertFalse($capabilities->resources); + $this->assertTrue($capabilities->resourcesSubscribe); + $this->assertTrue($capabilities->resourcesListChanged); + $this->assertFalse($capabilities->prompts); + $this->assertTrue($capabilities->promptsListChanged); + $this->assertTrue($capabilities->logging); + $this->assertTrue($capabilities->completions); + $this->assertEquals($experimental, $capabilities->experimental); + } + + public function testConstructorWithNullValues(): void + { + $capabilities = new ServerCapabilities( + tools: null, + toolsListChanged: null, + resources: null, + resourcesSubscribe: null, + resourcesListChanged: null, + prompts: null, + promptsListChanged: null, + logging: null, + completions: null, + experimental: null + ); + + $this->assertNull($capabilities->tools); + $this->assertNull($capabilities->toolsListChanged); + $this->assertNull($capabilities->resources); + $this->assertNull($capabilities->resourcesSubscribe); + $this->assertNull($capabilities->resourcesListChanged); + $this->assertNull($capabilities->prompts); + $this->assertNull($capabilities->promptsListChanged); + $this->assertNull($capabilities->logging); + $this->assertNull($capabilities->completions); + $this->assertNull($capabilities->experimental); + } + + public function testFromArrayWithEmptyArray(): void + { + $capabilities = ServerCapabilities::fromArray([]); + + $this->assertFalse($capabilities->logging); + $this->assertFalse($capabilities->completions); + $this->assertFalse($capabilities->tools); + $this->assertFalse($capabilities->prompts); + $this->assertFalse($capabilities->resources); + $this->assertNull($capabilities->toolsListChanged); + $this->assertNull($capabilities->promptsListChanged); + $this->assertNull($capabilities->resourcesSubscribe); + $this->assertNull($capabilities->resourcesListChanged); + $this->assertNull($capabilities->experimental); + } + + public function testFromArrayWithBasicCapabilities(): void + { + $data = [ + 'tools' => new \stdClass(), + 'resources' => new \stdClass(), + 'prompts' => new \stdClass(), + 'logging' => new \stdClass(), + 'completions' => new \stdClass(), + ]; + + $capabilities = ServerCapabilities::fromArray($data); + + $this->assertTrue($capabilities->tools); + $this->assertTrue($capabilities->resources); + $this->assertTrue($capabilities->prompts); + $this->assertTrue($capabilities->logging); + $this->assertTrue($capabilities->completions); + $this->assertNull($capabilities->toolsListChanged); + $this->assertNull($capabilities->promptsListChanged); + $this->assertNull($capabilities->resourcesSubscribe); + $this->assertNull($capabilities->resourcesListChanged); + } + + public function testFromArrayWithPromptsArrayListChanged(): void + { + $data = [ + 'prompts' => ['listChanged' => true], + ]; + + $capabilities = ServerCapabilities::fromArray($data); + + $this->assertTrue($capabilities->prompts); + $this->assertTrue($capabilities->promptsListChanged); + } + + public function testFromArrayWithPromptsObjectListChanged(): void + { + $prompts = new \stdClass(); + $prompts->listChanged = true; + + $data = [ + 'prompts' => $prompts, + ]; + + $capabilities = ServerCapabilities::fromArray($data); + + $this->assertTrue($capabilities->prompts); + $this->assertTrue($capabilities->promptsListChanged); + } + + public function testFromArrayWithResourcesArraySubscribeAndListChanged(): void + { + $data = [ + 'resources' => [ + 'subscribe' => true, + 'listChanged' => false, + ], + ]; + + $capabilities = ServerCapabilities::fromArray($data); + + $this->assertTrue($capabilities->resources); + $this->assertTrue($capabilities->resourcesSubscribe); + $this->assertFalse($capabilities->resourcesListChanged); + } + + public function testFromArrayWithResourcesObjectSubscribeAndListChanged(): void + { + $resources = new \stdClass(); + $resources->subscribe = false; + $resources->listChanged = true; + + $data = [ + 'resources' => $resources, + ]; + + $capabilities = ServerCapabilities::fromArray($data); + + $this->assertTrue($capabilities->resources); + $this->assertFalse($capabilities->resourcesSubscribe); + $this->assertTrue($capabilities->resourcesListChanged); + } + + public function testFromArrayWithToolsArrayListChanged(): void + { + $data = [ + 'tools' => ['listChanged' => false], + ]; + + $capabilities = ServerCapabilities::fromArray($data); + + $this->assertTrue($capabilities->tools); + $this->assertFalse($capabilities->toolsListChanged); + } + + public function testFromArrayWithToolsObjectListChanged(): void + { + $tools = new \stdClass(); + $tools->listChanged = true; + + $data = [ + 'tools' => $tools, + ]; + + $capabilities = ServerCapabilities::fromArray($data); + + $this->assertTrue($capabilities->tools); + $this->assertTrue($capabilities->toolsListChanged); + } + + public function testFromArrayWithExperimental(): void + { + $experimental = ['feature1' => true, 'feature2' => 'test']; + $data = [ + 'experimental' => $experimental, + ]; + + $capabilities = ServerCapabilities::fromArray($data); + + $this->assertEquals($experimental, $capabilities->experimental); + } + + public function testFromArrayWithComplexData(): void + { + $data = [ + 'tools' => ['listChanged' => true], + 'resources' => [ + 'subscribe' => true, + 'listChanged' => false, + ], + 'prompts' => ['listChanged' => true], + 'logging' => new \stdClass(), + 'completions' => new \stdClass(), + 'experimental' => ['customFeature' => 'enabled'], + ]; + + $capabilities = ServerCapabilities::fromArray($data); + + $this->assertTrue($capabilities->tools); + $this->assertTrue($capabilities->toolsListChanged); + $this->assertTrue($capabilities->resources); + $this->assertTrue($capabilities->resourcesSubscribe); + $this->assertFalse($capabilities->resourcesListChanged); + $this->assertTrue($capabilities->prompts); + $this->assertTrue($capabilities->promptsListChanged); + $this->assertTrue($capabilities->logging); + $this->assertTrue($capabilities->completions); + $this->assertEquals(['customFeature' => 'enabled'], $capabilities->experimental); + } + + public function testJsonSerializeWithDefaults(): void + { + $capabilities = new ServerCapabilities(); + $json = $capabilities->jsonSerialize(); + + $expected = [ + 'tools' => new \stdClass(), + 'resources' => new \stdClass(), + 'prompts' => new \stdClass(), + ]; + + $this->assertEquals($expected, $json); + } + + public function testJsonSerializeWithAllFeaturesEnabled(): void + { + $experimental = ['feature1' => true]; + $capabilities = new ServerCapabilities( + tools: true, + toolsListChanged: true, + resources: true, + resourcesSubscribe: true, + resourcesListChanged: true, + prompts: true, + promptsListChanged: true, + logging: true, + completions: true, + experimental: $experimental + ); + + $json = $capabilities->jsonSerialize(); + + $this->assertArrayHasKey('logging', $json); + $this->assertEquals(new \stdClass(), $json['logging']); + + $this->assertArrayHasKey('completions', $json); + $this->assertEquals(new \stdClass(), $json['completions']); + + $this->assertArrayHasKey('prompts', $json); + $this->assertTrue($json['prompts']->listChanged); + + $this->assertArrayHasKey('resources', $json); + $this->assertTrue($json['resources']->subscribe); + $this->assertTrue($json['resources']->listChanged); + + $this->assertArrayHasKey('tools', $json); + $this->assertTrue($json['tools']->listChanged); + + $this->assertArrayHasKey('experimental', $json); + $this->assertEquals((object) $experimental, $json['experimental']); + } + + public function testJsonSerializeWithFalseValues(): void + { + $capabilities = new ServerCapabilities( + tools: false, + resources: false, + prompts: false, + logging: false, + completions: false + ); + + $json = $capabilities->jsonSerialize(); + + $this->assertEquals([], $json); + } + + public function testJsonSerializeWithMixedValues(): void + { + $capabilities = new ServerCapabilities( + tools: true, + toolsListChanged: false, + resources: false, + resourcesSubscribe: true, + resourcesListChanged: true, + prompts: true, + promptsListChanged: false, + logging: false, + completions: true + ); + + $json = $capabilities->jsonSerialize(); + + $expected = [ + 'completions' => new \stdClass(), + 'prompts' => new \stdClass(), + 'resources' => (object) [ + 'subscribe' => true, + 'listChanged' => true, + ], + 'tools' => new \stdClass(), + ]; + + $this->assertEquals($expected, $json); + } + + public function testJsonSerializeWithOnlyListChangedFlags(): void + { + $capabilities = new ServerCapabilities( + tools: false, + toolsListChanged: true, + resources: false, + resourcesListChanged: true, + prompts: false, + promptsListChanged: true + ); + + $json = $capabilities->jsonSerialize(); + + $expected = [ + 'prompts' => (object) ['listChanged' => true], + 'resources' => (object) ['listChanged' => true], + 'tools' => (object) ['listChanged' => true], + ]; + + $this->assertEquals($expected, $json); + } + + public function testJsonSerializeWithNullExperimental(): void + { + $capabilities = new ServerCapabilities( + tools: true, + experimental: null + ); + + $json = $capabilities->jsonSerialize(); + + $this->assertArrayNotHasKey('experimental', $json); + $this->assertArrayHasKey('tools', $json); + } + + public function testFromArrayHandlesEdgeCasesGracefully(): void + { + $data = [ + 'prompts' => [], + 'resources' => [], + 'tools' => [], + ]; + + $capabilities = ServerCapabilities::fromArray($data); + + $this->assertTrue($capabilities->prompts); + $this->assertNull($capabilities->promptsListChanged); + $this->assertTrue($capabilities->resources); + $this->assertNull($capabilities->resourcesSubscribe); + $this->assertNull($capabilities->resourcesListChanged); + $this->assertTrue($capabilities->tools); + $this->assertNull($capabilities->toolsListChanged); + } +} diff --git a/tests/Server/RequestHandler/CallToolHandlerTest.php b/tests/Server/RequestHandler/CallToolHandlerTest.php new file mode 100644 index 0000000..e8f1362 --- /dev/null +++ b/tests/Server/RequestHandler/CallToolHandlerTest.php @@ -0,0 +1,294 @@ +toolExecutor = $this->createMock(ToolCallerInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->handler = new CallToolHandler( + $this->toolExecutor, + $this->logger, + ); + } + + public function testSupportsCallToolRequest(): void + { + $request = $this->createCallToolRequest('test_tool', ['param' => 'value']); + + $this->assertTrue($this->handler->supports($request)); + } + + public function testHandleSuccessfulToolCall(): void + { + $request = $this->createCallToolRequest('greet_user', ['name' => 'John']); + $expectedResult = new CallToolResult([new TextContent('Hello, John!')]); + + $this->toolExecutor + ->expects($this->once()) + ->method('call') + ->with($request) + ->willReturn($expectedResult); + + $this->logger + ->expects($this->never()) + ->method('error'); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals($request->getId(), $response->id); + $this->assertSame($expectedResult, $response->result); + } + + public function testHandleToolCallWithEmptyArguments(): void + { + $request = $this->createCallToolRequest('simple_tool', []); + $expectedResult = new CallToolResult([new TextContent('Simple result')]); + + $this->toolExecutor + ->expects($this->once()) + ->method('call') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + } + + public function testHandleToolCallWithComplexArguments(): void + { + $arguments = [ + 'string_param' => 'value', + 'int_param' => 42, + 'bool_param' => true, + 'array_param' => ['nested' => 'data'], + 'null_param' => null, + ]; + $request = $this->createCallToolRequest('complex_tool', $arguments); + $expectedResult = new CallToolResult([new TextContent('Complex result')]); + + $this->toolExecutor + ->expects($this->once()) + ->method('call') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + } + + public function testHandleToolNotFoundExceptionReturnsError(): void + { + $request = $this->createCallToolRequest('nonexistent_tool', ['param' => 'value']); + $exception = new ToolNotFoundException($request); + + $this->toolExecutor + ->expects($this->once()) + ->method('call') + ->with($request) + ->willThrowException($exception); + + $this->logger + ->expects($this->once()) + ->method('error') + ->with( + 'Error while executing tool "nonexistent_tool": "Tool not found for call: "nonexistent_tool".".', + [ + 'tool' => 'nonexistent_tool', + 'arguments' => ['param' => 'value'], + ], + ); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Error::class, $response); + $this->assertEquals($request->getId(), $response->id); + $this->assertEquals(Error::INTERNAL_ERROR, $response->code); + $this->assertEquals('Error while executing tool', $response->message); + } + + public function testHandleToolExecutionExceptionReturnsError(): void + { + $request = $this->createCallToolRequest('failing_tool', ['param' => 'value']); + $exception = new ToolCallException($request, new \RuntimeException('Tool execution failed')); + + $this->toolExecutor + ->expects($this->once()) + ->method('call') + ->with($request) + ->willThrowException($exception); + + $this->logger + ->expects($this->once()) + ->method('error') + ->with( + 'Error while executing tool "failing_tool": "Tool call "failing_tool" failed with error: "Tool execution failed".".', + [ + 'tool' => 'failing_tool', + 'arguments' => ['param' => 'value'], + ], + ); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Error::class, $response); + $this->assertEquals($request->getId(), $response->id); + $this->assertEquals(Error::INTERNAL_ERROR, $response->code); + $this->assertEquals('Error while executing tool', $response->message); + } + + public function testHandleWithNullResult(): void + { + $request = $this->createCallToolRequest('null_tool', []); + $expectedResult = new CallToolResult([]); + + $this->toolExecutor + ->expects($this->once()) + ->method('call') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + } + + public function testHandleWithErrorResult(): void + { + $request = $this->createCallToolRequest('error_tool', []); + $expectedResult = CallToolResult::error([new TextContent('Tool error occurred')]); + + $this->toolExecutor + ->expects($this->once()) + ->method('call') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + $this->assertTrue($response->result->isError); + } + + public function testConstructorWithDefaultLogger(): void + { + $handler = new CallToolHandler($this->toolExecutor); + + $this->assertInstanceOf(CallToolHandler::class, $handler); + } + + public function testHandleLogsErrorWithCorrectParameters(): void + { + $request = $this->createCallToolRequest('test_tool', ['key1' => 'value1', 'key2' => 42]); + $exception = new ToolCallException($request, new \RuntimeException('Custom error message')); + + $this->toolExecutor + ->expects($this->once()) + ->method('call') + ->willThrowException($exception); + + $this->logger + ->expects($this->once()) + ->method('error') + ->with( + 'Error while executing tool "test_tool": "Tool call "test_tool" failed with error: "Custom error message".".', + [ + 'tool' => 'test_tool', + 'arguments' => ['key1' => 'value1', 'key2' => 42], + ], + ); + + $this->handler->handle($request); + } + + public function testHandleWithSpecialCharactersInToolName(): void + { + $request = $this->createCallToolRequest('tool-with_special.chars', []); + $expectedResult = new CallToolResult([new TextContent('Special tool result')]); + + $this->toolExecutor + ->expects($this->once()) + ->method('call') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + } + + public function testHandleWithSpecialCharactersInArguments(): void + { + $arguments = [ + 'special_chars' => 'äöü ñ 中文 🚀', + 'unicode' => '\\u{1F600}', + 'quotes' => 'text with "quotes" and \'single quotes\'', + ]; + $request = $this->createCallToolRequest('unicode_tool', $arguments); + $expectedResult = new CallToolResult([new TextContent('Unicode handled')]); + + $this->toolExecutor + ->expects($this->once()) + ->method('call') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + } + + /** + * @param array $arguments + */ + private function createCallToolRequest(string $name, array $arguments): CallToolRequest + { + return CallToolRequest::fromArray([ + 'jsonrpc' => '2.0', + 'method' => CallToolRequest::getMethod(), + 'id' => 'test-request-'.uniqid(), + 'params' => [ + 'name' => $name, + 'arguments' => $arguments, + ], + ]); + } +} diff --git a/tests/Server/RequestHandler/GetPromptHandlerTest.php b/tests/Server/RequestHandler/GetPromptHandlerTest.php new file mode 100644 index 0000000..3debaa0 --- /dev/null +++ b/tests/Server/RequestHandler/GetPromptHandlerTest.php @@ -0,0 +1,342 @@ +promptGetter = $this->createMock(PromptGetterInterface::class); + + $this->handler = new GetPromptHandler($this->promptGetter); + } + + public function testSupportsGetPromptRequest(): void + { + $request = $this->createGetPromptRequest('test_prompt'); + + $this->assertTrue($this->handler->supports($request)); + } + + public function testHandleSuccessfulPromptGet(): void + { + $request = $this->createGetPromptRequest('greeting_prompt'); + $expectedMessages = [ + new PromptMessage(Role::User, new TextContent('Hello, how can I help you?')), + ]; + $expectedResult = new GetPromptResult( + description: 'A greeting prompt', + messages: $expectedMessages, + ); + + $this->promptGetter + ->expects($this->once()) + ->method('get') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals($request->getId(), $response->id); + $this->assertSame($expectedResult, $response->result); + } + + public function testHandlePromptGetWithArguments(): void + { + $arguments = [ + 'name' => 'John', + 'context' => 'business meeting', + 'formality' => 'formal', + ]; + $request = $this->createGetPromptRequest('personalized_prompt', $arguments); + $expectedMessages = [ + new PromptMessage( + Role::User, + new TextContent('Good morning, John. How may I assist you in your business meeting?'), + ), + ]; + $expectedResult = new GetPromptResult( + description: 'A personalized greeting prompt', + messages: $expectedMessages, + ); + + $this->promptGetter + ->expects($this->once()) + ->method('get') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + } + + public function testHandlePromptGetWithNullArguments(): void + { + $request = $this->createGetPromptRequest('simple_prompt', null); + $expectedMessages = [ + new PromptMessage(Role::Assistant, new TextContent('I am ready to help.')), + ]; + $expectedResult = new GetPromptResult( + description: 'A simple prompt', + messages: $expectedMessages, + ); + + $this->promptGetter + ->expects($this->once()) + ->method('get') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + } + + public function testHandlePromptGetWithEmptyArguments(): void + { + $request = $this->createGetPromptRequest('empty_args_prompt', []); + $expectedMessages = [ + new PromptMessage(Role::User, new TextContent('Default message')), + ]; + $expectedResult = new GetPromptResult( + description: 'A prompt with empty arguments', + messages: $expectedMessages, + ); + + $this->promptGetter + ->expects($this->once()) + ->method('get') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + } + + public function testHandlePromptGetWithMultipleMessages(): void + { + $request = $this->createGetPromptRequest('conversation_prompt'); + $expectedMessages = [ + new PromptMessage(Role::User, new TextContent('Hello')), + new PromptMessage(Role::Assistant, new TextContent('Hi there! How can I help you today?')), + new PromptMessage(Role::User, new TextContent('I need assistance with my project')), + ]; + $expectedResult = new GetPromptResult( + description: 'A conversation prompt', + messages: $expectedMessages, + ); + + $this->promptGetter + ->expects($this->once()) + ->method('get') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + $this->assertCount(3, $response->result->messages); + } + + public function testHandlePromptNotFoundExceptionReturnsError(): void + { + $request = $this->createGetPromptRequest('nonexistent_prompt'); + $exception = new PromptNotFoundException($request); + + $this->promptGetter + ->expects($this->once()) + ->method('get') + ->with($request) + ->willThrowException($exception); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Error::class, $response); + $this->assertEquals($request->getId(), $response->id); + $this->assertEquals(Error::INTERNAL_ERROR, $response->code); + $this->assertEquals('Error while handling prompt', $response->message); + } + + public function testHandlePromptGetExceptionReturnsError(): void + { + $request = $this->createGetPromptRequest('failing_prompt'); + $exception = new PromptGetException($request, new \RuntimeException('Failed to get prompt')); + + $this->promptGetter + ->expects($this->once()) + ->method('get') + ->with($request) + ->willThrowException($exception); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Error::class, $response); + $this->assertEquals($request->getId(), $response->id); + $this->assertEquals(Error::INTERNAL_ERROR, $response->code); + $this->assertEquals('Error while handling prompt', $response->message); + } + + public function testHandlePromptGetWithComplexArguments(): void + { + $arguments = [ + 'user_data' => [ + 'name' => 'Alice', + 'preferences' => ['formal', 'concise'], + 'history' => [ + 'last_interaction' => '2025-01-15', + 'topics' => ['technology', 'business'], + ], + ], + 'context' => 'technical consultation', + 'metadata' => [ + 'session_id' => 'sess_123456', + 'timestamp' => 1705392000, + ], + ]; + $request = $this->createGetPromptRequest('complex_prompt', $arguments); + $expectedMessages = [ + new PromptMessage(Role::User, new TextContent('Complex prompt generated with all parameters')), + ]; + $expectedResult = new GetPromptResult( + description: 'A complex prompt with nested arguments', + messages: $expectedMessages, + ); + + $this->promptGetter + ->expects($this->once()) + ->method('get') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + } + + public function testHandlePromptGetWithSpecialCharacters(): void + { + $arguments = [ + 'message' => 'Hello 世界! How are you? 😊', + 'special' => 'äöü ñ ß', + 'quotes' => 'Text with "double" and \'single\' quotes', + ]; + $request = $this->createGetPromptRequest('unicode_prompt', $arguments); + $expectedMessages = [ + new PromptMessage(Role::User, new TextContent('Unicode message processed')), + ]; + $expectedResult = new GetPromptResult( + description: 'A prompt handling unicode characters', + messages: $expectedMessages, + ); + + $this->promptGetter + ->expects($this->once()) + ->method('get') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + } + + public function testHandlePromptGetReturnsEmptyMessages(): void + { + $request = $this->createGetPromptRequest('empty_prompt'); + $expectedResult = new GetPromptResult( + description: 'An empty prompt', + messages: [], + ); + + $this->promptGetter + ->expects($this->once()) + ->method('get') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + $this->assertCount(0, $response->result->messages); + } + + public function testHandlePromptGetWithLargeNumberOfArguments(): void + { + $arguments = []; + for ($i = 0; $i < 100; ++$i) { + $arguments["arg_{$i}"] = "value_{$i}"; + } + + $request = $this->createGetPromptRequest('many_args_prompt', $arguments); + $expectedMessages = [ + new PromptMessage(Role::User, new TextContent('Processed 100 arguments')), + ]; + $expectedResult = new GetPromptResult( + description: 'A prompt with many arguments', + messages: $expectedMessages, + ); + + $this->promptGetter + ->expects($this->once()) + ->method('get') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + } + + /** + * @param array|null $arguments + */ + private function createGetPromptRequest(string $name, ?array $arguments = null): GetPromptRequest + { + return GetPromptRequest::fromArray([ + 'jsonrpc' => '2.0', + 'method' => GetPromptRequest::getMethod(), + 'id' => 'test-request-'.uniqid(), + 'params' => [ + 'name' => $name, + 'arguments' => $arguments, + ], + ]); + } +} diff --git a/tests/Server/RequestHandler/PingHandlerTest.php b/tests/Server/RequestHandler/PingHandlerTest.php new file mode 100644 index 0000000..0904d68 --- /dev/null +++ b/tests/Server/RequestHandler/PingHandlerTest.php @@ -0,0 +1,147 @@ +handler = new PingHandler(); + } + + public function testSupportsPingRequest(): void + { + $request = $this->createPingRequest(); + + $this->assertTrue($this->handler->supports($request)); + } + + public function testHandlePingRequest(): void + { + $request = $this->createPingRequest(); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals($request->getId(), $response->id); + $this->assertInstanceOf(EmptyResult::class, $response->result); + } + + public function testHandleMultiplePingRequests(): void + { + $request1 = $this->createPingRequest(); + $request2 = $this->createPingRequest(); + + $response1 = $this->handler->handle($request1); + $response2 = $this->handler->handle($request2); + + $this->assertInstanceOf(Response::class, $response1); + $this->assertInstanceOf(Response::class, $response2); + $this->assertInstanceOf(EmptyResult::class, $response1->result); + $this->assertInstanceOf(EmptyResult::class, $response2->result); + $this->assertEquals($request1->getId(), $response1->id); + $this->assertEquals($request2->getId(), $response2->id); + } + + public function testHandlerHasNoSideEffects(): void + { + $request = $this->createPingRequest(); + + // Handle same request multiple times + $response1 = $this->handler->handle($request); + $response2 = $this->handler->handle($request); + + // Both responses should be identical + $this->assertEquals($response1->id, $response2->id); + $this->assertEquals( + \get_class($response1->result), + \get_class($response2->result), + ); + } + + public function testEmptyResultIsCorrectType(): void + { + $request = $this->createPingRequest(); + $response = $this->handler->handle($request); + + $this->assertInstanceOf(EmptyResult::class, $response->result); + + // Verify EmptyResult serializes to empty object + $serialized = json_encode($response->result); + $this->assertEquals('{}', $serialized); + } + + public function testHandlerIsStateless(): void + { + $handler1 = new PingHandler(); + $handler2 = new PingHandler(); + + $request = $this->createPingRequest(); + + $response1 = $handler1->handle($request); + $response2 = $handler2->handle($request); + + // Both handlers should produce equivalent results + $this->assertEquals($response1->id, $response2->id); + $this->assertEquals( + \get_class($response1->result), + \get_class($response2->result), + ); + } + + public function testSupportsMethodIsConsistent(): void + { + $request = $this->createPingRequest(); + + // Multiple calls to supports should return same result + $this->assertTrue($this->handler->supports($request)); + $this->assertTrue($this->handler->supports($request)); + $this->assertTrue($this->handler->supports($request)); + } + + public function testHandlerCanBeReused(): void + { + $requests = []; + $responses = []; + + // Create multiple ping requests + for ($i = 0; $i < 5; ++$i) { + $requests[$i] = $this->createPingRequest(); + $responses[$i] = $this->handler->handle($requests[$i]); + } + + // All responses should be valid + foreach ($responses as $i => $response) { + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals($requests[$i]->getId(), $response->id); + $this->assertInstanceOf(EmptyResult::class, $response->result); + } + } + + private function createPingRequest(): Request + { + return PingRequest::fromArray([ + 'jsonrpc' => '2.0', + 'method' => PingRequest::getMethod(), + 'id' => 'test-request-'.uniqid(), + ]); + } +} diff --git a/tests/Server/RequestHandler/ReadResourceHandlerTest.php b/tests/Server/RequestHandler/ReadResourceHandlerTest.php new file mode 100644 index 0000000..6cb8acc --- /dev/null +++ b/tests/Server/RequestHandler/ReadResourceHandlerTest.php @@ -0,0 +1,351 @@ +resourceReader = $this->createMock(ResourceReaderInterface::class); + + $this->handler = new ReadResourceHandler($this->resourceReader); + } + + public function testSupportsReadResourceRequest(): void + { + $request = $this->createReadResourceRequest('file://test.txt'); + + $this->assertTrue($this->handler->supports($request)); + } + + public function testHandleSuccessfulResourceRead(): void + { + $uri = 'file://documents/readme.txt'; + $request = $this->createReadResourceRequest($uri); + $expectedContent = new TextResourceContents( + uri: $uri, + mimeType: 'text/plain', + text: 'This is the content of the readme file.', + ); + $expectedResult = new ReadResourceResult([$expectedContent]); + + $this->resourceReader + ->expects($this->once()) + ->method('read') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals($request->getId(), $response->id); + $this->assertSame($expectedResult, $response->result); + } + + public function testHandleResourceReadWithBlobContent(): void + { + $uri = 'file://images/logo.png'; + $request = $this->createReadResourceRequest($uri); + $expectedContent = new BlobResourceContents( + uri: $uri, + mimeType: 'image/png', + blob: base64_encode('fake-image-data'), + ); + $expectedResult = new ReadResourceResult([$expectedContent]); + + $this->resourceReader + ->expects($this->once()) + ->method('read') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + } + + public function testHandleResourceReadWithMultipleContents(): void + { + $uri = 'app://data/mixed-content'; + $request = $this->createReadResourceRequest($uri); + $textContent = new TextResourceContents( + uri: $uri, + mimeType: 'text/plain', + text: 'Text part of the resource', + ); + $blobContent = new BlobResourceContents( + uri: $uri, + mimeType: 'application/octet-stream', + blob: base64_encode('binary-data'), + ); + $expectedResult = new ReadResourceResult([$textContent, $blobContent]); + + $this->resourceReader + ->expects($this->once()) + ->method('read') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + $this->assertCount(2, $response->result->contents); + } + + public function testHandleResourceNotFoundExceptionReturnsSpecificError(): void + { + $uri = 'file://nonexistent/file.txt'; + $request = $this->createReadResourceRequest($uri); + $exception = new ResourceNotFoundException($request); + + $this->resourceReader + ->expects($this->once()) + ->method('read') + ->with($request) + ->willThrowException($exception); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Error::class, $response); + $this->assertEquals($request->getId(), $response->id); + $this->assertEquals(Error::RESOURCE_NOT_FOUND, $response->code); + $this->assertEquals('Resource not found for uri: "'.$uri.'".', $response->message); + } + + public function testHandleResourceReadExceptionReturnsGenericError(): void + { + $uri = 'file://corrupted/file.txt'; + $request = $this->createReadResourceRequest($uri); + $exception = new ResourceReadException( + $request, + new \RuntimeException('Failed to read resource: corrupted data'), + ); + + $this->resourceReader + ->expects($this->once()) + ->method('read') + ->with($request) + ->willThrowException($exception); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Error::class, $response); + $this->assertEquals($request->getId(), $response->id); + $this->assertEquals(Error::INTERNAL_ERROR, $response->code); + $this->assertEquals('Error while reading resource', $response->message); + } + + public function testHandleResourceReadWithDifferentUriSchemes(): void + { + $uriSchemes = [ + 'file://local/path/file.txt', + 'http://example.com/resource', + 'https://secure.example.com/api/data', + 'ftp://files.example.com/document.pdf', + 'app://internal/resource/123', + 'custom-scheme://special/resource', + ]; + + foreach ($uriSchemes as $uri) { + $request = $this->createReadResourceRequest($uri); + $expectedContent = new TextResourceContents( + uri: $uri, + mimeType: 'text/plain', + text: "Content for {$uri}", + ); + $expectedResult = new ReadResourceResult([$expectedContent]); + + $this->resourceReader + ->expects($this->once()) + ->method('read') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + + // Reset the mock for next iteration + $this->resourceReader = $this->createMock(ResourceReaderInterface::class); + $this->handler = new ReadResourceHandler($this->resourceReader); + } + } + + public function testHandleResourceReadWithSpecialCharactersInUri(): void + { + $uri = 'file://path/with spaces/äöü-file-ñ.txt'; + $request = $this->createReadResourceRequest($uri); + $expectedContent = new TextResourceContents( + uri: $uri, + mimeType: 'text/plain', + text: 'Content with unicode characters: äöü ñ 世界 🚀', + ); + $expectedResult = new ReadResourceResult([$expectedContent]); + + $this->resourceReader + ->expects($this->once()) + ->method('read') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + } + + public function testHandleResourceReadWithEmptyContent(): void + { + $uri = 'file://empty/file.txt'; + $request = $this->createReadResourceRequest($uri); + $expectedContent = new TextResourceContents( + uri: $uri, + mimeType: 'text/plain', + text: '', + ); + $expectedResult = new ReadResourceResult([$expectedContent]); + + $this->resourceReader + ->expects($this->once()) + ->method('read') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + $this->assertInstanceOf(TextResourceContents::class, $response->result->contents[0]); + $this->assertEquals('', $response->result->contents[0]->text); + } + + public function testHandleResourceReadWithDifferentMimeTypes(): void + { + $mimeTypes = [ + 'text/plain', + 'text/html', + 'application/json', + 'application/xml', + 'image/png', + 'image/jpeg', + 'application/pdf', + 'video/mp4', + 'audio/mpeg', + 'application/octet-stream', + ]; + + foreach ($mimeTypes as $i => $mimeType) { + $uri = "file://test/file{$i}"; + $request = $this->createReadResourceRequest($uri); + + if (str_starts_with($mimeType, 'text/') || str_starts_with($mimeType, 'application/json')) { + $expectedContent = new TextResourceContents( + uri: $uri, + mimeType: $mimeType, + text: "Content for {$mimeType}", + ); + } else { + $expectedContent = new BlobResourceContents( + uri: $uri, + mimeType: $mimeType, + blob: base64_encode("binary-content-for-{$mimeType}"), + ); + } + $expectedResult = new ReadResourceResult([$expectedContent]); + + $this->resourceReader + ->expects($this->once()) + ->method('read') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + $this->assertEquals($mimeType, $response->result->contents[0]->mimeType); + + // Reset the mock for next iteration + $this->resourceReader = $this->createMock(ResourceReaderInterface::class); + $this->handler = new ReadResourceHandler($this->resourceReader); + } + } + + public function testHandleResourceNotFoundWithCustomMessage(): void + { + $uri = 'file://custom/missing.txt'; + $request = $this->createReadResourceRequest($uri); + $exception = new ResourceNotFoundException($request); + + $this->resourceReader + ->expects($this->once()) + ->method('read') + ->with($request) + ->willThrowException($exception); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Error::class, $response); + $this->assertEquals(Error::RESOURCE_NOT_FOUND, $response->code); + $this->assertEquals('Resource not found for uri: "'.$uri.'".', $response->message); + } + + public function testHandleResourceReadWithEmptyResult(): void + { + $uri = 'file://empty/resource'; + $request = $this->createReadResourceRequest($uri); + $expectedResult = new ReadResourceResult([]); + + $this->resourceReader + ->expects($this->once()) + ->method('read') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + $this->assertCount(0, $response->result->contents); + } + + private function createReadResourceRequest(string $uri): ReadResourceRequest + { + return ReadResourceRequest::fromArray([ + 'jsonrpc' => '2.0', + 'method' => ReadResourceRequest::getMethod(), + 'id' => 'test-request-'.uniqid(), + 'params' => [ + 'uri' => $uri, + ], + ]); + } +} diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 2c2129e..1917711 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -32,6 +32,7 @@ public function testJsonExceptions() ->disableOriginalConstructor() ->onlyMethods(['process']) ->getMock(); + $handler->expects($this->exactly(2))->method('process')->willReturnOnConsecutiveCalls(new Exception(new \JsonException('foobar')), ['success']); $transport = $this->getMockBuilder(InMemoryTransport::class)