From 6e4c57d0dc9aeb0cfb7367a011cbbdcd3a62034e Mon Sep 17 00:00:00 2001 From: butschster Date: Sun, 7 Sep 2025 15:52:51 +0400 Subject: [PATCH 01/26] refactor: Separate Registry concerns following SOLID principles [WIP] - Extract ReferenceProvider and ReferenceRegistryInterface interfaces - Create DefaultToolExecutor with ReferenceHandlerInterface - Remove execution responsibility from Registry class - Enable custom handler and executor implementations --- src/Capability/Registry.php | 137 ++++++++---------- src/Capability/Registry/ReferenceHandler.php | 2 +- .../Registry/ReferenceHandlerInterface.php | 34 +++++ .../Registry/ReferenceProviderInterface.php | 79 ++++++++++ .../Registry/ReferenceRegistryInterface.php | 79 ++++++++++ src/Capability/Tool/DefaultToolExecutor.php | 76 ++++++++++ 6 files changed, 326 insertions(+), 81 deletions(-) create mode 100644 src/Capability/Registry/ReferenceHandlerInterface.php create mode 100644 src/Capability/Registry/ReferenceProviderInterface.php create mode 100644 src/Capability/Registry/ReferenceRegistryInterface.php create mode 100644 src/Capability/Tool/DefaultToolExecutor.php diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index f0db6549..93a49bd7 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -13,7 +13,8 @@ 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 +22,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; @@ -36,9 +34,14 @@ /** * @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,11 +64,11 @@ 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(), - ) { - } + ) {} + + // === ReferenceRegistry interface methods === public function getCapabilities(): ServerCapabilities { @@ -74,14 +77,14 @@ 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, ); } @@ -95,7 +98,9 @@ public function registerTool(Tool $tool, callable|array|string $handler, bool $i $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; } @@ -114,7 +119,9 @@ public function registerResource(Resource $resource, callable|array|string $hand $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; } @@ -125,7 +132,7 @@ public function registerResource(Resource $resource, callable|array|string $hand } /** - * @param callable|CallableArray|string $handler + * @param callable|CallableArray|string $handler * @param array $completionProviders */ public function registerResourceTemplate( @@ -138,18 +145,25 @@ 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 callable|CallableArray|string $handler * @param array $completionProviders */ public function registerPrompt( @@ -162,7 +176,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,17 +188,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. */ @@ -220,43 +225,17 @@ 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) - ); - } + // === ReferenceProvider interface methods === 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,22 +261,6 @@ 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; @@ -308,7 +271,7 @@ public function getPrompt(string $name): ?PromptReference */ public function getTools(): array { - return array_map(fn (ToolReference $tool) => $tool->tool, $this->tools); + return array_map(fn(ToolReference $tool) => $tool->tool, $this->tools); } /** @@ -316,7 +279,7 @@ public function getTools(): array */ public function getResources(): array { - return array_map(fn (ResourceReference $resource) => $resource->schema, $this->resources); + return array_map(fn(ResourceReference $resource) => $resource->schema, $this->resources); } /** @@ -324,12 +287,26 @@ public function getResources(): array */ public function getPrompts(): array { - return array_map(fn (PromptReference $prompt) => $prompt->prompt, $this->prompts); + return array_map(fn(PromptReference $prompt) => $prompt->prompt, $this->prompts); } - /** @return array */ + /** + * @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); + } + + /** + * 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); } } diff --git a/src/Capability/Registry/ReferenceHandler.php b/src/Capability/Registry/ReferenceHandler.php index d4af3169..b0333788 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 00000000..b8f565a4 --- /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 00000000..9984f983 --- /dev/null +++ b/src/Capability/Registry/ReferenceProviderInterface.php @@ -0,0 +1,79 @@ + + */ +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 00000000..19006114 --- /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 callable|CallableArray|string $handler + */ + public function registerTool(Tool $tool, callable|array|string $handler, bool $isManual = false): void; + + /** + * Registers a resource with its handler. + * + * @param callable|CallableArray|string $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 callable|CallableArray|string $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 callable|CallableArray|string $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/Tool/DefaultToolExecutor.php b/src/Capability/Tool/DefaultToolExecutor.php new file mode 100644 index 00000000..4dec98c4 --- /dev/null +++ b/src/Capability/Tool/DefaultToolExecutor.php @@ -0,0 +1,76 @@ + + */ +final class DefaultToolExecutor implements ToolExecutorInterface +{ + public function __construct( + private readonly ReferenceProviderInterface $referenceProvider, + private readonly ReferenceHandlerInterface $referenceHandler, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + /** + * @throws ToolExecutionException 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); + $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 ToolExecutionException($request, $e); + } + } +} From 047004e045d582c989e9a96090d8e6c892c6b588 Mon Sep 17 00:00:00 2001 From: butschster Date: Mon, 8 Sep 2025 00:12:32 +0400 Subject: [PATCH 02/26] refactor: implement SOLID principles with separated execution concerns * Create DefaultResourceReader, DefaultPromptGetter * Refactor JsonRpc Handler and RequestHandlers to use dedicated executors * Update ServerBuilder to support custom executors via dependency injection --- src/Capability/Prompt/DefaultPromptGetter.php | 46 +++++++++++++++++ src/Capability/Registry.php | 15 +++--- .../Registry/ReferenceProviderInterface.php | 2 +- .../Registry/ReferenceRegistryInterface.php | 4 +- .../Resource/DefaultResourceReader.php | 46 +++++++++++++++++ src/JsonRpc/Handler.php | 22 ++++---- src/Server/RequestHandler/CallToolHandler.php | 20 ++++---- .../RequestHandler/GetPromptHandler.php | 9 ++-- .../RequestHandler/ReadResourceHandler.php | 9 ++-- src/Server/ServerBuilder.php | 51 +++++++++++++++++-- 10 files changed, 183 insertions(+), 41 deletions(-) create mode 100644 src/Capability/Prompt/DefaultPromptGetter.php create mode 100644 src/Capability/Resource/DefaultResourceReader.php diff --git a/src/Capability/Prompt/DefaultPromptGetter.php b/src/Capability/Prompt/DefaultPromptGetter.php new file mode 100644 index 00000000..eb55a687 --- /dev/null +++ b/src/Capability/Prompt/DefaultPromptGetter.php @@ -0,0 +1,46 @@ +referenceProvider->getPrompt($request->name); + + if (null === $reference) { + throw new \InvalidArgumentException(\sprintf('Prompt "%s" is not registered.', $request->name)); + } + + return new GetPromptResult( + $reference->formatResult( + $this->referenceHandler->handle($reference, $request->arguments), + ), + ); + } +} diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index 93a49bd7..06a01a09 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -66,7 +66,8 @@ final class Registry implements ReferenceProviderInterface, ReferenceRegistryInt public function __construct( private readonly ?EventDispatcherInterface $eventDispatcher = null, private readonly LoggerInterface $logger = new NullLogger(), - ) {} + ) { + } // === ReferenceRegistry interface methods === @@ -132,7 +133,7 @@ public function registerResource(Resource $resource, callable|array|string $hand } /** - * @param callable|CallableArray|string $handler + * @param callable|CallableArray|string $handler * @param array $completionProviders */ public function registerResourceTemplate( @@ -163,7 +164,7 @@ public function registerResourceTemplate( } /** - * @param callable|CallableArray|string $handler + * @param callable|CallableArray|string $handler * @param array $completionProviders */ public function registerPrompt( @@ -271,7 +272,7 @@ public function getPrompt(string $name): ?PromptReference */ public function getTools(): array { - return array_map(fn(ToolReference $tool) => $tool->tool, $this->tools); + return array_map(fn (ToolReference $tool) => $tool->tool, $this->tools); } /** @@ -279,7 +280,7 @@ public function getTools(): array */ public function getResources(): array { - return array_map(fn(ResourceReference $resource) => $resource->schema, $this->resources); + return array_map(fn (ResourceReference $resource) => $resource->schema, $this->resources); } /** @@ -287,7 +288,7 @@ public function getResources(): array */ public function getPrompts(): array { - return array_map(fn(PromptReference $prompt) => $prompt->prompt, $this->prompts); + return array_map(fn (PromptReference $prompt) => $prompt->prompt, $this->prompts); } /** @@ -295,7 +296,7 @@ public function getPrompts(): array */ public function getResourceTemplates(): array { - return array_map(fn(ResourceTemplateReference $template) => $template->resourceTemplate, + return array_map(fn (ResourceTemplateReference $template) => $template->resourceTemplate, $this->resourceTemplates); } diff --git a/src/Capability/Registry/ReferenceProviderInterface.php b/src/Capability/Registry/ReferenceProviderInterface.php index 9984f983..0419f7eb 100644 --- a/src/Capability/Registry/ReferenceProviderInterface.php +++ b/src/Capability/Registry/ReferenceProviderInterface.php @@ -54,7 +54,7 @@ public function getTools(): array; /** * Gets all registered resources. * - * @return array + * @return array */ public function getResources(): array; diff --git a/src/Capability/Registry/ReferenceRegistryInterface.php b/src/Capability/Registry/ReferenceRegistryInterface.php index 19006114..c9b779a0 100644 --- a/src/Capability/Registry/ReferenceRegistryInterface.php +++ b/src/Capability/Registry/ReferenceRegistryInterface.php @@ -49,7 +49,7 @@ public function registerResource(Resource $resource, callable|array|string $hand /** * Registers a resource template with its handler and completion providers. * - * @param callable|CallableArray|string $handler + * @param callable|CallableArray|string $handler * @param array $completionProviders */ public function registerResourceTemplate( @@ -62,7 +62,7 @@ public function registerResourceTemplate( /** * Registers a prompt with its handler and completion providers. * - * @param callable|CallableArray|string $handler + * @param callable|CallableArray|string $handler * @param array $completionProviders */ public function registerPrompt( diff --git a/src/Capability/Resource/DefaultResourceReader.php b/src/Capability/Resource/DefaultResourceReader.php new file mode 100644 index 00000000..259691aa --- /dev/null +++ b/src/Capability/Resource/DefaultResourceReader.php @@ -0,0 +1,46 @@ +referenceProvider->getResource($request->uri); + + if (null === $reference) { + throw new \InvalidArgumentException(\sprintf('Resource "%s" is not registered.', $request->uri)); + } + + return new ReadResourceResult( + $reference->formatResult( + $this->referenceHandler->handle($reference, ['uri' => $request->uri]), + $request->uri, + ), + ); + } +} diff --git a/src/JsonRpc/Handler.php b/src/JsonRpc/Handler.php index 3cff864f..916b5455 100644 --- a/src/JsonRpc/Handler.php +++ b/src/JsonRpc/Handler.php @@ -11,7 +11,10 @@ namespace Mcp\JsonRpc; +use Mcp\Capability\Prompt\PromptGetterInterface; use Mcp\Capability\Registry; +use Mcp\Capability\Resource\ResourceReaderInterface; +use Mcp\Capability\Tool\ToolExecutorInterface; use Mcp\Exception\ExceptionInterface; use Mcp\Exception\HandlerNotFoundException; use Mcp\Exception\InvalidInputMessageException; @@ -27,11 +30,9 @@ use Psr\Log\NullLogger; /** - * @final - * * @author Christopher Hertel */ -class Handler +final class Handler { /** * @var array @@ -52,22 +53,25 @@ public function __construct( public static function make( Registry $registry, Implementation $implementation, + ToolExecutorInterface $toolExecutor, + 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\GetPromptHandler($promptGetter), new RequestHandler\ListResourcesHandler($registry), - new RequestHandler\ReadResourceHandler($registry), - new RequestHandler\CallToolHandler($registry, $logger), + new RequestHandler\ReadResourceHandler($resourceReader), + new RequestHandler\CallToolHandler($toolExecutor, $logger), new RequestHandler\ListToolsHandler($registry), ], - $logger, + logger: $logger, ); } diff --git a/src/Server/RequestHandler/CallToolHandler.php b/src/Server/RequestHandler/CallToolHandler.php index a4dfebd0..a18e48f9 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\ToolExecutorInterface; 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 ToolExecutorInterface $toolExecutor, 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->toolExecutor->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 c74044d8..1ac0a3ff 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/ReadResourceHandler.php b/src/Server/RequestHandler/ReadResourceHandler.php index 40746a6e..9c80d2b1 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 f8867567..393b9c0a 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\DefaultPromptGetter; +use Mcp\Capability\Prompt\PromptGetterInterface; use Mcp\Capability\Registry; use Mcp\Capability\Registry\Container; use Mcp\Capability\Registry\ReferenceHandler; +use Mcp\Capability\Resource\DefaultResourceReader; +use Mcp\Capability\Resource\ResourceReaderInterface; +use Mcp\Capability\Tool\DefaultToolExecutor; +use Mcp\Capability\Tool\ToolExecutorInterface; use Mcp\Exception\ConfigurationException; use Mcp\JsonRpc\Handler; use Mcp\Schema\Annotations; @@ -49,6 +55,12 @@ final class ServerBuilder private ?CacheInterface $cache = null; + private ?ToolExecutorInterface $toolExecutor = 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 withToolExecutor(ToolExecutorInterface $toolExecutor): self + { + $this->toolExecutor = $toolExecutor; + + 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. @@ -220,7 +253,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); + $toolExecutor = $this->toolExecutor ??= new DefaultToolExecutor($registry, $referenceHandler, $logger); + $resourceReader = $this->resourceReader ??= new DefaultResourceReader($registry, $referenceHandler); + $promptGetter = $this->promptGetter ??= new DefaultPromptGetter($registry, $referenceHandler); $this->registerManualElements($registry, $logger); @@ -230,8 +268,15 @@ public function build(): Server } return new Server( - Handler::make($registry, $this->serverInfo, $logger), - $logger, + jsonRpcHandler: Handler::make( + registry: $registry, + implementation: $this->serverInfo, + toolExecutor: $toolExecutor, + resourceReader: $resourceReader, + promptGetter: $promptGetter, + logger: $logger, + ), + logger: $logger, ); } From 9bf2a7ed3afd85ee289d890c20a498cdf84c5382 Mon Sep 17 00:00:00 2001 From: butschster Date: Mon, 8 Sep 2025 00:15:33 +0400 Subject: [PATCH 03/26] chore: remove extra comments --- src/Capability/Registry.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index 06a01a09..8dfa193f 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -69,8 +69,6 @@ public function __construct( ) { } - // === ReferenceRegistry interface methods === - public function getCapabilities(): ServerCapabilities { if (!$this->hasElements()) { @@ -226,8 +224,6 @@ public function clear(): void } } - // === ReferenceProvider interface methods === - public function getTool(string $name): ?ToolReference { return $this->tools[$name] ?? null; From 4aafe6b8a0dc607b50f22aa01d798e9b38280e96 Mon Sep 17 00:00:00 2001 From: butschster Date: Mon, 8 Sep 2025 00:51:34 +0400 Subject: [PATCH 04/26] feat: introduce DispatchableRegistry for enhanced tool and resource management --- src/Capability/DispatchableRegistry.php | 83 +++++++++++++++++++ src/Capability/Registry.php | 20 +---- src/Schema/ServerCapabilities.php | 17 +++- src/Server/ServerBuilder.php | 106 ++++++++++++++++++------ 4 files changed, 184 insertions(+), 42 deletions(-) create mode 100644 src/Capability/DispatchableRegistry.php diff --git a/src/Capability/DispatchableRegistry.php b/src/Capability/DispatchableRegistry.php new file mode 100644 index 00000000..f45b5ab2 --- /dev/null +++ b/src/Capability/DispatchableRegistry.php @@ -0,0 +1,83 @@ +referenceProvider->getCapabilities(); + + if (null !== $this->eventDispatcher) { + return $capabilities->withEvents(); + } + + return $capabilities; + } + + public function registerTool(Tool $tool, callable|array|string $handler, bool $isManual = false): void + { + $this->referenceProvider->registerTool($tool, $handler, $isManual); + $this->eventDispatcher?->dispatch(new ToolListChangedEvent()); + } + + public function registerResource(Resource $resource, callable|array|string $handler, bool $isManual = false): void + { + $this->referenceProvider->registerResource($resource, $handler, $isManual); + $this->eventDispatcher?->dispatch(new ResourceListChangedEvent()); + } + + public function registerResourceTemplate( + ResourceTemplate $template, + callable|array|string $handler, + array $completionProviders = [], + bool $isManual = false, + ): void { + $this->referenceProvider->registerResourceTemplate($template, $handler, $completionProviders, $isManual); + $this->eventDispatcher?->dispatch(new ResourceTemplateListChangedEvent()); + } + + public function registerPrompt( + Prompt $prompt, + callable|array|string $handler, + array $completionProviders = [], + bool $isManual = false, + ): void { + $this->referenceProvider->registerPrompt($prompt, $handler, $completionProviders, $isManual); + + $this->eventDispatcher?->dispatch(new PromptListChangedEvent()); + } + + public function clear(): void + { + $this->referenceProvider->clear(); + // TODO: are there any events to dispatch here? + } +} diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index 8dfa193f..5bf791da 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -18,16 +18,11 @@ use Mcp\Capability\Registry\ResourceReference; use Mcp\Capability\Registry\ResourceTemplateReference; use Mcp\Capability\Registry\ToolReference; -use Mcp\Event\PromptListChangedEvent; -use Mcp\Event\ResourceListChangedEvent; -use Mcp\Event\ResourceTemplateListChangedEvent; -use Mcp\Event\ToolListChangedEvent; use Mcp\Schema\Prompt; use Mcp\Schema\Resource; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\ServerCapabilities; use Mcp\Schema\Tool; -use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -64,7 +59,6 @@ final class Registry implements ReferenceProviderInterface, ReferenceRegistryInt private array $resourceTemplates = []; public function __construct( - private readonly ?EventDispatcherInterface $eventDispatcher = null, private readonly LoggerInterface $logger = new NullLogger(), ) { } @@ -77,12 +71,12 @@ public function getCapabilities(): ServerCapabilities return new ServerCapabilities( tools: [] !== $this->tools, - toolsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, + toolsListChanged: false, resources: [] !== $this->resources || [] !== $this->resourceTemplates, resourcesSubscribe: false, - resourcesListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, + resourcesListChanged: false, prompts: [] !== $this->prompts, - promptsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, + promptsListChanged: false, logging: false, completions: true, ); @@ -105,8 +99,6 @@ public function registerTool(Tool $tool, callable|array|string $handler, bool $i } $this->tools[$toolName] = new ToolReference($tool, $handler, $isManual); - - $this->eventDispatcher?->dispatch(new ToolListChangedEvent()); } /** @@ -126,8 +118,6 @@ public function registerResource(Resource $resource, callable|array|string $hand } $this->resources[$uri] = new ResourceReference($resource, $handler, $isManual); - - $this->eventDispatcher?->dispatch(new ResourceListChangedEvent()); } /** @@ -157,8 +147,6 @@ public function registerResourceTemplate( $isManual, $completionProviders, ); - - $this->eventDispatcher?->dispatch(new ResourceTemplateListChangedEvent()); } /** @@ -183,8 +171,6 @@ public function registerPrompt( } $this->prompts[$promptName] = new PromptReference($prompt, $handler, $isManual, $completionProviders); - - $this->eventDispatcher?->dispatch(new PromptListChangedEvent()); } /** diff --git a/src/Schema/ServerCapabilities.php b/src/Schema/ServerCapabilities.php index fffc2655..c820772c 100644 --- a/src/Schema/ServerCapabilities.php +++ b/src/Schema/ServerCapabilities.php @@ -45,6 +45,21 @@ public function __construct( ) { } + public function withEvents(): self + { + return new self( + tools: $this->tools, + toolsListChanged: true, + resources: $this->resources, + resourcesSubscribe: $this->resourcesSubscribe, + resourcesListChanged: true, + prompts: $this->prompts, + promptsListChanged: true, + logging: $this->logging, + completions: $this->completions, + ); + } + /** * @param array{ * logging?: mixed, @@ -106,7 +121,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/ServerBuilder.php b/src/Server/ServerBuilder.php index 393b9c0a..de217498 100644 --- a/src/Server/ServerBuilder.php +++ b/src/Server/ServerBuilder.php @@ -16,6 +16,7 @@ use Mcp\Capability\Discovery\DocBlockParser; use Mcp\Capability\Discovery\HandlerResolver; use Mcp\Capability\Discovery\SchemaGenerator; +use Mcp\Capability\DispatchableRegistry; use Mcp\Capability\Prompt\Completion\EnumCompletionProvider; use Mcp\Capability\Prompt\Completion\ListCompletionProvider; use Mcp\Capability\Prompt\DefaultPromptGetter; @@ -208,8 +209,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; @@ -218,8 +224,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; @@ -228,9 +241,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; } @@ -253,12 +279,16 @@ public function build(): Server $logger = $this->logger ?? new NullLogger(); $container = $this->container ?? new Container(); - $registry = new Registry($this->eventDispatcher, $logger); + $referenceProvider = new Registry($logger); + $registry = new DispatchableRegistry( + referenceProvider: new Registry($logger), + eventDispatcher: $this->eventDispatcher, + ); $referenceHandler = new ReferenceHandler($container); - $toolExecutor = $this->toolExecutor ??= new DefaultToolExecutor($registry, $referenceHandler, $logger); - $resourceReader = $this->resourceReader ??= new DefaultResourceReader($registry, $referenceHandler); - $promptGetter = $this->promptGetter ??= new DefaultPromptGetter($registry, $referenceHandler); + $toolExecutor = $this->toolExecutor ??= new DefaultToolExecutor($referenceProvider, $referenceHandler, $logger); + $resourceReader = $this->resourceReader ??= new DefaultResourceReader($referenceProvider, $referenceHandler); + $promptGetter = $this->promptGetter ??= new DefaultPromptGetter($referenceProvider, $referenceHandler); $this->registerManualElements($registry, $logger); @@ -270,6 +300,7 @@ public function build(): Server return new Server( jsonRpcHandler: Handler::make( registry: $registry, + referenceProvider: $referenceProvider, implementation: $this->serverInfo, toolExecutor: $toolExecutor, resourceReader: $resourceReader, @@ -284,8 +315,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; } @@ -315,10 +348,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); } } @@ -348,10 +386,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); } } @@ -381,10 +424,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); } } @@ -407,7 +455,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(); @@ -420,7 +470,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(), ); } @@ -428,10 +478,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); } } @@ -448,7 +503,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(); From bd4791a7a8a9734ab7b71d9011c4cba265dcd867 Mon Sep 17 00:00:00 2001 From: butschster Date: Mon, 8 Sep 2025 00:52:06 +0400 Subject: [PATCH 05/26] refactor: use proper interfaces --- src/Capability/Discovery/Discoverer.php | 4 ++-- src/Capability/Prompt/DefaultPromptGetter.php | 3 +++ src/Capability/Resource/DefaultResourceReader.php | 3 +++ src/Server/RequestHandler/ListPromptsHandler.php | 4 ++-- src/Server/RequestHandler/ListResourcesHandler.php | 4 ++-- src/Server/RequestHandler/ListToolsHandler.php | 4 ++-- 6 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/Capability/Discovery/Discoverer.php b/src/Capability/Discovery/Discoverer.php index 4d9651cb..a2bdd3cd 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/DefaultPromptGetter.php b/src/Capability/Prompt/DefaultPromptGetter.php index eb55a687..a86aa750 100644 --- a/src/Capability/Prompt/DefaultPromptGetter.php +++ b/src/Capability/Prompt/DefaultPromptGetter.php @@ -17,6 +17,9 @@ use Mcp\Schema\Request\GetPromptRequest; use Mcp\Schema\Result\GetPromptResult; +/** + * @author Pavel Buchnev + */ final class DefaultPromptGetter implements PromptGetterInterface { public function __construct( diff --git a/src/Capability/Resource/DefaultResourceReader.php b/src/Capability/Resource/DefaultResourceReader.php index 259691aa..2eae9f37 100644 --- a/src/Capability/Resource/DefaultResourceReader.php +++ b/src/Capability/Resource/DefaultResourceReader.php @@ -17,6 +17,9 @@ use Mcp\Schema\Request\ReadResourceRequest; use Mcp\Schema\Result\ReadResourceResult; +/** + * @author Pavel Buchnev + */ final class DefaultResourceReader implements ResourceReaderInterface { public function __construct( diff --git a/src/Server/RequestHandler/ListPromptsHandler.php b/src/Server/RequestHandler/ListPromptsHandler.php index 942550a0..2bf479c9 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 75804d84..212f4f00 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 ef35fa8d..eb49e0d9 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, ) { } From fc467d8a6af23f40db37a52f295151ece42dfdfb Mon Sep 17 00:00:00 2001 From: butschster Date: Mon, 8 Sep 2025 00:52:18 +0400 Subject: [PATCH 06/26] refactor: implement HandlerInterface for improved abstraction and flexibility --- src/JsonRpc/Handler.php | 20 ++++++++------------ src/JsonRpc/HandlerInterface.php | 28 ++++++++++++++++++++++++++++ src/Server.php | 4 ++-- tests/ServerTest.php | 4 +++- 4 files changed, 41 insertions(+), 15 deletions(-) create mode 100644 src/JsonRpc/HandlerInterface.php diff --git a/src/JsonRpc/Handler.php b/src/JsonRpc/Handler.php index 916b5455..4d1d047f 100644 --- a/src/JsonRpc/Handler.php +++ b/src/JsonRpc/Handler.php @@ -12,7 +12,8 @@ namespace Mcp\JsonRpc; use Mcp\Capability\Prompt\PromptGetterInterface; -use Mcp\Capability\Registry; +use Mcp\Capability\Registry\ReferenceProviderInterface; +use Mcp\Capability\Registry\ReferenceRegistryInterface; use Mcp\Capability\Resource\ResourceReaderInterface; use Mcp\Capability\Tool\ToolExecutorInterface; use Mcp\Exception\ExceptionInterface; @@ -32,7 +33,7 @@ /** * @author Christopher Hertel */ -final class Handler +final class Handler implements HandlerInterface { /** * @var array @@ -51,7 +52,8 @@ public function __construct( } public static function make( - Registry $registry, + ReferenceRegistryInterface $registry, + ReferenceProviderInterface $referenceProvider, Implementation $implementation, ToolExecutorInterface $toolExecutor, ResourceReaderInterface $resourceReader, @@ -64,23 +66,17 @@ public static function make( new NotificationHandler\InitializedHandler(), new RequestHandler\InitializeHandler($registry->getCapabilities(), $implementation), new RequestHandler\PingHandler(), - new RequestHandler\ListPromptsHandler($registry), + new RequestHandler\ListPromptsHandler($referenceProvider), new RequestHandler\GetPromptHandler($promptGetter), - new RequestHandler\ListResourcesHandler($registry), + new RequestHandler\ListResourcesHandler($referenceProvider), new RequestHandler\ReadResourceHandler($resourceReader), new RequestHandler\CallToolHandler($toolExecutor, $logger), - new RequestHandler\ListToolsHandler($registry), + new RequestHandler\ListToolsHandler($referenceProvider), ], logger: $logger, ); } - /** - * @return iterable - * - * @throws ExceptionInterface When a handler throws an exception during message processing - * @throws \JsonException When JSON encoding of the response fails - */ public function process(string $input): iterable { $this->logger->info('Received message to process.', ['message' => $input]); diff --git a/src/JsonRpc/HandlerInterface.php b/src/JsonRpc/HandlerInterface.php new file mode 100644 index 00000000..ca9ec615 --- /dev/null +++ b/src/JsonRpc/HandlerInterface.php @@ -0,0 +1,28 @@ + + */ +interface HandlerInterface +{ + /** + * @return iterable + * + * @throws ExceptionInterface When a handler throws an exception during message processing + * @throws \JsonException When JSON encoding of the response fails + */ + public function process(string $input): iterable; +} diff --git a/src/Server.php b/src/Server.php index fc81382d..d59e6b4c 100644 --- a/src/Server.php +++ b/src/Server.php @@ -11,7 +11,7 @@ namespace Mcp; -use Mcp\JsonRpc\Handler; +use Mcp\JsonRpc\HandlerInterface; use Mcp\Server\ServerBuilder; use Mcp\Server\TransportInterface; use Psr\Log\LoggerInterface; @@ -23,7 +23,7 @@ final class Server { public function __construct( - private readonly Handler $jsonRpcHandler, + private readonly HandlerInterface $jsonRpcHandler, private readonly LoggerInterface $logger = new NullLogger(), ) { } diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 2c2129e0..559d5db2 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -12,6 +12,7 @@ namespace Mcp\Tests; use Mcp\JsonRpc\Handler; +use Mcp\JsonRpc\HandlerInterface; use Mcp\Server; use Mcp\Server\Transport\InMemoryTransport; use PHPUnit\Framework\MockObject\Stub\Exception; @@ -28,10 +29,11 @@ public function testJsonExceptions() ->getMock(); $logger->expects($this->once())->method('error'); - $handler = $this->getMockBuilder(Handler::class) + $handler = $this->getMockBuilder(HandlerInterface::class) ->disableOriginalConstructor() ->onlyMethods(['process']) ->getMock(); + $handler->expects($this->exactly(2))->method('process')->willReturnOnConsecutiveCalls(new Exception(new \JsonException('foobar')), ['success']); $transport = $this->getMockBuilder(InMemoryTransport::class) From e6dc2af95b47e89eeeb637facaca3f36abdb6513 Mon Sep 17 00:00:00 2001 From: butschster Date: Mon, 8 Sep 2025 00:52:29 +0400 Subject: [PATCH 07/26] test: add unit tests for DispatchableRegistry and Registry classes --- .../Registry/DispatchableRegistryTest.php | 415 ++++++++++++++++ .../Registry/RegistryProviderTest.php | 313 ++++++++++++ tests/Capability/Registry/RegistryTest.php | 357 ++++++++++++++ tests/Schema/ServerCapabilitiesTest.php | 466 ++++++++++++++++++ 4 files changed, 1551 insertions(+) create mode 100644 tests/Capability/Registry/DispatchableRegistryTest.php create mode 100644 tests/Capability/Registry/RegistryProviderTest.php create mode 100644 tests/Capability/Registry/RegistryTest.php create mode 100644 tests/Schema/ServerCapabilitiesTest.php diff --git a/tests/Capability/Registry/DispatchableRegistryTest.php b/tests/Capability/Registry/DispatchableRegistryTest.php new file mode 100644 index 00000000..86de71ff --- /dev/null +++ b/tests/Capability/Registry/DispatchableRegistryTest.php @@ -0,0 +1,415 @@ +referenceRegistry = $this->createMock(ReferenceRegistryInterface::class); + $this->eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $this->dispatchableRegistry = new DispatchableRegistry($this->referenceRegistry, $this->eventDispatcher); + } + + public function testConstructorWithoutEventDispatcher(): void + { + $dispatchableRegistry = new DispatchableRegistry($this->referenceRegistry); + + $this->assertInstanceOf(DispatchableRegistry::class, $dispatchableRegistry); + } + + public function testGetCapabilitiesWithEventDispatcher(): void + { + $baseCapabilities = new ServerCapabilities( + tools: true, + toolsListChanged: false, + resources: true, + resourcesListChanged: false, + prompts: true, + promptsListChanged: false + ); + + $this->referenceRegistry->expects($this->once()) + ->method('getCapabilities') + ->willReturn($baseCapabilities); + + $capabilities = $this->dispatchableRegistry->getCapabilities(); + + $this->assertTrue($capabilities->tools); + $this->assertTrue($capabilities->toolsListChanged); + $this->assertTrue($capabilities->resources); + $this->assertTrue($capabilities->resourcesListChanged); + $this->assertTrue($capabilities->prompts); + $this->assertTrue($capabilities->promptsListChanged); + } + + public function testGetCapabilitiesWithoutEventDispatcher(): void + { + $baseCapabilities = new ServerCapabilities( + tools: true, + toolsListChanged: false, + resources: true, + resourcesListChanged: false, + prompts: true, + promptsListChanged: false + ); + + $dispatchableRegistry = new DispatchableRegistry($this->referenceRegistry, null); + + $this->referenceRegistry->expects($this->once()) + ->method('getCapabilities') + ->willReturn($baseCapabilities); + + $capabilities = $dispatchableRegistry->getCapabilities(); + + $this->assertTrue($capabilities->tools); + $this->assertFalse($capabilities->toolsListChanged); + $this->assertTrue($capabilities->resources); + $this->assertFalse($capabilities->resourcesListChanged); + $this->assertTrue($capabilities->prompts); + $this->assertFalse($capabilities->promptsListChanged); + } + + public function testRegisterToolDelegatesToReferenceRegistryAndDispatchesEvent(): void + { + $tool = $this->createValidTool('test_tool'); + $handler = fn() => 'result'; + + $this->referenceRegistry->expects($this->once()) + ->method('registerTool') + ->with($tool, $handler, false); + + $this->eventDispatcher->expects($this->once()) + ->method('dispatch') + ->with($this->isInstanceOf(ToolListChangedEvent::class)); + + $this->dispatchableRegistry->registerTool($tool, $handler); + } + + public function testRegisterToolWithManualFlag(): void + { + $tool = $this->createValidTool('test_tool'); + $handler = fn() => 'result'; + + $this->referenceRegistry->expects($this->once()) + ->method('registerTool') + ->with($tool, $handler, true); + + $this->eventDispatcher->expects($this->once()) + ->method('dispatch') + ->with($this->isInstanceOf(ToolListChangedEvent::class)); + + $this->dispatchableRegistry->registerTool($tool, $handler, true); + } + + public function testRegisterToolWithoutEventDispatcher(): void + { + $dispatchableRegistry = new DispatchableRegistry($this->referenceRegistry, null); + $tool = $this->createValidTool('test_tool'); + $handler = fn() => 'result'; + + $this->referenceRegistry->expects($this->once()) + ->method('registerTool') + ->with($tool, $handler, false); + + // Should not throw exception when event dispatcher is null + $dispatchableRegistry->registerTool($tool, $handler); + } + + public function testRegisterResourceDelegatesToReferenceRegistryAndDispatchesEvent(): void + { + $resource = $this->createValidResource('test://resource'); + $handler = fn() => 'content'; + + $this->referenceRegistry->expects($this->once()) + ->method('registerResource') + ->with($resource, $handler, false); + + $this->eventDispatcher->expects($this->once()) + ->method('dispatch') + ->with($this->isInstanceOf(ResourceListChangedEvent::class)); + + $this->dispatchableRegistry->registerResource($resource, $handler); + } + + public function testRegisterResourceWithManualFlag(): void + { + $resource = $this->createValidResource('test://resource'); + $handler = fn() => 'content'; + + $this->referenceRegistry->expects($this->once()) + ->method('registerResource') + ->with($resource, $handler, true); + + $this->eventDispatcher->expects($this->once()) + ->method('dispatch') + ->with($this->isInstanceOf(ResourceListChangedEvent::class)); + + $this->dispatchableRegistry->registerResource($resource, $handler, true); + } + + public function testRegisterResourceWithoutEventDispatcher(): void + { + $dispatchableRegistry = new DispatchableRegistry($this->referenceRegistry, null); + $resource = $this->createValidResource('test://resource'); + $handler = fn() => 'content'; + + $this->referenceRegistry->expects($this->once()) + ->method('registerResource') + ->with($resource, $handler, false); + + $dispatchableRegistry->registerResource($resource, $handler); + } + + public function testRegisterResourceTemplateDelegatesToReferenceRegistryAndDispatchesEvent(): void + { + $template = $this->createValidResourceTemplate('test://{id}'); + $handler = fn() => 'content'; + $completionProviders = ['id' => 'TestProvider']; + + $this->referenceRegistry->expects($this->once()) + ->method('registerResourceTemplate') + ->with($template, $handler, $completionProviders, false); + + $this->eventDispatcher->expects($this->once()) + ->method('dispatch') + ->with($this->isInstanceOf(ResourceTemplateListChangedEvent::class)); + + $this->dispatchableRegistry->registerResourceTemplate($template, $handler, $completionProviders); + } + + public function testRegisterResourceTemplateWithDefaults(): void + { + $template = $this->createValidResourceTemplate('test://{id}'); + $handler = fn() => 'content'; + + $this->referenceRegistry->expects($this->once()) + ->method('registerResourceTemplate') + ->with($template, $handler, [], false); + + $this->eventDispatcher->expects($this->once()) + ->method('dispatch') + ->with($this->isInstanceOf(ResourceTemplateListChangedEvent::class)); + + $this->dispatchableRegistry->registerResourceTemplate($template, $handler); + } + + public function testRegisterResourceTemplateWithManualFlag(): void + { + $template = $this->createValidResourceTemplate('test://{id}'); + $handler = fn() => 'content'; + + $this->referenceRegistry->expects($this->once()) + ->method('registerResourceTemplate') + ->with($template, $handler, [], true); + + $this->eventDispatcher->expects($this->once()) + ->method('dispatch') + ->with($this->isInstanceOf(ResourceTemplateListChangedEvent::class)); + + $this->dispatchableRegistry->registerResourceTemplate($template, $handler, [], true); + } + + public function testRegisterResourceTemplateWithoutEventDispatcher(): void + { + $dispatchableRegistry = new DispatchableRegistry($this->referenceRegistry, null); + $template = $this->createValidResourceTemplate('test://{id}'); + $handler = fn() => 'content'; + + $this->referenceRegistry->expects($this->once()) + ->method('registerResourceTemplate') + ->with($template, $handler, [], false); + + $dispatchableRegistry->registerResourceTemplate($template, $handler); + } + + public function testRegisterPromptDelegatesToReferenceRegistryAndDispatchesEvent(): void + { + $prompt = $this->createValidPrompt('test_prompt'); + $handler = fn() => []; + $completionProviders = ['param' => 'TestProvider']; + + $this->referenceRegistry->expects($this->once()) + ->method('registerPrompt') + ->with($prompt, $handler, $completionProviders, false); + + $this->eventDispatcher->expects($this->once()) + ->method('dispatch') + ->with($this->isInstanceOf(PromptListChangedEvent::class)); + + $this->dispatchableRegistry->registerPrompt($prompt, $handler, $completionProviders); + } + + public function testRegisterPromptWithDefaults(): void + { + $prompt = $this->createValidPrompt('test_prompt'); + $handler = fn() => []; + + $this->referenceRegistry->expects($this->once()) + ->method('registerPrompt') + ->with($prompt, $handler, [], false); + + $this->eventDispatcher->expects($this->once()) + ->method('dispatch') + ->with($this->isInstanceOf(PromptListChangedEvent::class)); + + $this->dispatchableRegistry->registerPrompt($prompt, $handler); + } + + public function testRegisterPromptWithManualFlag(): void + { + $prompt = $this->createValidPrompt('test_prompt'); + $handler = fn() => []; + + $this->referenceRegistry->expects($this->once()) + ->method('registerPrompt') + ->with($prompt, $handler, [], true); + + $this->eventDispatcher->expects($this->once()) + ->method('dispatch') + ->with($this->isInstanceOf(PromptListChangedEvent::class)); + + $this->dispatchableRegistry->registerPrompt($prompt, $handler, [], true); + } + + public function testRegisterPromptWithoutEventDispatcher(): void + { + $dispatchableRegistry = new DispatchableRegistry($this->referenceRegistry, null); + $prompt = $this->createValidPrompt('test_prompt'); + $handler = fn() => []; + + $this->referenceRegistry->expects($this->once()) + ->method('registerPrompt') + ->with($prompt, $handler, [], false); + + $dispatchableRegistry->registerPrompt($prompt, $handler); + } + + public function testClearDelegatesToReferenceRegistry(): void + { + $this->referenceRegistry->expects($this->once()) + ->method('clear'); + + $this->dispatchableRegistry->clear(); + } + + public function testRegisterToolHandlesStringHandler(): void + { + $tool = $this->createValidTool('test_tool'); + $handler = 'TestClass::testMethod'; + + $this->referenceRegistry->expects($this->once()) + ->method('registerTool') + ->with($tool, $handler, false); + + $this->eventDispatcher->expects($this->once()) + ->method('dispatch') + ->with($this->isInstanceOf(ToolListChangedEvent::class)); + + $this->dispatchableRegistry->registerTool($tool, $handler); + } + + public function testRegisterToolHandlesArrayHandler(): void + { + $tool = $this->createValidTool('test_tool'); + $handler = ['TestClass', 'testMethod']; + + $this->referenceRegistry->expects($this->once()) + ->method('registerTool') + ->with($tool, $handler, false); + + $this->eventDispatcher->expects($this->once()) + ->method('dispatch') + ->with($this->isInstanceOf(ToolListChangedEvent::class)); + + $this->dispatchableRegistry->registerTool($tool, $handler); + } + + public function testRegisterResourceHandlesCallableHandler(): void + { + $resource = $this->createValidResource('test://resource'); + $handler = fn() => 'content'; + + $this->referenceRegistry->expects($this->once()) + ->method('registerResource') + ->with($resource, $handler, false); + + $this->eventDispatcher->expects($this->once()) + ->method('dispatch') + ->with($this->isInstanceOf(ResourceListChangedEvent::class)); + + $this->dispatchableRegistry->registerResource($resource, $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/Registry/RegistryProviderTest.php b/tests/Capability/Registry/RegistryProviderTest.php new file mode 100644 index 00000000..8669afab --- /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 00000000..a8b43fd6 --- /dev/null +++ b/tests/Capability/Registry/RegistryTest.php @@ -0,0 +1,357 @@ +logger = $this->createMock(LoggerInterface::class); + $this->registry = new Registry($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' => 'TestProvider']; + + $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' => 'TestProvider']; + + $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/Schema/ServerCapabilitiesTest.php b/tests/Schema/ServerCapabilitiesTest.php new file mode 100644 index 00000000..dc54d98a --- /dev/null +++ b/tests/Schema/ServerCapabilitiesTest.php @@ -0,0 +1,466 @@ +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 testWithEvents(): void + { + $capabilities = new ServerCapabilities( + tools: true, + toolsListChanged: false, + resources: true, + resourcesSubscribe: false, + resourcesListChanged: false, + prompts: true, + promptsListChanged: false, + logging: false, + completions: true + ); + + $withEvents = $capabilities->withEvents(); + + $this->assertTrue($withEvents->tools); + $this->assertTrue($withEvents->toolsListChanged); + $this->assertTrue($withEvents->resources); + $this->assertFalse($withEvents->resourcesSubscribe); + $this->assertTrue($withEvents->resourcesListChanged); + $this->assertTrue($withEvents->prompts); + $this->assertTrue($withEvents->promptsListChanged); + $this->assertFalse($withEvents->logging); + $this->assertTrue($withEvents->completions); + } + + public function testWithEventsPreservesResourcesSubscribe(): void + { + $capabilities = new ServerCapabilities( + resourcesSubscribe: true + ); + + $withEvents = $capabilities->withEvents(); + + $this->assertTrue($withEvents->resourcesSubscribe); + $this->assertTrue($withEvents->resourcesListChanged); + } + + public function testWithEventsIsImmutable(): void + { + $original = new ServerCapabilities( + toolsListChanged: false, + resourcesListChanged: false, + promptsListChanged: false + ); + + $withEvents = $original->withEvents(); + + $this->assertFalse($original->toolsListChanged); + $this->assertFalse($original->resourcesListChanged); + $this->assertFalse($original->promptsListChanged); + + $this->assertTrue($withEvents->toolsListChanged); + $this->assertTrue($withEvents->resourcesListChanged); + $this->assertTrue($withEvents->promptsListChanged); + + $this->assertNotSame($original, $withEvents); + } + + 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->assertEquals(true, $json['prompts']->listChanged); + + $this->assertArrayHasKey('resources', $json); + $this->assertEquals(true, $json['resources']->subscribe); + $this->assertEquals(true, $json['resources']->listChanged); + + $this->assertArrayHasKey('tools', $json); + $this->assertEquals(true, $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); + } +} From 20de24fd97b274d1d27f6f4b86def84e7025178e Mon Sep 17 00:00:00 2001 From: butschster Date: Mon, 8 Sep 2025 01:31:24 +0400 Subject: [PATCH 08/26] refactor: cover with unit tests Resource reader, Prompt getter and Tool executor --- .guideline/tests.md | 513 ++++++++++++++ src/Capability/DispatchableRegistry.php | 1 - src/Capability/Prompt/DefaultPromptGetter.php | 2 +- .../Registry/ResourceTemplateReference.php | 2 +- .../Resource/ResourceReadResult.php | 30 - .../Prompt/DefaultPromptGetterTest.php | 637 ++++++++++++++++++ .../Registry/DispatchableRegistryTest.php | 36 +- tests/Capability/Registry/RegistryTest.php | 65 +- .../Resource/DefaultResourceReaderTest.php | 496 ++++++++++++++ .../Tool/DefaultToolExecutorTest.php | 627 +++++++++++++++++ tests/Schema/ServerCapabilitiesTest.php | 32 +- tests/ServerTest.php | 1 - 12 files changed, 2339 insertions(+), 103 deletions(-) create mode 100644 .guideline/tests.md delete mode 100644 src/Capability/Resource/ResourceReadResult.php create mode 100644 tests/Capability/Prompt/DefaultPromptGetterTest.php create mode 100644 tests/Capability/Resource/DefaultResourceReaderTest.php create mode 100644 tests/Capability/Tool/DefaultToolExecutorTest.php diff --git a/.guideline/tests.md b/.guideline/tests.md new file mode 100644 index 00000000..2642cec5 --- /dev/null +++ b/.guideline/tests.md @@ -0,0 +1,513 @@ +# PHP MCP SDK Style Guide + +This style guide is based on the analysis of the Model Context Protocol (MCP) PHP SDK test codebase and establishes the +conventions used throughout this framework. + +## 1. Project Overview + +- **PHP Version**: 8.1+ +- **Framework**: Custom MCP SDK (Model Context Protocol) +- **Architecture Pattern**: Modular SDK with Discovery, Registry, and Capability patterns +- **Key Dependencies**: PHPUnit, PHPStan, PHP-CS-Fixer, PHPDocumentor, Symfony Components + +## 2. File Structure & Organization + +### Header Comment Pattern + +Every PHP file must start with this exact header comment: + +```php +subjectUnderTest = new ExampleClass(); + } + + public function testSpecificBehavior(): void + { + // Arrange + $input = $this->getTestData(); + + // Act + $result = $this->subjectUnderTest->process($input); + + // Assert + $this->assertInstanceOf(ExpectedClass::class, $result); + $this->assertEquals($expectedValue, $result->getValue()); + } + + private function getTestData(): array + { + return ['key' => 'value']; + } +} +``` + +### Test Method Naming + +- Must start with `test` +- Followed by clear description of what is being tested +- Use camelCase after the `test` prefix +- Be descriptive - method names can be long if necessary + +```php +// Test method naming examples +public function testDiscoversAllElementTypesCorrectlyFromFixtureFiles(): void +public function testDoesNotDiscoverElementsFromExcludedDirectories(): void +public function testHandlesEmptyDirectoriesOrDirectoriesWithNoPhpFiles(): void +public function testCorrectlyInfersNamesAndDescriptionsFromMethodsOrClassesIfNotSetInAttribute(): void +``` + +### Assertion Patterns + +- Use specific assertions over generic ones +- Group related assertions together +- Use `assertInstanceOf` for type checking +- Use `assertCount` for array/collection size checking +- Use `assertEqualsCanonicalizing` for arrays where order doesn't matter + +```php +// Assertion examples +$this->assertCount(4, $tools); +$this->assertInstanceOf(ToolReference::class, $greetUserTool); +$this->assertFalse($greetUserTool->isManual); +$this->assertEquals('greet_user', $greetUserTool->tool->name); +$this->assertArrayHasKey('name', $greetUserTool->tool->inputSchema['properties'] ?? []); +$this->assertEqualsCanonicalizing(['name', 'age', 'active', 'tags'], $schema['required']); +``` + +### Test Data Organization + +- Use private helper methods for test data generation +- Follow naming pattern: `get{DataType}Data()` or `get{Purpose}Schema()` +- Return type-hinted arrays with PHPDoc when complex + +```php +/** + * @return array{ + * name: string, + * age: int, + * active: bool, + * score: float, + * items: string[], + * nullableValue: null, + * optionalValue: string + * } + */ +private function getValidData(): array +{ + return [ + 'name' => 'Tester', + 'age' => 30, + 'active' => true, + 'score' => 99.5, + 'items' => ['a', 'b'], + 'nullableValue' => null, + 'optionalValue' => 'present', + ]; +} +``` + +## 7. PHPDoc Documentation Standards + +### Class Documentation + +```php +/** + * A stub class for testing DocBlock parsing. + * + * @author Author Name + */ +class DocBlockTestFixture +{ +} +``` + +### Method Documentation + +- Always include `@param` for parameters with descriptions +- Include `@return` for non-void methods +- Use `@throws` for exceptions +- Include method description for complex methods + +```php +/** + * Method with various parameter tags. + * + * @param string $param1 description for string param + * @param int|null $param2 description for nullable int param + * @param bool $param3 nothing to say + * @param $param4 Missing type + * @param array $param5 array description + * @param \stdClass $param6 object param + */ +public function methodWithParams(string $param1, ?int $param2, bool $param3, $param4, array $param5, \stdClass $param6): void +``` + +### Complex Type Documentation + +Use PHPStan-style type definitions for complex structures: + +```php +/** + * @phpstan-type DiscoveredCount array{ + * tools: int, + * resources: int, + * prompts: int, + * resourceTemplates: int, + * } + */ +``` + +## 8. Code Quality Rules + +### Error Handling + +- Use specific exception types where available +- Always provide meaningful error messages +- Log errors appropriately in service classes + +```php +try { + $reflectionClass = new \ReflectionClass($className); + // process class +} catch (\ReflectionException $e) { + $this->logger->error('Reflection error processing file for MCP discovery', [ + 'file' => $filePath, + 'class' => $className, + 'exception' => $e->getMessage() + ]); +} +``` + +### Method Complexity + +- Keep methods focused on single responsibilities +- Extract complex logic into private helper methods +- Test methods can be longer but should remain readable +- Use early returns to reduce nesting + +### Null Safety + +- Use nullable type hints (`?Type`) appropriately +- Check for null values before usage +- Prefer null coalescing operator (`??`) when appropriate + +```php +$docComment = $method->getDocComment() ?: null; +$docBlock = $this->parser->parseDocBlock($docComment); +$name = $instance->name ?? ('__invoke' === $methodName ? $classShortName : $methodName); +``` + +## 9. Framework-Specific Guidelines + +### Attribute Usage + +The codebase uses PHP 8 attributes extensively: + +```php +#[McpTool(name: 'greet_user', description: 'Greets a user by name.')] +public function greet(string $name): string +{ + return "Hello, {$name}!"; +} + +#[McpResource( + uri: 'app://info/version', + name: 'app_version', + description: 'The current version of the application.', + mimeType: 'text/plain', + size: 10 +)] +public function getAppVersion(): string +{ + return '1.2.3-discovered'; +} +``` + +### Registry Pattern + +Use the registry pattern for managing discovered elements: + +```php +$this->registry->registerTool($tool, [$className, $methodName]); +$this->registry->registerResource($resource, [$className, $methodName]); +``` + +### Reflection Usage + +Use reflection appropriately for discovery mechanisms: + +```php +$reflectionClass = new \ReflectionClass($className); +if ($reflectionClass->isAbstract() || $reflectionClass->isInterface()) { + return; +} + +foreach ($reflectionClass->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { + // process methods +} +``` + +## 10. Example Templates + +### Basic Test Class Template + +```php +service = new ExampleService(); + } + + public function testExampleBehavior(): void + { + $result = $this->service->performAction(); + + $this->assertInstanceOf(ExpectedResult::class, $result); + } + + private function getTestData(): array + { + return ['key' => 'value']; + } +} +``` + +### Fixture Class Template + +```php +value = 'test'; + return $result; + } +} +``` \ No newline at end of file diff --git a/src/Capability/DispatchableRegistry.php b/src/Capability/DispatchableRegistry.php index f45b5ab2..38f39dcf 100644 --- a/src/Capability/DispatchableRegistry.php +++ b/src/Capability/DispatchableRegistry.php @@ -71,7 +71,6 @@ public function registerPrompt( bool $isManual = false, ): void { $this->referenceProvider->registerPrompt($prompt, $handler, $completionProviders, $isManual); - $this->eventDispatcher?->dispatch(new PromptListChangedEvent()); } diff --git a/src/Capability/Prompt/DefaultPromptGetter.php b/src/Capability/Prompt/DefaultPromptGetter.php index a86aa750..3982619f 100644 --- a/src/Capability/Prompt/DefaultPromptGetter.php +++ b/src/Capability/Prompt/DefaultPromptGetter.php @@ -42,7 +42,7 @@ public function get(GetPromptRequest $request): GetPromptResult return new GetPromptResult( $reference->formatResult( - $this->referenceHandler->handle($reference, $request->arguments), + $this->referenceHandler->handle($reference, $request->arguments ?? []), ), ); } diff --git a/src/Capability/Registry/ResourceTemplateReference.php b/src/Capability/Registry/ResourceTemplateReference.php index 3ef1d6cb..802827db 100644 --- a/src/Capability/Registry/ResourceTemplateReference.php +++ b/src/Capability/Registry/ResourceTemplateReference.php @@ -162,7 +162,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 b352cf42..00000000 --- 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/tests/Capability/Prompt/DefaultPromptGetterTest.php b/tests/Capability/Prompt/DefaultPromptGetterTest.php new file mode 100644 index 00000000..81c0d396 --- /dev/null +++ b/tests/Capability/Prompt/DefaultPromptGetterTest.php @@ -0,0 +1,637 @@ +referenceProvider = $this->createMock(ReferenceProviderInterface::class); + $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); + + $this->promptGetter = new DefaultPromptGetter( + $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(\InvalidArgumentException::class); + $this->expectExceptionMessage('Prompt "nonexistent_prompt" is not registered.'); + + $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(RegistryException::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(\JsonException::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 testGetHandlesDifferentExceptionTypes(): void + { + $request = new GetPromptRequest('error_prompt', []); + $prompt = $this->createValidPrompt('error_prompt'); + $promptReference = new PromptReference($prompt, fn () => throw new \InvalidArgumentException('Invalid input')); + $handlerException = new \InvalidArgumentException('Invalid input'); + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('error_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, []) + ->willThrowException($handlerException); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid input'); + + $this->promptGetter->get($request); + } + + 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' => 'SomeCompletionProvider']; + $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(RuntimeException::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(RuntimeException::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(RuntimeException::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(RuntimeException::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(RuntimeException::class); + $this->expectExceptionMessage('Prompt generator method must return an array of messages.'); + + $this->promptGetter->get($request); + } + + private function createValidPrompt(string $name): Prompt + { + return new Prompt( + name: $name, + description: "Test prompt: {$name}", + arguments: null, + ); + } +} diff --git a/tests/Capability/Registry/DispatchableRegistryTest.php b/tests/Capability/Registry/DispatchableRegistryTest.php index 86de71ff..ca9fdf71 100644 --- a/tests/Capability/Registry/DispatchableRegistryTest.php +++ b/tests/Capability/Registry/DispatchableRegistryTest.php @@ -41,7 +41,7 @@ protected function setUp(): void public function testConstructorWithoutEventDispatcher(): void { $dispatchableRegistry = new DispatchableRegistry($this->referenceRegistry); - + $this->assertInstanceOf(DispatchableRegistry::class, $dispatchableRegistry); } @@ -100,7 +100,7 @@ public function testGetCapabilitiesWithoutEventDispatcher(): void public function testRegisterToolDelegatesToReferenceRegistryAndDispatchesEvent(): void { $tool = $this->createValidTool('test_tool'); - $handler = fn() => 'result'; + $handler = fn () => 'result'; $this->referenceRegistry->expects($this->once()) ->method('registerTool') @@ -116,7 +116,7 @@ public function testRegisterToolDelegatesToReferenceRegistryAndDispatchesEvent() public function testRegisterToolWithManualFlag(): void { $tool = $this->createValidTool('test_tool'); - $handler = fn() => 'result'; + $handler = fn () => 'result'; $this->referenceRegistry->expects($this->once()) ->method('registerTool') @@ -133,7 +133,7 @@ public function testRegisterToolWithoutEventDispatcher(): void { $dispatchableRegistry = new DispatchableRegistry($this->referenceRegistry, null); $tool = $this->createValidTool('test_tool'); - $handler = fn() => 'result'; + $handler = fn () => 'result'; $this->referenceRegistry->expects($this->once()) ->method('registerTool') @@ -146,7 +146,7 @@ public function testRegisterToolWithoutEventDispatcher(): void public function testRegisterResourceDelegatesToReferenceRegistryAndDispatchesEvent(): void { $resource = $this->createValidResource('test://resource'); - $handler = fn() => 'content'; + $handler = fn () => 'content'; $this->referenceRegistry->expects($this->once()) ->method('registerResource') @@ -162,7 +162,7 @@ public function testRegisterResourceDelegatesToReferenceRegistryAndDispatchesEve public function testRegisterResourceWithManualFlag(): void { $resource = $this->createValidResource('test://resource'); - $handler = fn() => 'content'; + $handler = fn () => 'content'; $this->referenceRegistry->expects($this->once()) ->method('registerResource') @@ -179,7 +179,7 @@ public function testRegisterResourceWithoutEventDispatcher(): void { $dispatchableRegistry = new DispatchableRegistry($this->referenceRegistry, null); $resource = $this->createValidResource('test://resource'); - $handler = fn() => 'content'; + $handler = fn () => 'content'; $this->referenceRegistry->expects($this->once()) ->method('registerResource') @@ -191,7 +191,7 @@ public function testRegisterResourceWithoutEventDispatcher(): void public function testRegisterResourceTemplateDelegatesToReferenceRegistryAndDispatchesEvent(): void { $template = $this->createValidResourceTemplate('test://{id}'); - $handler = fn() => 'content'; + $handler = fn () => 'content'; $completionProviders = ['id' => 'TestProvider']; $this->referenceRegistry->expects($this->once()) @@ -208,7 +208,7 @@ public function testRegisterResourceTemplateDelegatesToReferenceRegistryAndDispa public function testRegisterResourceTemplateWithDefaults(): void { $template = $this->createValidResourceTemplate('test://{id}'); - $handler = fn() => 'content'; + $handler = fn () => 'content'; $this->referenceRegistry->expects($this->once()) ->method('registerResourceTemplate') @@ -224,7 +224,7 @@ public function testRegisterResourceTemplateWithDefaults(): void public function testRegisterResourceTemplateWithManualFlag(): void { $template = $this->createValidResourceTemplate('test://{id}'); - $handler = fn() => 'content'; + $handler = fn () => 'content'; $this->referenceRegistry->expects($this->once()) ->method('registerResourceTemplate') @@ -241,7 +241,7 @@ public function testRegisterResourceTemplateWithoutEventDispatcher(): void { $dispatchableRegistry = new DispatchableRegistry($this->referenceRegistry, null); $template = $this->createValidResourceTemplate('test://{id}'); - $handler = fn() => 'content'; + $handler = fn () => 'content'; $this->referenceRegistry->expects($this->once()) ->method('registerResourceTemplate') @@ -253,7 +253,7 @@ public function testRegisterResourceTemplateWithoutEventDispatcher(): void public function testRegisterPromptDelegatesToReferenceRegistryAndDispatchesEvent(): void { $prompt = $this->createValidPrompt('test_prompt'); - $handler = fn() => []; + $handler = fn () => []; $completionProviders = ['param' => 'TestProvider']; $this->referenceRegistry->expects($this->once()) @@ -270,7 +270,7 @@ public function testRegisterPromptDelegatesToReferenceRegistryAndDispatchesEvent public function testRegisterPromptWithDefaults(): void { $prompt = $this->createValidPrompt('test_prompt'); - $handler = fn() => []; + $handler = fn () => []; $this->referenceRegistry->expects($this->once()) ->method('registerPrompt') @@ -286,7 +286,7 @@ public function testRegisterPromptWithDefaults(): void public function testRegisterPromptWithManualFlag(): void { $prompt = $this->createValidPrompt('test_prompt'); - $handler = fn() => []; + $handler = fn () => []; $this->referenceRegistry->expects($this->once()) ->method('registerPrompt') @@ -303,7 +303,7 @@ public function testRegisterPromptWithoutEventDispatcher(): void { $dispatchableRegistry = new DispatchableRegistry($this->referenceRegistry, null); $prompt = $this->createValidPrompt('test_prompt'); - $handler = fn() => []; + $handler = fn () => []; $this->referenceRegistry->expects($this->once()) ->method('registerPrompt') @@ -355,7 +355,7 @@ public function testRegisterToolHandlesArrayHandler(): void public function testRegisterResourceHandlesCallableHandler(): void { $resource = $this->createValidResource('test://resource'); - $handler = fn() => 'content'; + $handler = fn () => 'content'; $this->referenceRegistry->expects($this->once()) ->method('registerResource') @@ -375,9 +375,9 @@ private function createValidTool(string $name): Tool inputSchema: [ 'type' => 'object', 'properties' => [ - 'param' => ['type' => 'string'] + 'param' => ['type' => 'string'], ], - 'required' => null + 'required' => null, ], description: "Test tool: {$name}", annotations: null diff --git a/tests/Capability/Registry/RegistryTest.php b/tests/Capability/Registry/RegistryTest.php index a8b43fd6..c3a296c3 100644 --- a/tests/Capability/Registry/RegistryTest.php +++ b/tests/Capability/Registry/RegistryTest.php @@ -12,17 +12,12 @@ namespace Mcp\Tests\Capability\Registry; use Mcp\Capability\Registry; -use Mcp\Event\PromptListChangedEvent; -use Mcp\Event\ResourceListChangedEvent; -use Mcp\Event\ResourceTemplateListChangedEvent; -use Mcp\Event\ToolListChangedEvent; use Mcp\Schema\Prompt; use Mcp\Schema\Resource; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\ServerCapabilities; use Mcp\Schema\Tool; use PHPUnit\Framework\TestCase; -use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; class RegistryTest extends TestCase @@ -68,10 +63,10 @@ public function testGetCapabilitiesWhenPopulated(): void $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'); + $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(); @@ -86,7 +81,7 @@ public function testGetCapabilitiesWhenPopulated(): void public function testRegisterToolWithManualFlag(): void { $tool = $this->createValidTool('test_tool'); - $handler = fn() => 'result'; + $handler = fn () => 'result'; $this->registry->registerTool($tool, $handler, true); @@ -99,14 +94,14 @@ public function testRegisterToolIgnoresDiscoveredWhenManualExists(): void $manualTool = $this->createValidTool('test_tool'); $discoveredTool = $this->createValidTool('test_tool'); - $this->registry->registerTool($manualTool, fn() => 'manual', true); + $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); + $this->registry->registerTool($discoveredTool, fn () => 'discovered', false); $toolRef = $this->registry->getTool('test_tool'); $this->assertTrue($toolRef->isManual); @@ -117,8 +112,8 @@ 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); + $this->registry->registerTool($discoveredTool, fn () => 'discovered', false); + $this->registry->registerTool($manualTool, fn () => 'manual', true); $toolRef = $this->registry->getTool('test_tool'); $this->assertTrue($toolRef->isManual); @@ -127,7 +122,7 @@ public function testRegisterToolOverridesDiscoveredWithManual(): void public function testRegisterResourceWithManualFlag(): void { $resource = $this->createValidResource('test://resource'); - $handler = fn() => 'content'; + $handler = fn () => 'content'; $this->registry->registerResource($resource, $handler, true); @@ -140,14 +135,14 @@ public function testRegisterResourceIgnoresDiscoveredWhenManualExists(): void $manualResource = $this->createValidResource('test://resource'); $discoveredResource = $this->createValidResource('test://resource'); - $this->registry->registerResource($manualResource, fn() => 'manual', true); + $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); + $this->registry->registerResource($discoveredResource, fn () => 'discovered', false); $resourceRef = $this->registry->getResource('test://resource'); $this->assertTrue($resourceRef->isManual); @@ -158,7 +153,7 @@ public function testRegisterResourceTemplateWithCompletionProviders(): void $template = $this->createValidResourceTemplate('test://{id}'); $completionProviders = ['id' => 'TestProvider']; - $this->registry->registerResourceTemplate($template, fn() => 'content', $completionProviders); + $this->registry->registerResourceTemplate($template, fn () => 'content', $completionProviders); $templateRef = $this->registry->getResourceTemplate('test://{id}'); $this->assertEquals($completionProviders, $templateRef->completionProviders); @@ -169,14 +164,14 @@ public function testRegisterResourceTemplateIgnoresDiscoveredWhenManualExists(): $manualTemplate = $this->createValidResourceTemplate('test://{id}'); $discoveredTemplate = $this->createValidResourceTemplate('test://{id}'); - $this->registry->registerResourceTemplate($manualTemplate, fn() => 'manual', [], true); + $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); + $this->registry->registerResourceTemplate($discoveredTemplate, fn () => 'discovered', [], false); $templateRef = $this->registry->getResourceTemplate('test://{id}'); $this->assertTrue($templateRef->isManual); @@ -187,7 +182,7 @@ public function testRegisterPromptWithCompletionProviders(): void $prompt = $this->createValidPrompt('test_prompt'); $completionProviders = ['param' => 'TestProvider']; - $this->registry->registerPrompt($prompt, fn() => [], $completionProviders); + $this->registry->registerPrompt($prompt, fn () => [], $completionProviders); $promptRef = $this->registry->getPrompt('test_prompt'); $this->assertEquals($completionProviders, $promptRef->completionProviders); @@ -198,14 +193,14 @@ public function testRegisterPromptIgnoresDiscoveredWhenManualExists(): void $manualPrompt = $this->createValidPrompt('test_prompt'); $discoveredPrompt = $this->createValidPrompt('test_prompt'); - $this->registry->registerPrompt($manualPrompt, fn() => 'manual', [], true); + $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); + $this->registry->registerPrompt($discoveredPrompt, fn () => 'discovered', [], false); $promptRef = $this->registry->getPrompt('test_prompt'); $this->assertTrue($promptRef->isManual); @@ -222,14 +217,14 @@ public function testClearRemovesOnlyDiscoveredElements(): void $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->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()) @@ -253,7 +248,7 @@ public function testClearRemovesOnlyDiscoveredElements(): void public function testClearLogsNothingWhenNoDiscoveredElements(): void { $manualTool = $this->createValidTool('manual_tool'); - $this->registry->registerTool($manualTool, fn() => 'manual', true); + $this->registry->registerTool($manualTool, fn () => 'manual', true); $this->logger ->expects($this->never()) @@ -289,7 +284,7 @@ public function testRegisterToolHandlesArrayHandler(): void public function testRegisterResourceHandlesCallableHandler(): void { $resource = $this->createValidResource('test://resource'); - $handler = fn() => 'content'; + $handler = fn () => 'content'; $this->registry->registerResource($resource, $handler); @@ -302,8 +297,8 @@ 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); + $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'); diff --git a/tests/Capability/Resource/DefaultResourceReaderTest.php b/tests/Capability/Resource/DefaultResourceReaderTest.php new file mode 100644 index 00000000..97e8103b --- /dev/null +++ b/tests/Capability/Resource/DefaultResourceReaderTest.php @@ -0,0 +1,496 @@ +referenceProvider = $this->createMock(ReferenceProviderInterface::class); + $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); + + $this->resourceReader = new DefaultResourceReader( + $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(\InvalidArgumentException::class); + $this->expectExceptionMessage('Resource "nonexistent://resource" is not registered.'); + + $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(RegistryException::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); + } + + 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/DefaultToolExecutorTest.php b/tests/Capability/Tool/DefaultToolExecutorTest.php new file mode 100644 index 00000000..115d203f --- /dev/null +++ b/tests/Capability/Tool/DefaultToolExecutorTest.php @@ -0,0 +1,627 @@ +referenceProvider = $this->createMock(ReferenceProviderInterface::class); + $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->toolExecutor = new DefaultToolExecutor( + $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->toolExecutor->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->toolExecutor->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->toolExecutor->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->toolExecutor->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(ToolExecutionException::class); + $this->expectExceptionMessage('Execution of tool "failing_tool" failed with error: "Handler failed".'); + + $thrownException = null; + try { + $this->toolExecutor->call($request); + } catch (ToolExecutionException $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->toolExecutor->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->toolExecutor->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->toolExecutor->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->toolExecutor->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->toolExecutor->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(ToolExecutionException::class); + $this->expectExceptionMessage('Execution of tool "error_tool" failed with error: "Invalid input".'); + + $this->toolExecutor->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->toolExecutor->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->toolExecutor->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->toolExecutor->call($request); + + $this->assertInstanceOf(CallToolResult::class, $result); + } + + public function testConstructorWithDefaultLogger(): void + { + $executor = new DefaultToolExecutor($this->referenceProvider, $this->referenceHandler); + + // Verify it's constructed without throwing exceptions + $this->assertInstanceOf(DefaultToolExecutor::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->toolExecutor->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->toolExecutor->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->toolExecutor->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->toolExecutor->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 index dc54d98a..b3c9e2a2 100644 --- a/tests/Schema/ServerCapabilitiesTest.php +++ b/tests/Schema/ServerCapabilitiesTest.php @@ -35,7 +35,7 @@ public function testConstructorWithDefaults(): void public function testConstructorWithAllParameters(): void { $experimental = ['feature1' => true, 'feature2' => 'enabled']; - + $capabilities = new ServerCapabilities( tools: false, toolsListChanged: true, @@ -190,7 +190,7 @@ public function testFromArrayWithBasicCapabilities(): void public function testFromArrayWithPromptsArrayListChanged(): void { $data = [ - 'prompts' => ['listChanged' => true] + 'prompts' => ['listChanged' => true], ]; $capabilities = ServerCapabilities::fromArray($data); @@ -205,7 +205,7 @@ public function testFromArrayWithPromptsObjectListChanged(): void $prompts->listChanged = true; $data = [ - 'prompts' => $prompts + 'prompts' => $prompts, ]; $capabilities = ServerCapabilities::fromArray($data); @@ -219,8 +219,8 @@ public function testFromArrayWithResourcesArraySubscribeAndListChanged(): void $data = [ 'resources' => [ 'subscribe' => true, - 'listChanged' => false - ] + 'listChanged' => false, + ], ]; $capabilities = ServerCapabilities::fromArray($data); @@ -237,7 +237,7 @@ public function testFromArrayWithResourcesObjectSubscribeAndListChanged(): void $resources->listChanged = true; $data = [ - 'resources' => $resources + 'resources' => $resources, ]; $capabilities = ServerCapabilities::fromArray($data); @@ -250,7 +250,7 @@ public function testFromArrayWithResourcesObjectSubscribeAndListChanged(): void public function testFromArrayWithToolsArrayListChanged(): void { $data = [ - 'tools' => ['listChanged' => false] + 'tools' => ['listChanged' => false], ]; $capabilities = ServerCapabilities::fromArray($data); @@ -265,7 +265,7 @@ public function testFromArrayWithToolsObjectListChanged(): void $tools->listChanged = true; $data = [ - 'tools' => $tools + 'tools' => $tools, ]; $capabilities = ServerCapabilities::fromArray($data); @@ -278,7 +278,7 @@ public function testFromArrayWithExperimental(): void { $experimental = ['feature1' => true, 'feature2' => 'test']; $data = [ - 'experimental' => $experimental + 'experimental' => $experimental, ]; $capabilities = ServerCapabilities::fromArray($data); @@ -292,12 +292,12 @@ public function testFromArrayWithComplexData(): void 'tools' => ['listChanged' => true], 'resources' => [ 'subscribe' => true, - 'listChanged' => false + 'listChanged' => false, ], 'prompts' => ['listChanged' => true], 'logging' => new \stdClass(), 'completions' => new \stdClass(), - 'experimental' => ['customFeature' => 'enabled'] + 'experimental' => ['customFeature' => 'enabled'], ]; $capabilities = ServerCapabilities::fromArray($data); @@ -353,14 +353,14 @@ public function testJsonSerializeWithAllFeaturesEnabled(): void $this->assertEquals(new \stdClass(), $json['completions']); $this->assertArrayHasKey('prompts', $json); - $this->assertEquals(true, $json['prompts']->listChanged); + $this->assertTrue($json['prompts']->listChanged); $this->assertArrayHasKey('resources', $json); - $this->assertEquals(true, $json['resources']->subscribe); - $this->assertEquals(true, $json['resources']->listChanged); + $this->assertTrue($json['resources']->subscribe); + $this->assertTrue($json['resources']->listChanged); $this->assertArrayHasKey('tools', $json); - $this->assertEquals(true, $json['tools']->listChanged); + $this->assertTrue($json['tools']->listChanged); $this->assertArrayHasKey('experimental', $json); $this->assertEquals((object) $experimental, $json['experimental']); @@ -450,7 +450,7 @@ public function testFromArrayHandlesEdgeCasesGracefully(): void $data = [ 'prompts' => [], 'resources' => [], - 'tools' => [] + 'tools' => [], ]; $capabilities = ServerCapabilities::fromArray($data); diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 559d5db2..3e391a71 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -11,7 +11,6 @@ namespace Mcp\Tests; -use Mcp\JsonRpc\Handler; use Mcp\JsonRpc\HandlerInterface; use Mcp\Server; use Mcp\Server\Transport\InMemoryTransport; From 7237665205df470e63f7a7dc7f4a3be99a3bdc6d Mon Sep 17 00:00:00 2001 From: butschster Date: Mon, 8 Sep 2025 01:32:03 +0400 Subject: [PATCH 09/26] cs fix --- .../Capability/Prompt/DefaultPromptGetterTest.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/Capability/Prompt/DefaultPromptGetterTest.php b/tests/Capability/Prompt/DefaultPromptGetterTest.php index 81c0d396..0203193c 100644 --- a/tests/Capability/Prompt/DefaultPromptGetterTest.php +++ b/tests/Capability/Prompt/DefaultPromptGetterTest.php @@ -183,7 +183,7 @@ 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()) @@ -462,7 +462,7 @@ 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', @@ -490,7 +490,7 @@ public function testGetReflectsFormattedMessagesCorrectly(): void } /** - * Test that invalid handler results throw RuntimeException from PromptReference.formatResult() + * Test that invalid handler results throw RuntimeException from PromptReference.formatResult(). */ public function testGetThrowsRuntimeExceptionForInvalidHandlerResult(): void { @@ -517,7 +517,7 @@ public function testGetThrowsRuntimeExceptionForInvalidHandlerResult(): void } /** - * Test that null result from handler throws RuntimeException + * Test that null result from handler throws RuntimeException. */ public function testGetThrowsRuntimeExceptionForNullHandlerResult(): void { @@ -544,7 +544,7 @@ public function testGetThrowsRuntimeExceptionForNullHandlerResult(): void } /** - * Test that scalar result from handler throws RuntimeException + * Test that scalar result from handler throws RuntimeException. */ public function testGetThrowsRuntimeExceptionForScalarHandlerResult(): void { @@ -571,7 +571,7 @@ public function testGetThrowsRuntimeExceptionForScalarHandlerResult(): void } /** - * Test that boolean result from handler throws RuntimeException + * Test that boolean result from handler throws RuntimeException. */ public function testGetThrowsRuntimeExceptionForBooleanHandlerResult(): void { @@ -598,7 +598,7 @@ public function testGetThrowsRuntimeExceptionForBooleanHandlerResult(): void } /** - * Test that object result from handler throws RuntimeException + * Test that object result from handler throws RuntimeException. */ public function testGetThrowsRuntimeExceptionForObjectHandlerResult(): void { From 1f160f958e113c149b0d8aeb7c5e5aa7f9268bac Mon Sep 17 00:00:00 2001 From: butschster Date: Mon, 8 Sep 2025 02:36:47 +0400 Subject: [PATCH 10/26] phpstan fix --- phpstan-baseline.neon | 120 ------------------ src/Capability/Registry.php | 35 ----- .../Registry/ReferenceRegistryInterface.php | 10 +- .../Registry/ResourceTemplateReference.php | 3 +- src/Capability/Tool/DefaultToolExecutor.php | 5 + src/Schema/ServerCapabilities.php | 2 +- .../Prompt/DefaultPromptGetterTest.php | 8 +- .../Registry/DispatchableRegistryTest.php | 10 +- tests/Capability/Registry/RegistryTest.php | 8 +- .../Resource/DefaultResourceReaderTest.php | 5 +- .../Tool/DefaultToolExecutorTest.php | 7 +- 11 files changed, 36 insertions(+), 177 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index c550bc03..ba5e4509 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -402,96 +402,6 @@ parameters: 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 - count: 1 - path: src/Capability/Registry.php - - - - message: '#^Parameter \$handler of method Mcp\\Capability\\Registry\:\:registerResource\(\) has invalid type Mcp\\Capability\\CallableArray\.$#' - identifier: class.notFound - count: 1 - path: src/Capability/Registry.php - - - - message: '#^Parameter \$handler of method Mcp\\Capability\\Registry\:\:registerResourceTemplate\(\) has invalid type Mcp\\Capability\\CallableArray\.$#' - identifier: class.notFound - count: 1 - path: src/Capability/Registry.php - - - - message: '#^Parameter \$handler of method Mcp\\Capability\\Registry\:\:registerTool\(\) has invalid type Mcp\\Capability\\CallableArray\.$#' - identifier: class.notFound - count: 1 - path: src/Capability/Registry.php - - - - message: '#^Call to an undefined method Mcp\\Capability\\Registry\\ResourceTemplateReference\:\:handle\(\)\.$#' - identifier: method.notFound - count: 1 - path: src/Capability/Registry/ResourceTemplateReference.php - - message: '#^PHPDoc tag @return with type array is incompatible with native type object\.$#' identifier: return.phpDocType @@ -504,30 +414,6 @@ parameters: 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 - count: 1 - path: src/Server/RequestHandler/ListPromptsHandler.php - - - - message: '#^Strict comparison using \!\=\= between null and null will always evaluate to false\.$#' - identifier: notIdentical.alwaysFalse - count: 1 - path: src/Server/RequestHandler/ListPromptsHandler.php - - - - message: '#^Method Mcp\\Capability\\Registry\:\: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 @@ -540,12 +426,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/Registry.php b/src/Capability/Registry.php index 5bf791da..bc21b5ad 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -11,7 +11,6 @@ namespace Mcp\Capability; -use Mcp\Capability\Registry\ElementReference; use Mcp\Capability\Registry\PromptReference; use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Capability\Registry\ReferenceRegistryInterface; @@ -27,8 +26,6 @@ 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. @@ -82,9 +79,6 @@ public function getCapabilities(): ServerCapabilities ); } - /** - * @param callable|CallableArray|string $handler - */ public function registerTool(Tool $tool, callable|array|string $handler, bool $isManual = false): void { $toolName = $tool->name; @@ -101,9 +95,6 @@ public function registerTool(Tool $tool, callable|array|string $handler, bool $i $this->tools[$toolName] = new ToolReference($tool, $handler, $isManual); } - /** - * @param callable|CallableArray|string $handler - */ public function registerResource(Resource $resource, callable|array|string $handler, bool $isManual = false): void { $uri = $resource->uri; @@ -120,10 +111,6 @@ public function registerResource(Resource $resource, callable|array|string $hand $this->resources[$uri] = new ResourceReference($resource, $handler, $isManual); } - /** - * @param callable|CallableArray|string $handler - * @param array $completionProviders - */ public function registerResourceTemplate( ResourceTemplate $template, callable|array|string $handler, @@ -149,10 +136,6 @@ public function registerResourceTemplate( ); } - /** - * @param callable|CallableArray|string $handler - * @param array $completionProviders - */ public function registerPrompt( Prompt $prompt, callable|array|string $handler, @@ -173,9 +156,6 @@ public function registerPrompt( $this->prompts[$promptName] = new PromptReference($prompt, $handler, $isManual, $completionProviders); } - /** - * Clear discovered elements from registry. - */ public function clear(): void { $clearCount = 0; @@ -249,42 +229,27 @@ 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 (ResourceTemplateReference $template) => $template->resourceTemplate, $this->resourceTemplates); } - /** - * Checks if any elements (manual or discovered) are currently registered. - */ public function hasElements(): bool { return !empty($this->tools) diff --git a/src/Capability/Registry/ReferenceRegistryInterface.php b/src/Capability/Registry/ReferenceRegistryInterface.php index c9b779a0..2d90de71 100644 --- a/src/Capability/Registry/ReferenceRegistryInterface.php +++ b/src/Capability/Registry/ReferenceRegistryInterface.php @@ -18,7 +18,7 @@ use Mcp\Schema\Tool; /** - * @phpstan-import-type CallableArray from ElementReference + * @phpstan-import-type Handler from ElementReference * * Interface for registering MCP elements. * Separates the concern of registering elements from accessing them. @@ -35,21 +35,21 @@ public function getCapabilities(): ServerCapabilities; /** * Registers a tool with its handler. * - * @param callable|CallableArray|string $handler + * @param Handler $handler */ public function registerTool(Tool $tool, callable|array|string $handler, bool $isManual = false): void; /** * Registers a resource with its handler. * - * @param callable|CallableArray|string $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 callable|CallableArray|string $handler + * @param Handler $handler * @param array $completionProviders */ public function registerResourceTemplate( @@ -62,7 +62,7 @@ public function registerResourceTemplate( /** * Registers a prompt with its handler and completion providers. * - * @param callable|CallableArray|string $handler + * @param Handler $handler * @param array $completionProviders */ public function registerPrompt( diff --git a/src/Capability/Registry/ResourceTemplateReference.php b/src/Capability/Registry/ResourceTemplateReference.php index 802827db..322e6a57 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 */ diff --git a/src/Capability/Tool/DefaultToolExecutor.php b/src/Capability/Tool/DefaultToolExecutor.php index 4dec98c4..12d5a953 100644 --- a/src/Capability/Tool/DefaultToolExecutor.php +++ b/src/Capability/Tool/DefaultToolExecutor.php @@ -15,6 +15,10 @@ use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Exception\ToolExecutionException; use Mcp\Exception\ToolNotFoundException; +use Mcp\Schema\Content\AudioContent; +use Mcp\Schema\Content\EmbeddedResource; +use Mcp\Schema\Content\ImageContent; +use Mcp\Schema\Content\TextContent; use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\Result\CallToolResult; use Psr\Log\LoggerInterface; @@ -55,6 +59,7 @@ public function call(CallToolRequest $request): CallToolResult try { $result = $this->referenceHandler->handle($toolReference, $arguments); + /** @var TextContent[]|ImageContent[]|EmbeddedResource[]|AudioContent[] $formattedResult */ $formattedResult = $toolReference->formatResult($result); $this->logger->debug('Tool executed successfully', [ diff --git a/src/Schema/ServerCapabilities.php b/src/Schema/ServerCapabilities.php index c820772c..9169be26 100644 --- a/src/Schema/ServerCapabilities.php +++ b/src/Schema/ServerCapabilities.php @@ -66,7 +66,7 @@ public function withEvents(): self * completions?: mixed, * prompts?: array{listChanged?: bool}|object, * resources?: array{listChanged?: bool, subscribe?: bool}|object, - * tools?: object, + * tools?: object|array{listChanged?: bool}, * experimental?: array, * } $data */ diff --git a/tests/Capability/Prompt/DefaultPromptGetterTest.php b/tests/Capability/Prompt/DefaultPromptGetterTest.php index 0203193c..f0ef5717 100644 --- a/tests/Capability/Prompt/DefaultPromptGetterTest.php +++ b/tests/Capability/Prompt/DefaultPromptGetterTest.php @@ -11,6 +11,7 @@ namespace Mcp\Tests\Capability\Prompt; +use Mcp\Capability\Prompt\Completion\EnumCompletionProvider; use Mcp\Capability\Prompt\DefaultPromptGetter; use Mcp\Capability\Registry\PromptReference; use Mcp\Capability\Registry\ReferenceHandlerInterface; @@ -23,13 +24,14 @@ use Mcp\Schema\Prompt; use Mcp\Schema\Request\GetPromptRequest; use Mcp\Schema\Result\GetPromptResult; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; class DefaultPromptGetterTest extends TestCase { private DefaultPromptGetter $promptGetter; - private ReferenceProviderInterface $referenceProvider; - private ReferenceHandlerInterface $referenceHandler; + private ReferenceProviderInterface|MockObject $referenceProvider; + private ReferenceHandlerInterface|MockObject $referenceHandler; protected function setUp(): void { @@ -393,7 +395,7 @@ public function testGetWithPromptReferenceHavingCompletionProviders(): void { $request = new GetPromptRequest('completion_prompt', ['param' => 'value']); $prompt = $this->createValidPrompt('completion_prompt'); - $completionProviders = ['param' => 'SomeCompletionProvider']; + $completionProviders = ['param' => EnumCompletionProvider::class]; $promptReference = new PromptReference( $prompt, fn () => 'Completion content', diff --git a/tests/Capability/Registry/DispatchableRegistryTest.php b/tests/Capability/Registry/DispatchableRegistryTest.php index ca9fdf71..9d2a44fb 100644 --- a/tests/Capability/Registry/DispatchableRegistryTest.php +++ b/tests/Capability/Registry/DispatchableRegistryTest.php @@ -12,6 +12,7 @@ namespace Capability\Registry; use Mcp\Capability\DispatchableRegistry; +use Mcp\Capability\Prompt\Completion\EnumCompletionProvider; use Mcp\Capability\Registry\ReferenceRegistryInterface; use Mcp\Event\PromptListChangedEvent; use Mcp\Event\ResourceListChangedEvent; @@ -22,13 +23,14 @@ use Mcp\Schema\ResourceTemplate; use Mcp\Schema\ServerCapabilities; use Mcp\Schema\Tool; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\EventDispatcher\EventDispatcherInterface; class DispatchableRegistryTest extends TestCase { - private ReferenceRegistryInterface $referenceRegistry; - private EventDispatcherInterface $eventDispatcher; + private ReferenceRegistryInterface|MockObject $referenceRegistry; + private EventDispatcherInterface|MockObject $eventDispatcher; private DispatchableRegistry $dispatchableRegistry; protected function setUp(): void @@ -192,7 +194,7 @@ public function testRegisterResourceTemplateDelegatesToReferenceRegistryAndDispa { $template = $this->createValidResourceTemplate('test://{id}'); $handler = fn () => 'content'; - $completionProviders = ['id' => 'TestProvider']; + $completionProviders = ['id' => EnumCompletionProvider::class]; $this->referenceRegistry->expects($this->once()) ->method('registerResourceTemplate') @@ -254,7 +256,7 @@ public function testRegisterPromptDelegatesToReferenceRegistryAndDispatchesEvent { $prompt = $this->createValidPrompt('test_prompt'); $handler = fn () => []; - $completionProviders = ['param' => 'TestProvider']; + $completionProviders = ['param' => EnumCompletionProvider::class]; $this->referenceRegistry->expects($this->once()) ->method('registerPrompt') diff --git a/tests/Capability/Registry/RegistryTest.php b/tests/Capability/Registry/RegistryTest.php index c3a296c3..1167617b 100644 --- a/tests/Capability/Registry/RegistryTest.php +++ b/tests/Capability/Registry/RegistryTest.php @@ -11,19 +11,21 @@ namespace Mcp\Tests\Capability\Registry; +use Mcp\Capability\Prompt\Completion\EnumCompletionProvider; use Mcp\Capability\Registry; use Mcp\Schema\Prompt; use Mcp\Schema\Resource; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\ServerCapabilities; use Mcp\Schema\Tool; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; class RegistryTest extends TestCase { private Registry $registry; - private LoggerInterface $logger; + private LoggerInterface|MockObject $logger; protected function setUp(): void { @@ -151,7 +153,7 @@ public function testRegisterResourceIgnoresDiscoveredWhenManualExists(): void public function testRegisterResourceTemplateWithCompletionProviders(): void { $template = $this->createValidResourceTemplate('test://{id}'); - $completionProviders = ['id' => 'TestProvider']; + $completionProviders = ['id' => EnumCompletionProvider::class]; $this->registry->registerResourceTemplate($template, fn () => 'content', $completionProviders); @@ -180,7 +182,7 @@ public function testRegisterResourceTemplateIgnoresDiscoveredWhenManualExists(): public function testRegisterPromptWithCompletionProviders(): void { $prompt = $this->createValidPrompt('test_prompt'); - $completionProviders = ['param' => 'TestProvider']; + $completionProviders = ['param' => EnumCompletionProvider::class]; $this->registry->registerPrompt($prompt, fn () => [], $completionProviders); diff --git a/tests/Capability/Resource/DefaultResourceReaderTest.php b/tests/Capability/Resource/DefaultResourceReaderTest.php index 97e8103b..d3315fdf 100644 --- a/tests/Capability/Resource/DefaultResourceReaderTest.php +++ b/tests/Capability/Resource/DefaultResourceReaderTest.php @@ -23,13 +23,14 @@ use Mcp\Schema\Resource; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\Result\ReadResourceResult; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; class DefaultResourceReaderTest extends TestCase { private DefaultResourceReader $resourceReader; - private ReferenceProviderInterface $referenceProvider; - private ReferenceHandlerInterface $referenceHandler; + private ReferenceProviderInterface|MockObject $referenceProvider; + private ReferenceHandlerInterface|MockObject $referenceHandler; protected function setUp(): void { diff --git a/tests/Capability/Tool/DefaultToolExecutorTest.php b/tests/Capability/Tool/DefaultToolExecutorTest.php index 115d203f..af503ae6 100644 --- a/tests/Capability/Tool/DefaultToolExecutorTest.php +++ b/tests/Capability/Tool/DefaultToolExecutorTest.php @@ -21,15 +21,16 @@ use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\Result\CallToolResult; use Mcp\Schema\Tool; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; class DefaultToolExecutorTest extends TestCase { private DefaultToolExecutor $toolExecutor; - private ReferenceProviderInterface $referenceProvider; - private ReferenceHandlerInterface $referenceHandler; - private LoggerInterface $logger; + private ReferenceProviderInterface|MockObject $referenceProvider; + private ReferenceHandlerInterface|MockObject $referenceHandler; + private LoggerInterface|MockObject $logger; protected function setUp(): void { From e7494da66499dfa66044b6088b9f3f646e802dc1 Mon Sep 17 00:00:00 2001 From: butschster Date: Mon, 8 Sep 2025 14:51:35 +0400 Subject: [PATCH 11/26] test: add unit tests for CallToolHandler, GetPromptHandler, PingHandler, and ReadResourceHandler --- .../RequestHandler/CallToolHandlerTest.php | 292 +++++++++++++++ .../RequestHandler/GetPromptHandlerTest.php | 340 +++++++++++++++++ .../Server/RequestHandler/PingHandlerTest.php | 147 ++++++++ .../ReadResourceHandlerTest.php | 351 ++++++++++++++++++ 4 files changed, 1130 insertions(+) create mode 100644 tests/Server/RequestHandler/CallToolHandlerTest.php create mode 100644 tests/Server/RequestHandler/GetPromptHandlerTest.php create mode 100644 tests/Server/RequestHandler/PingHandlerTest.php create mode 100644 tests/Server/RequestHandler/ReadResourceHandlerTest.php diff --git a/tests/Server/RequestHandler/CallToolHandlerTest.php b/tests/Server/RequestHandler/CallToolHandlerTest.php new file mode 100644 index 00000000..74d8144a --- /dev/null +++ b/tests/Server/RequestHandler/CallToolHandlerTest.php @@ -0,0 +1,292 @@ +toolExecutor = $this->createMock(ToolExecutorInterface::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 ToolExecutionException($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": "Execution of tool "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 ToolExecutionException($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": "Execution of tool "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); + } + + private function createCallToolRequest(string $name, array $arguments): Request + { + 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 00000000..75da50cf --- /dev/null +++ b/tests/Server/RequestHandler/GetPromptHandlerTest.php @@ -0,0 +1,340 @@ +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); + } + + private function createGetPromptRequest(string $name, ?array $arguments = null): Request + { + 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 00000000..0904d68b --- /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 00000000..ae54b721 --- /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->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): Request + { + return ReadResourceRequest::fromArray([ + 'jsonrpc' => '2.0', + 'method' => ReadResourceRequest::getMethod(), + 'id' => 'test-request-'.uniqid(), + 'params' => [ + 'uri' => $uri, + ], + ]); + } +} From 3773e756c7d297a0dd97897fc06502c4c95f02c6 Mon Sep 17 00:00:00 2001 From: butschster Date: Tue, 9 Sep 2025 10:06:12 +0400 Subject: [PATCH 12/26] refactor: revert Handler --- src/JsonRpc/Handler.php | 3 ++- src/JsonRpc/HandlerInterface.php | 28 ---------------------------- src/Server.php | 4 ++-- tests/ServerTest.php | 4 ++-- 4 files changed, 6 insertions(+), 33 deletions(-) delete mode 100644 src/JsonRpc/HandlerInterface.php diff --git a/src/JsonRpc/Handler.php b/src/JsonRpc/Handler.php index 4d1d047f..55a56e0e 100644 --- a/src/JsonRpc/Handler.php +++ b/src/JsonRpc/Handler.php @@ -31,9 +31,10 @@ use Psr\Log\NullLogger; /** + * @final * @author Christopher Hertel */ -final class Handler implements HandlerInterface +class Handler { /** * @var array diff --git a/src/JsonRpc/HandlerInterface.php b/src/JsonRpc/HandlerInterface.php deleted file mode 100644 index ca9ec615..00000000 --- a/src/JsonRpc/HandlerInterface.php +++ /dev/null @@ -1,28 +0,0 @@ - - */ -interface HandlerInterface -{ - /** - * @return iterable - * - * @throws ExceptionInterface When a handler throws an exception during message processing - * @throws \JsonException When JSON encoding of the response fails - */ - public function process(string $input): iterable; -} diff --git a/src/Server.php b/src/Server.php index d59e6b4c..fc81382d 100644 --- a/src/Server.php +++ b/src/Server.php @@ -11,7 +11,7 @@ namespace Mcp; -use Mcp\JsonRpc\HandlerInterface; +use Mcp\JsonRpc\Handler; use Mcp\Server\ServerBuilder; use Mcp\Server\TransportInterface; use Psr\Log\LoggerInterface; @@ -23,7 +23,7 @@ final class Server { public function __construct( - private readonly HandlerInterface $jsonRpcHandler, + private readonly Handler $jsonRpcHandler, private readonly LoggerInterface $logger = new NullLogger(), ) { } diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 3e391a71..19177112 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -11,7 +11,7 @@ namespace Mcp\Tests; -use Mcp\JsonRpc\HandlerInterface; +use Mcp\JsonRpc\Handler; use Mcp\Server; use Mcp\Server\Transport\InMemoryTransport; use PHPUnit\Framework\MockObject\Stub\Exception; @@ -28,7 +28,7 @@ public function testJsonExceptions() ->getMock(); $logger->expects($this->once())->method('error'); - $handler = $this->getMockBuilder(HandlerInterface::class) + $handler = $this->getMockBuilder(Handler::class) ->disableOriginalConstructor() ->onlyMethods(['process']) ->getMock(); From 8a6653924606cd6bbf23bcd21c08c6d53332f88d Mon Sep 17 00:00:00 2001 From: butschster Date: Tue, 9 Sep 2025 10:07:03 +0400 Subject: [PATCH 13/26] chore: remove style guide for tests --- .guideline/tests.md | 513 -------------------------------------------- 1 file changed, 513 deletions(-) delete mode 100644 .guideline/tests.md diff --git a/.guideline/tests.md b/.guideline/tests.md deleted file mode 100644 index 2642cec5..00000000 --- a/.guideline/tests.md +++ /dev/null @@ -1,513 +0,0 @@ -# PHP MCP SDK Style Guide - -This style guide is based on the analysis of the Model Context Protocol (MCP) PHP SDK test codebase and establishes the -conventions used throughout this framework. - -## 1. Project Overview - -- **PHP Version**: 8.1+ -- **Framework**: Custom MCP SDK (Model Context Protocol) -- **Architecture Pattern**: Modular SDK with Discovery, Registry, and Capability patterns -- **Key Dependencies**: PHPUnit, PHPStan, PHP-CS-Fixer, PHPDocumentor, Symfony Components - -## 2. File Structure & Organization - -### Header Comment Pattern - -Every PHP file must start with this exact header comment: - -```php -subjectUnderTest = new ExampleClass(); - } - - public function testSpecificBehavior(): void - { - // Arrange - $input = $this->getTestData(); - - // Act - $result = $this->subjectUnderTest->process($input); - - // Assert - $this->assertInstanceOf(ExpectedClass::class, $result); - $this->assertEquals($expectedValue, $result->getValue()); - } - - private function getTestData(): array - { - return ['key' => 'value']; - } -} -``` - -### Test Method Naming - -- Must start with `test` -- Followed by clear description of what is being tested -- Use camelCase after the `test` prefix -- Be descriptive - method names can be long if necessary - -```php -// Test method naming examples -public function testDiscoversAllElementTypesCorrectlyFromFixtureFiles(): void -public function testDoesNotDiscoverElementsFromExcludedDirectories(): void -public function testHandlesEmptyDirectoriesOrDirectoriesWithNoPhpFiles(): void -public function testCorrectlyInfersNamesAndDescriptionsFromMethodsOrClassesIfNotSetInAttribute(): void -``` - -### Assertion Patterns - -- Use specific assertions over generic ones -- Group related assertions together -- Use `assertInstanceOf` for type checking -- Use `assertCount` for array/collection size checking -- Use `assertEqualsCanonicalizing` for arrays where order doesn't matter - -```php -// Assertion examples -$this->assertCount(4, $tools); -$this->assertInstanceOf(ToolReference::class, $greetUserTool); -$this->assertFalse($greetUserTool->isManual); -$this->assertEquals('greet_user', $greetUserTool->tool->name); -$this->assertArrayHasKey('name', $greetUserTool->tool->inputSchema['properties'] ?? []); -$this->assertEqualsCanonicalizing(['name', 'age', 'active', 'tags'], $schema['required']); -``` - -### Test Data Organization - -- Use private helper methods for test data generation -- Follow naming pattern: `get{DataType}Data()` or `get{Purpose}Schema()` -- Return type-hinted arrays with PHPDoc when complex - -```php -/** - * @return array{ - * name: string, - * age: int, - * active: bool, - * score: float, - * items: string[], - * nullableValue: null, - * optionalValue: string - * } - */ -private function getValidData(): array -{ - return [ - 'name' => 'Tester', - 'age' => 30, - 'active' => true, - 'score' => 99.5, - 'items' => ['a', 'b'], - 'nullableValue' => null, - 'optionalValue' => 'present', - ]; -} -``` - -## 7. PHPDoc Documentation Standards - -### Class Documentation - -```php -/** - * A stub class for testing DocBlock parsing. - * - * @author Author Name - */ -class DocBlockTestFixture -{ -} -``` - -### Method Documentation - -- Always include `@param` for parameters with descriptions -- Include `@return` for non-void methods -- Use `@throws` for exceptions -- Include method description for complex methods - -```php -/** - * Method with various parameter tags. - * - * @param string $param1 description for string param - * @param int|null $param2 description for nullable int param - * @param bool $param3 nothing to say - * @param $param4 Missing type - * @param array $param5 array description - * @param \stdClass $param6 object param - */ -public function methodWithParams(string $param1, ?int $param2, bool $param3, $param4, array $param5, \stdClass $param6): void -``` - -### Complex Type Documentation - -Use PHPStan-style type definitions for complex structures: - -```php -/** - * @phpstan-type DiscoveredCount array{ - * tools: int, - * resources: int, - * prompts: int, - * resourceTemplates: int, - * } - */ -``` - -## 8. Code Quality Rules - -### Error Handling - -- Use specific exception types where available -- Always provide meaningful error messages -- Log errors appropriately in service classes - -```php -try { - $reflectionClass = new \ReflectionClass($className); - // process class -} catch (\ReflectionException $e) { - $this->logger->error('Reflection error processing file for MCP discovery', [ - 'file' => $filePath, - 'class' => $className, - 'exception' => $e->getMessage() - ]); -} -``` - -### Method Complexity - -- Keep methods focused on single responsibilities -- Extract complex logic into private helper methods -- Test methods can be longer but should remain readable -- Use early returns to reduce nesting - -### Null Safety - -- Use nullable type hints (`?Type`) appropriately -- Check for null values before usage -- Prefer null coalescing operator (`??`) when appropriate - -```php -$docComment = $method->getDocComment() ?: null; -$docBlock = $this->parser->parseDocBlock($docComment); -$name = $instance->name ?? ('__invoke' === $methodName ? $classShortName : $methodName); -``` - -## 9. Framework-Specific Guidelines - -### Attribute Usage - -The codebase uses PHP 8 attributes extensively: - -```php -#[McpTool(name: 'greet_user', description: 'Greets a user by name.')] -public function greet(string $name): string -{ - return "Hello, {$name}!"; -} - -#[McpResource( - uri: 'app://info/version', - name: 'app_version', - description: 'The current version of the application.', - mimeType: 'text/plain', - size: 10 -)] -public function getAppVersion(): string -{ - return '1.2.3-discovered'; -} -``` - -### Registry Pattern - -Use the registry pattern for managing discovered elements: - -```php -$this->registry->registerTool($tool, [$className, $methodName]); -$this->registry->registerResource($resource, [$className, $methodName]); -``` - -### Reflection Usage - -Use reflection appropriately for discovery mechanisms: - -```php -$reflectionClass = new \ReflectionClass($className); -if ($reflectionClass->isAbstract() || $reflectionClass->isInterface()) { - return; -} - -foreach ($reflectionClass->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { - // process methods -} -``` - -## 10. Example Templates - -### Basic Test Class Template - -```php -service = new ExampleService(); - } - - public function testExampleBehavior(): void - { - $result = $this->service->performAction(); - - $this->assertInstanceOf(ExpectedResult::class, $result); - } - - private function getTestData(): array - { - return ['key' => 'value']; - } -} -``` - -### Fixture Class Template - -```php -value = 'test'; - return $result; - } -} -``` \ No newline at end of file From 156f8424410f537a2ed71f578fd47121f07604ac Mon Sep 17 00:00:00 2001 From: butschster Date: Tue, 9 Sep 2025 10:11:30 +0400 Subject: [PATCH 14/26] refactor: remove "Default" prefix from classes --- .../{DefaultPromptGetter.php => PromptGetter.php} | 2 +- ...{DefaultResourceReader.php => ResourceReader.php} | 2 +- .../{DefaultToolExecutor.php => ToolExecutor.php} | 2 +- src/Server/ServerBuilder.php | 12 ++++++------ ...aultPromptGetterTest.php => PromptGetterTest.php} | 8 ++++---- ...ResourceReaderTest.php => ResourceReaderTest.php} | 8 ++++---- ...aultToolExecutorTest.php => ToolExecutorTest.php} | 12 ++++++------ 7 files changed, 23 insertions(+), 23 deletions(-) rename src/Capability/Prompt/{DefaultPromptGetter.php => PromptGetter.php} (95%) rename src/Capability/Resource/{DefaultResourceReader.php => ResourceReader.php} (95%) rename src/Capability/Tool/{DefaultToolExecutor.php => ToolExecutor.php} (97%) rename tests/Capability/Prompt/{DefaultPromptGetterTest.php => PromptGetterTest.php} (99%) rename tests/Capability/Resource/{DefaultResourceReaderTest.php => ResourceReaderTest.php} (98%) rename tests/Capability/Tool/{DefaultToolExecutorTest.php => ToolExecutorTest.php} (98%) diff --git a/src/Capability/Prompt/DefaultPromptGetter.php b/src/Capability/Prompt/PromptGetter.php similarity index 95% rename from src/Capability/Prompt/DefaultPromptGetter.php rename to src/Capability/Prompt/PromptGetter.php index 3982619f..35fee75f 100644 --- a/src/Capability/Prompt/DefaultPromptGetter.php +++ b/src/Capability/Prompt/PromptGetter.php @@ -20,7 +20,7 @@ /** * @author Pavel Buchnev */ -final class DefaultPromptGetter implements PromptGetterInterface +final class PromptGetter implements PromptGetterInterface { public function __construct( private readonly ReferenceProviderInterface $referenceProvider, diff --git a/src/Capability/Resource/DefaultResourceReader.php b/src/Capability/Resource/ResourceReader.php similarity index 95% rename from src/Capability/Resource/DefaultResourceReader.php rename to src/Capability/Resource/ResourceReader.php index 2eae9f37..afd4f0a5 100644 --- a/src/Capability/Resource/DefaultResourceReader.php +++ b/src/Capability/Resource/ResourceReader.php @@ -20,7 +20,7 @@ /** * @author Pavel Buchnev */ -final class DefaultResourceReader implements ResourceReaderInterface +final class ResourceReader implements ResourceReaderInterface { public function __construct( private readonly ReferenceProviderInterface $referenceProvider, diff --git a/src/Capability/Tool/DefaultToolExecutor.php b/src/Capability/Tool/ToolExecutor.php similarity index 97% rename from src/Capability/Tool/DefaultToolExecutor.php rename to src/Capability/Tool/ToolExecutor.php index 12d5a953..284890f8 100644 --- a/src/Capability/Tool/DefaultToolExecutor.php +++ b/src/Capability/Tool/ToolExecutor.php @@ -30,7 +30,7 @@ * * @author Pavel Buchnev */ -final class DefaultToolExecutor implements ToolExecutorInterface +final class ToolExecutor implements ToolExecutorInterface { public function __construct( private readonly ReferenceProviderInterface $referenceProvider, diff --git a/src/Server/ServerBuilder.php b/src/Server/ServerBuilder.php index de217498..3e135654 100644 --- a/src/Server/ServerBuilder.php +++ b/src/Server/ServerBuilder.php @@ -19,14 +19,14 @@ use Mcp\Capability\DispatchableRegistry; use Mcp\Capability\Prompt\Completion\EnumCompletionProvider; use Mcp\Capability\Prompt\Completion\ListCompletionProvider; -use Mcp\Capability\Prompt\DefaultPromptGetter; +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\DefaultResourceReader; +use Mcp\Capability\Resource\ResourceReader; use Mcp\Capability\Resource\ResourceReaderInterface; -use Mcp\Capability\Tool\DefaultToolExecutor; +use Mcp\Capability\Tool\ToolExecutor; use Mcp\Capability\Tool\ToolExecutorInterface; use Mcp\Exception\ConfigurationException; use Mcp\JsonRpc\Handler; @@ -286,9 +286,9 @@ public function build(): Server ); $referenceHandler = new ReferenceHandler($container); - $toolExecutor = $this->toolExecutor ??= new DefaultToolExecutor($referenceProvider, $referenceHandler, $logger); - $resourceReader = $this->resourceReader ??= new DefaultResourceReader($referenceProvider, $referenceHandler); - $promptGetter = $this->promptGetter ??= new DefaultPromptGetter($referenceProvider, $referenceHandler); + $toolExecutor = $this->toolExecutor ??= new ToolExecutor($referenceProvider, $referenceHandler, $logger); + $resourceReader = $this->resourceReader ??= new ResourceReader($referenceProvider, $referenceHandler); + $promptGetter = $this->promptGetter ??= new PromptGetter($referenceProvider, $referenceHandler); $this->registerManualElements($registry, $logger); diff --git a/tests/Capability/Prompt/DefaultPromptGetterTest.php b/tests/Capability/Prompt/PromptGetterTest.php similarity index 99% rename from tests/Capability/Prompt/DefaultPromptGetterTest.php rename to tests/Capability/Prompt/PromptGetterTest.php index f0ef5717..7ee011f5 100644 --- a/tests/Capability/Prompt/DefaultPromptGetterTest.php +++ b/tests/Capability/Prompt/PromptGetterTest.php @@ -12,7 +12,7 @@ namespace Mcp\Tests\Capability\Prompt; use Mcp\Capability\Prompt\Completion\EnumCompletionProvider; -use Mcp\Capability\Prompt\DefaultPromptGetter; +use Mcp\Capability\Prompt\PromptGetter; use Mcp\Capability\Registry\PromptReference; use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\Registry\ReferenceProviderInterface; @@ -27,9 +27,9 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -class DefaultPromptGetterTest extends TestCase +class PromptGetterTest extends TestCase { - private DefaultPromptGetter $promptGetter; + private PromptGetter $promptGetter; private ReferenceProviderInterface|MockObject $referenceProvider; private ReferenceHandlerInterface|MockObject $referenceHandler; @@ -38,7 +38,7 @@ protected function setUp(): void $this->referenceProvider = $this->createMock(ReferenceProviderInterface::class); $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); - $this->promptGetter = new DefaultPromptGetter( + $this->promptGetter = new PromptGetter( $this->referenceProvider, $this->referenceHandler, ); diff --git a/tests/Capability/Resource/DefaultResourceReaderTest.php b/tests/Capability/Resource/ResourceReaderTest.php similarity index 98% rename from tests/Capability/Resource/DefaultResourceReaderTest.php rename to tests/Capability/Resource/ResourceReaderTest.php index d3315fdf..71ef5a16 100644 --- a/tests/Capability/Resource/DefaultResourceReaderTest.php +++ b/tests/Capability/Resource/ResourceReaderTest.php @@ -15,7 +15,7 @@ use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Capability\Registry\ResourceReference; use Mcp\Capability\Registry\ResourceTemplateReference; -use Mcp\Capability\Resource\DefaultResourceReader; +use Mcp\Capability\Resource\ResourceReader; use Mcp\Exception\RegistryException; use Mcp\Schema\Content\BlobResourceContents; use Mcp\Schema\Content\TextResourceContents; @@ -26,9 +26,9 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -class DefaultResourceReaderTest extends TestCase +class ResourceReaderTest extends TestCase { - private DefaultResourceReader $resourceReader; + private ResourceReader $resourceReader; private ReferenceProviderInterface|MockObject $referenceProvider; private ReferenceHandlerInterface|MockObject $referenceHandler; @@ -37,7 +37,7 @@ protected function setUp(): void $this->referenceProvider = $this->createMock(ReferenceProviderInterface::class); $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); - $this->resourceReader = new DefaultResourceReader( + $this->resourceReader = new ResourceReader( $this->referenceProvider, $this->referenceHandler, ); diff --git a/tests/Capability/Tool/DefaultToolExecutorTest.php b/tests/Capability/Tool/ToolExecutorTest.php similarity index 98% rename from tests/Capability/Tool/DefaultToolExecutorTest.php rename to tests/Capability/Tool/ToolExecutorTest.php index af503ae6..472388af 100644 --- a/tests/Capability/Tool/DefaultToolExecutorTest.php +++ b/tests/Capability/Tool/ToolExecutorTest.php @@ -14,7 +14,7 @@ use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Capability\Registry\ToolReference; -use Mcp\Capability\Tool\DefaultToolExecutor; +use Mcp\Capability\Tool\ToolExecutor; use Mcp\Exception\ToolExecutionException; use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\Content\TextContent; @@ -25,9 +25,9 @@ use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; -class DefaultToolExecutorTest extends TestCase +class ToolExecutorTest extends TestCase { - private DefaultToolExecutor $toolExecutor; + private ToolExecutor $toolExecutor; private ReferenceProviderInterface|MockObject $referenceProvider; private ReferenceHandlerInterface|MockObject $referenceHandler; private LoggerInterface|MockObject $logger; @@ -38,7 +38,7 @@ protected function setUp(): void $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); $this->logger = $this->createMock(LoggerInterface::class); - $this->toolExecutor = new DefaultToolExecutor( + $this->toolExecutor = new ToolExecutor( $this->referenceProvider, $this->referenceHandler, $this->logger, @@ -493,10 +493,10 @@ public function testCallLogsResultTypeCorrectlyForArray(): void public function testConstructorWithDefaultLogger(): void { - $executor = new DefaultToolExecutor($this->referenceProvider, $this->referenceHandler); + $executor = new ToolExecutor($this->referenceProvider, $this->referenceHandler); // Verify it's constructed without throwing exceptions - $this->assertInstanceOf(DefaultToolExecutor::class, $executor); + $this->assertInstanceOf(ToolExecutor::class, $executor); } public function testCallHandlesEmptyArrayResult(): void From 4f64998653ee42bb75b4969b6e48cf1cfead769b Mon Sep 17 00:00:00 2001 From: butschster Date: Tue, 9 Sep 2025 10:19:33 +0400 Subject: [PATCH 15/26] refactor: remove DispatchableRegistry --- src/Capability/Registry.php | 20 +- src/Schema/ServerCapabilities.php | 15 - src/Server/ServerBuilder.php | 14 +- .../Registry/DispatchableRegistryTest.php | 417 ------------------ tests/Capability/Registry/RegistryTest.php | 2 +- 5 files changed, 23 insertions(+), 445 deletions(-) delete mode 100644 tests/Capability/Registry/DispatchableRegistryTest.php diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index bc21b5ad..f9e65823 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -17,11 +17,16 @@ use Mcp\Capability\Registry\ResourceReference; use Mcp\Capability\Registry\ResourceTemplateReference; use Mcp\Capability\Registry\ToolReference; +use Mcp\Event\PromptListChangedEvent; +use Mcp\Event\ResourceListChangedEvent; +use Mcp\Event\ResourceTemplateListChangedEvent; +use Mcp\Event\ToolListChangedEvent; use Mcp\Schema\Prompt; use Mcp\Schema\Resource; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\ServerCapabilities; use Mcp\Schema\Tool; +use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -56,6 +61,7 @@ final class Registry implements ReferenceProviderInterface, ReferenceRegistryInt private array $resourceTemplates = []; public function __construct( + private readonly ?EventDispatcherInterface $eventDispatcher = null, private readonly LoggerInterface $logger = new NullLogger(), ) { } @@ -68,12 +74,12 @@ public function getCapabilities(): ServerCapabilities return new ServerCapabilities( tools: [] !== $this->tools, - toolsListChanged: false, + toolsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, resources: [] !== $this->resources || [] !== $this->resourceTemplates, resourcesSubscribe: false, - resourcesListChanged: false, + resourcesListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, prompts: [] !== $this->prompts, - promptsListChanged: false, + promptsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, logging: false, completions: true, ); @@ -93,6 +99,8 @@ public function registerTool(Tool $tool, callable|array|string $handler, bool $i } $this->tools[$toolName] = new ToolReference($tool, $handler, $isManual); + + $this->eventDispatcher?->dispatch(new ToolListChangedEvent()); } public function registerResource(Resource $resource, callable|array|string $handler, bool $isManual = false): void @@ -109,6 +117,8 @@ public function registerResource(Resource $resource, callable|array|string $hand } $this->resources[$uri] = new ResourceReference($resource, $handler, $isManual); + + $this->eventDispatcher?->dispatch(new ResourceListChangedEvent()); } public function registerResourceTemplate( @@ -134,6 +144,8 @@ public function registerResourceTemplate( $isManual, $completionProviders, ); + + $this->eventDispatcher?->dispatch(new ResourceTemplateListChangedEvent()); } public function registerPrompt( @@ -154,6 +166,8 @@ public function registerPrompt( } $this->prompts[$promptName] = new PromptReference($prompt, $handler, $isManual, $completionProviders); + + $this->eventDispatcher?->dispatch(new PromptListChangedEvent()); } public function clear(): void diff --git a/src/Schema/ServerCapabilities.php b/src/Schema/ServerCapabilities.php index 9169be26..89eec187 100644 --- a/src/Schema/ServerCapabilities.php +++ b/src/Schema/ServerCapabilities.php @@ -45,21 +45,6 @@ public function __construct( ) { } - public function withEvents(): self - { - return new self( - tools: $this->tools, - toolsListChanged: true, - resources: $this->resources, - resourcesSubscribe: $this->resourcesSubscribe, - resourcesListChanged: true, - prompts: $this->prompts, - promptsListChanged: true, - logging: $this->logging, - completions: $this->completions, - ); - } - /** * @param array{ * logging?: mixed, diff --git a/src/Server/ServerBuilder.php b/src/Server/ServerBuilder.php index 3e135654..32abb2fd 100644 --- a/src/Server/ServerBuilder.php +++ b/src/Server/ServerBuilder.php @@ -279,16 +279,12 @@ public function build(): Server $logger = $this->logger ?? new NullLogger(); $container = $this->container ?? new Container(); - $referenceProvider = new Registry($logger); - $registry = new DispatchableRegistry( - referenceProvider: new Registry($logger), - eventDispatcher: $this->eventDispatcher, - ); + $registry = new Registry($this->eventDispatcher, $logger); $referenceHandler = new ReferenceHandler($container); - $toolExecutor = $this->toolExecutor ??= new ToolExecutor($referenceProvider, $referenceHandler, $logger); - $resourceReader = $this->resourceReader ??= new ResourceReader($referenceProvider, $referenceHandler); - $promptGetter = $this->promptGetter ??= new PromptGetter($referenceProvider, $referenceHandler); + $toolExecutor = $this->toolExecutor ??= new ToolExecutor($registry, $referenceHandler, $logger); + $resourceReader = $this->resourceReader ??= new ResourceReader($registry, $referenceHandler); + $promptGetter = $this->promptGetter ??= new PromptGetter($registry, $referenceHandler); $this->registerManualElements($registry, $logger); @@ -300,7 +296,7 @@ public function build(): Server return new Server( jsonRpcHandler: Handler::make( registry: $registry, - referenceProvider: $referenceProvider, + referenceProvider: $registry, implementation: $this->serverInfo, toolExecutor: $toolExecutor, resourceReader: $resourceReader, diff --git a/tests/Capability/Registry/DispatchableRegistryTest.php b/tests/Capability/Registry/DispatchableRegistryTest.php deleted file mode 100644 index 9d2a44fb..00000000 --- a/tests/Capability/Registry/DispatchableRegistryTest.php +++ /dev/null @@ -1,417 +0,0 @@ -referenceRegistry = $this->createMock(ReferenceRegistryInterface::class); - $this->eventDispatcher = $this->createMock(EventDispatcherInterface::class); - $this->dispatchableRegistry = new DispatchableRegistry($this->referenceRegistry, $this->eventDispatcher); - } - - public function testConstructorWithoutEventDispatcher(): void - { - $dispatchableRegistry = new DispatchableRegistry($this->referenceRegistry); - - $this->assertInstanceOf(DispatchableRegistry::class, $dispatchableRegistry); - } - - public function testGetCapabilitiesWithEventDispatcher(): void - { - $baseCapabilities = new ServerCapabilities( - tools: true, - toolsListChanged: false, - resources: true, - resourcesListChanged: false, - prompts: true, - promptsListChanged: false - ); - - $this->referenceRegistry->expects($this->once()) - ->method('getCapabilities') - ->willReturn($baseCapabilities); - - $capabilities = $this->dispatchableRegistry->getCapabilities(); - - $this->assertTrue($capabilities->tools); - $this->assertTrue($capabilities->toolsListChanged); - $this->assertTrue($capabilities->resources); - $this->assertTrue($capabilities->resourcesListChanged); - $this->assertTrue($capabilities->prompts); - $this->assertTrue($capabilities->promptsListChanged); - } - - public function testGetCapabilitiesWithoutEventDispatcher(): void - { - $baseCapabilities = new ServerCapabilities( - tools: true, - toolsListChanged: false, - resources: true, - resourcesListChanged: false, - prompts: true, - promptsListChanged: false - ); - - $dispatchableRegistry = new DispatchableRegistry($this->referenceRegistry, null); - - $this->referenceRegistry->expects($this->once()) - ->method('getCapabilities') - ->willReturn($baseCapabilities); - - $capabilities = $dispatchableRegistry->getCapabilities(); - - $this->assertTrue($capabilities->tools); - $this->assertFalse($capabilities->toolsListChanged); - $this->assertTrue($capabilities->resources); - $this->assertFalse($capabilities->resourcesListChanged); - $this->assertTrue($capabilities->prompts); - $this->assertFalse($capabilities->promptsListChanged); - } - - public function testRegisterToolDelegatesToReferenceRegistryAndDispatchesEvent(): void - { - $tool = $this->createValidTool('test_tool'); - $handler = fn () => 'result'; - - $this->referenceRegistry->expects($this->once()) - ->method('registerTool') - ->with($tool, $handler, false); - - $this->eventDispatcher->expects($this->once()) - ->method('dispatch') - ->with($this->isInstanceOf(ToolListChangedEvent::class)); - - $this->dispatchableRegistry->registerTool($tool, $handler); - } - - public function testRegisterToolWithManualFlag(): void - { - $tool = $this->createValidTool('test_tool'); - $handler = fn () => 'result'; - - $this->referenceRegistry->expects($this->once()) - ->method('registerTool') - ->with($tool, $handler, true); - - $this->eventDispatcher->expects($this->once()) - ->method('dispatch') - ->with($this->isInstanceOf(ToolListChangedEvent::class)); - - $this->dispatchableRegistry->registerTool($tool, $handler, true); - } - - public function testRegisterToolWithoutEventDispatcher(): void - { - $dispatchableRegistry = new DispatchableRegistry($this->referenceRegistry, null); - $tool = $this->createValidTool('test_tool'); - $handler = fn () => 'result'; - - $this->referenceRegistry->expects($this->once()) - ->method('registerTool') - ->with($tool, $handler, false); - - // Should not throw exception when event dispatcher is null - $dispatchableRegistry->registerTool($tool, $handler); - } - - public function testRegisterResourceDelegatesToReferenceRegistryAndDispatchesEvent(): void - { - $resource = $this->createValidResource('test://resource'); - $handler = fn () => 'content'; - - $this->referenceRegistry->expects($this->once()) - ->method('registerResource') - ->with($resource, $handler, false); - - $this->eventDispatcher->expects($this->once()) - ->method('dispatch') - ->with($this->isInstanceOf(ResourceListChangedEvent::class)); - - $this->dispatchableRegistry->registerResource($resource, $handler); - } - - public function testRegisterResourceWithManualFlag(): void - { - $resource = $this->createValidResource('test://resource'); - $handler = fn () => 'content'; - - $this->referenceRegistry->expects($this->once()) - ->method('registerResource') - ->with($resource, $handler, true); - - $this->eventDispatcher->expects($this->once()) - ->method('dispatch') - ->with($this->isInstanceOf(ResourceListChangedEvent::class)); - - $this->dispatchableRegistry->registerResource($resource, $handler, true); - } - - public function testRegisterResourceWithoutEventDispatcher(): void - { - $dispatchableRegistry = new DispatchableRegistry($this->referenceRegistry, null); - $resource = $this->createValidResource('test://resource'); - $handler = fn () => 'content'; - - $this->referenceRegistry->expects($this->once()) - ->method('registerResource') - ->with($resource, $handler, false); - - $dispatchableRegistry->registerResource($resource, $handler); - } - - public function testRegisterResourceTemplateDelegatesToReferenceRegistryAndDispatchesEvent(): void - { - $template = $this->createValidResourceTemplate('test://{id}'); - $handler = fn () => 'content'; - $completionProviders = ['id' => EnumCompletionProvider::class]; - - $this->referenceRegistry->expects($this->once()) - ->method('registerResourceTemplate') - ->with($template, $handler, $completionProviders, false); - - $this->eventDispatcher->expects($this->once()) - ->method('dispatch') - ->with($this->isInstanceOf(ResourceTemplateListChangedEvent::class)); - - $this->dispatchableRegistry->registerResourceTemplate($template, $handler, $completionProviders); - } - - public function testRegisterResourceTemplateWithDefaults(): void - { - $template = $this->createValidResourceTemplate('test://{id}'); - $handler = fn () => 'content'; - - $this->referenceRegistry->expects($this->once()) - ->method('registerResourceTemplate') - ->with($template, $handler, [], false); - - $this->eventDispatcher->expects($this->once()) - ->method('dispatch') - ->with($this->isInstanceOf(ResourceTemplateListChangedEvent::class)); - - $this->dispatchableRegistry->registerResourceTemplate($template, $handler); - } - - public function testRegisterResourceTemplateWithManualFlag(): void - { - $template = $this->createValidResourceTemplate('test://{id}'); - $handler = fn () => 'content'; - - $this->referenceRegistry->expects($this->once()) - ->method('registerResourceTemplate') - ->with($template, $handler, [], true); - - $this->eventDispatcher->expects($this->once()) - ->method('dispatch') - ->with($this->isInstanceOf(ResourceTemplateListChangedEvent::class)); - - $this->dispatchableRegistry->registerResourceTemplate($template, $handler, [], true); - } - - public function testRegisterResourceTemplateWithoutEventDispatcher(): void - { - $dispatchableRegistry = new DispatchableRegistry($this->referenceRegistry, null); - $template = $this->createValidResourceTemplate('test://{id}'); - $handler = fn () => 'content'; - - $this->referenceRegistry->expects($this->once()) - ->method('registerResourceTemplate') - ->with($template, $handler, [], false); - - $dispatchableRegistry->registerResourceTemplate($template, $handler); - } - - public function testRegisterPromptDelegatesToReferenceRegistryAndDispatchesEvent(): void - { - $prompt = $this->createValidPrompt('test_prompt'); - $handler = fn () => []; - $completionProviders = ['param' => EnumCompletionProvider::class]; - - $this->referenceRegistry->expects($this->once()) - ->method('registerPrompt') - ->with($prompt, $handler, $completionProviders, false); - - $this->eventDispatcher->expects($this->once()) - ->method('dispatch') - ->with($this->isInstanceOf(PromptListChangedEvent::class)); - - $this->dispatchableRegistry->registerPrompt($prompt, $handler, $completionProviders); - } - - public function testRegisterPromptWithDefaults(): void - { - $prompt = $this->createValidPrompt('test_prompt'); - $handler = fn () => []; - - $this->referenceRegistry->expects($this->once()) - ->method('registerPrompt') - ->with($prompt, $handler, [], false); - - $this->eventDispatcher->expects($this->once()) - ->method('dispatch') - ->with($this->isInstanceOf(PromptListChangedEvent::class)); - - $this->dispatchableRegistry->registerPrompt($prompt, $handler); - } - - public function testRegisterPromptWithManualFlag(): void - { - $prompt = $this->createValidPrompt('test_prompt'); - $handler = fn () => []; - - $this->referenceRegistry->expects($this->once()) - ->method('registerPrompt') - ->with($prompt, $handler, [], true); - - $this->eventDispatcher->expects($this->once()) - ->method('dispatch') - ->with($this->isInstanceOf(PromptListChangedEvent::class)); - - $this->dispatchableRegistry->registerPrompt($prompt, $handler, [], true); - } - - public function testRegisterPromptWithoutEventDispatcher(): void - { - $dispatchableRegistry = new DispatchableRegistry($this->referenceRegistry, null); - $prompt = $this->createValidPrompt('test_prompt'); - $handler = fn () => []; - - $this->referenceRegistry->expects($this->once()) - ->method('registerPrompt') - ->with($prompt, $handler, [], false); - - $dispatchableRegistry->registerPrompt($prompt, $handler); - } - - public function testClearDelegatesToReferenceRegistry(): void - { - $this->referenceRegistry->expects($this->once()) - ->method('clear'); - - $this->dispatchableRegistry->clear(); - } - - public function testRegisterToolHandlesStringHandler(): void - { - $tool = $this->createValidTool('test_tool'); - $handler = 'TestClass::testMethod'; - - $this->referenceRegistry->expects($this->once()) - ->method('registerTool') - ->with($tool, $handler, false); - - $this->eventDispatcher->expects($this->once()) - ->method('dispatch') - ->with($this->isInstanceOf(ToolListChangedEvent::class)); - - $this->dispatchableRegistry->registerTool($tool, $handler); - } - - public function testRegisterToolHandlesArrayHandler(): void - { - $tool = $this->createValidTool('test_tool'); - $handler = ['TestClass', 'testMethod']; - - $this->referenceRegistry->expects($this->once()) - ->method('registerTool') - ->with($tool, $handler, false); - - $this->eventDispatcher->expects($this->once()) - ->method('dispatch') - ->with($this->isInstanceOf(ToolListChangedEvent::class)); - - $this->dispatchableRegistry->registerTool($tool, $handler); - } - - public function testRegisterResourceHandlesCallableHandler(): void - { - $resource = $this->createValidResource('test://resource'); - $handler = fn () => 'content'; - - $this->referenceRegistry->expects($this->once()) - ->method('registerResource') - ->with($resource, $handler, false); - - $this->eventDispatcher->expects($this->once()) - ->method('dispatch') - ->with($this->isInstanceOf(ResourceListChangedEvent::class)); - - $this->dispatchableRegistry->registerResource($resource, $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/Registry/RegistryTest.php b/tests/Capability/Registry/RegistryTest.php index 1167617b..14548b63 100644 --- a/tests/Capability/Registry/RegistryTest.php +++ b/tests/Capability/Registry/RegistryTest.php @@ -30,7 +30,7 @@ class RegistryTest extends TestCase protected function setUp(): void { $this->logger = $this->createMock(LoggerInterface::class); - $this->registry = new Registry($this->logger); + $this->registry = new Registry(null, $this->logger); } public function testConstructorWithDefaults(): void From d9cd7ba7dd42ff5faa95cfcfd2ced4c4eb85d1b6 Mon Sep 17 00:00:00 2001 From: butschster Date: Tue, 9 Sep 2025 10:28:44 +0400 Subject: [PATCH 16/26] refactor: use package specific exception classes --- src/Capability/Prompt/PromptGetter.php | 21 +++++---- src/Capability/Resource/ResourceReader.php | 27 +++++------ tests/Capability/Prompt/PromptGetterTest.php | 45 +++++-------------- .../Resource/ResourceReaderTest.php | 8 ++-- 4 files changed, 43 insertions(+), 58 deletions(-) diff --git a/src/Capability/Prompt/PromptGetter.php b/src/Capability/Prompt/PromptGetter.php index 35fee75f..86db84ed 100644 --- a/src/Capability/Prompt/PromptGetter.php +++ b/src/Capability/Prompt/PromptGetter.php @@ -13,6 +13,8 @@ use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\Registry\ReferenceProviderInterface; +use Mcp\Exception\PromptGetException; +use Mcp\Exception\PromptNotFoundException; use Mcp\Exception\RegistryException; use Mcp\Schema\Request\GetPromptRequest; use Mcp\Schema\Result\GetPromptResult; @@ -25,8 +27,7 @@ final class PromptGetter implements PromptGetterInterface public function __construct( private readonly ReferenceProviderInterface $referenceProvider, private readonly ReferenceHandlerInterface $referenceHandler, - ) { - } + ) {} /** * @throws RegistryException @@ -37,13 +38,17 @@ public function get(GetPromptRequest $request): GetPromptResult $reference = $this->referenceProvider->getPrompt($request->name); if (null === $reference) { - throw new \InvalidArgumentException(\sprintf('Prompt "%s" is not registered.', $request->name)); + throw new PromptNotFoundException($request); } - return new GetPromptResult( - $reference->formatResult( - $this->referenceHandler->handle($reference, $request->arguments ?? []), - ), - ); + try { + return new GetPromptResult( + $reference->formatResult( + $this->referenceHandler->handle($reference, $request->arguments ?? []), + ), + ); + } catch (\Throwable $e) { + throw new PromptGetException($request, $e); + } } } diff --git a/src/Capability/Resource/ResourceReader.php b/src/Capability/Resource/ResourceReader.php index afd4f0a5..536b1007 100644 --- a/src/Capability/Resource/ResourceReader.php +++ b/src/Capability/Resource/ResourceReader.php @@ -13,7 +13,8 @@ use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\Registry\ReferenceProviderInterface; -use Mcp\Exception\RegistryException; +use Mcp\Exception\ResourceNotFoundException; +use Mcp\Exception\ResourceReadException; use Mcp\Schema\Request\ReadResourceRequest; use Mcp\Schema\Result\ReadResourceResult; @@ -25,25 +26,25 @@ final class ResourceReader implements ResourceReaderInterface public function __construct( private readonly ReferenceProviderInterface $referenceProvider, private readonly ReferenceHandlerInterface $referenceHandler, - ) { - } + ) {} - /** - * @throws RegistryException - */ public function read(ReadResourceRequest $request): ReadResourceResult { $reference = $this->referenceProvider->getResource($request->uri); if (null === $reference) { - throw new \InvalidArgumentException(\sprintf('Resource "%s" is not registered.', $request->uri)); + throw new ResourceNotFoundException($request); } - return new ReadResourceResult( - $reference->formatResult( - $this->referenceHandler->handle($reference, ['uri' => $request->uri]), - $request->uri, - ), - ); + try { + return new ReadResourceResult( + $reference->formatResult( + $this->referenceHandler->handle($reference, ['uri' => $request->uri]), + $request->uri, + ), + ); + } catch (\Throwable $e) { + throw new ResourceReadException($request, $e); + } } } diff --git a/tests/Capability/Prompt/PromptGetterTest.php b/tests/Capability/Prompt/PromptGetterTest.php index 7ee011f5..d77f4499 100644 --- a/tests/Capability/Prompt/PromptGetterTest.php +++ b/tests/Capability/Prompt/PromptGetterTest.php @@ -16,6 +16,8 @@ use Mcp\Capability\Registry\PromptReference; use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\Registry\ReferenceProviderInterface; +use Mcp\Exception\PromptGetException; +use Mcp\Exception\PromptNotFoundException; use Mcp\Exception\RegistryException; use Mcp\Exception\RuntimeException; use Mcp\Schema\Content\PromptMessage; @@ -151,8 +153,8 @@ public function testGetThrowsInvalidArgumentExceptionWhenPromptNotFound(): void ->expects($this->never()) ->method('handle'); - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Prompt "nonexistent_prompt" is not registered.'); + $this->expectException(PromptNotFoundException::class); + $this->expectExceptionMessage('Prompt not found for name: "nonexistent_prompt".'); $this->promptGetter->get($request); } @@ -176,7 +178,7 @@ public function testGetThrowsRegistryExceptionWhenHandlerFails(): void ->with($promptReference, ['param' => 'value']) ->willThrowException($handlerException); - $this->expectException(RegistryException::class); + $this->expectException(PromptGetException::class); $this->promptGetter->get($request); } @@ -204,7 +206,7 @@ public function testGetHandlesJsonExceptionDuringFormatting(): void ->with($promptReference, []) ->willReturn('some result'); - $this->expectException(\JsonException::class); + $this->expectException(PromptGetException::class); $this->expectExceptionMessage('JSON encoding failed'); $this->promptGetter->get($request); @@ -333,31 +335,6 @@ public function testGetHandlesEmptyArrayResult(): void $this->assertCount(0, $result->messages); } - public function testGetHandlesDifferentExceptionTypes(): void - { - $request = new GetPromptRequest('error_prompt', []); - $prompt = $this->createValidPrompt('error_prompt'); - $promptReference = new PromptReference($prompt, fn () => throw new \InvalidArgumentException('Invalid input')); - $handlerException = new \InvalidArgumentException('Invalid input'); - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('error_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, []) - ->willThrowException($handlerException); - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid input'); - - $this->promptGetter->get($request); - } - public function testGetWithTypedContentStructure(): void { $request = new GetPromptRequest('typed_content_prompt', []); @@ -512,7 +489,7 @@ public function testGetThrowsRuntimeExceptionForInvalidHandlerResult(): void ->with($promptReference, []) ->willReturn('This is not a valid prompt format'); - $this->expectException(RuntimeException::class); + $this->expectException(PromptGetException::class); $this->expectExceptionMessage('Prompt generator method must return an array of messages.'); $this->promptGetter->get($request); @@ -539,7 +516,7 @@ public function testGetThrowsRuntimeExceptionForNullHandlerResult(): void ->with($promptReference, []) ->willReturn(null); - $this->expectException(RuntimeException::class); + $this->expectException(PromptGetException::class); $this->expectExceptionMessage('Prompt generator method must return an array of messages.'); $this->promptGetter->get($request); @@ -566,7 +543,7 @@ public function testGetThrowsRuntimeExceptionForScalarHandlerResult(): void ->with($promptReference, []) ->willReturn(42); - $this->expectException(RuntimeException::class); + $this->expectException(PromptGetException::class); $this->expectExceptionMessage('Prompt generator method must return an array of messages.'); $this->promptGetter->get($request); @@ -593,7 +570,7 @@ public function testGetThrowsRuntimeExceptionForBooleanHandlerResult(): void ->with($promptReference, []) ->willReturn(true); - $this->expectException(RuntimeException::class); + $this->expectException(PromptGetException::class); $this->expectExceptionMessage('Prompt generator method must return an array of messages.'); $this->promptGetter->get($request); @@ -622,7 +599,7 @@ public function testGetThrowsRuntimeExceptionForObjectHandlerResult(): void ->with($promptReference, []) ->willReturn($objectResult); - $this->expectException(RuntimeException::class); + $this->expectException(PromptGetException::class); $this->expectExceptionMessage('Prompt generator method must return an array of messages.'); $this->promptGetter->get($request); diff --git a/tests/Capability/Resource/ResourceReaderTest.php b/tests/Capability/Resource/ResourceReaderTest.php index 71ef5a16..ae8478c5 100644 --- a/tests/Capability/Resource/ResourceReaderTest.php +++ b/tests/Capability/Resource/ResourceReaderTest.php @@ -17,6 +17,8 @@ use Mcp\Capability\Registry\ResourceTemplateReference; use Mcp\Capability\Resource\ResourceReader; use Mcp\Exception\RegistryException; +use Mcp\Exception\ResourceNotFoundException; +use Mcp\Exception\ResourceReadException; use Mcp\Schema\Content\BlobResourceContents; use Mcp\Schema\Content\TextResourceContents; use Mcp\Schema\Request\ReadResourceRequest; @@ -239,8 +241,8 @@ public function testReadResourceThrowsExceptionWhenResourceNotFound(): void ->expects($this->never()) ->method('handle'); - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Resource "nonexistent://resource" is not registered.'); + $this->expectException(ResourceNotFoundException::class); + $this->expectExceptionMessage('Resource not found for uri: "nonexistent://resource".'); $this->resourceReader->read($request); } @@ -264,7 +266,7 @@ public function testReadResourceThrowsRegistryExceptionWhenHandlerFails(): void ->with($resourceReference, ['uri' => 'failing://resource']) ->willThrowException($handlerException); - $this->expectException(RegistryException::class); + $this->expectException(ResourceReadException::class); $this->expectExceptionMessage('Handler execution failed'); $this->resourceReader->read($request); From addfd23846487863f33af7fe3680761d0188ee92 Mon Sep 17 00:00:00 2001 From: butschster Date: Tue, 9 Sep 2025 10:30:05 +0400 Subject: [PATCH 17/26] refactor: remove DispatchableRegistry --- src/Capability/DispatchableRegistry.php | 82 ------------------------- 1 file changed, 82 deletions(-) delete mode 100644 src/Capability/DispatchableRegistry.php diff --git a/src/Capability/DispatchableRegistry.php b/src/Capability/DispatchableRegistry.php deleted file mode 100644 index 38f39dcf..00000000 --- a/src/Capability/DispatchableRegistry.php +++ /dev/null @@ -1,82 +0,0 @@ -referenceProvider->getCapabilities(); - - if (null !== $this->eventDispatcher) { - return $capabilities->withEvents(); - } - - return $capabilities; - } - - public function registerTool(Tool $tool, callable|array|string $handler, bool $isManual = false): void - { - $this->referenceProvider->registerTool($tool, $handler, $isManual); - $this->eventDispatcher?->dispatch(new ToolListChangedEvent()); - } - - public function registerResource(Resource $resource, callable|array|string $handler, bool $isManual = false): void - { - $this->referenceProvider->registerResource($resource, $handler, $isManual); - $this->eventDispatcher?->dispatch(new ResourceListChangedEvent()); - } - - public function registerResourceTemplate( - ResourceTemplate $template, - callable|array|string $handler, - array $completionProviders = [], - bool $isManual = false, - ): void { - $this->referenceProvider->registerResourceTemplate($template, $handler, $completionProviders, $isManual); - $this->eventDispatcher?->dispatch(new ResourceTemplateListChangedEvent()); - } - - public function registerPrompt( - Prompt $prompt, - callable|array|string $handler, - array $completionProviders = [], - bool $isManual = false, - ): void { - $this->referenceProvider->registerPrompt($prompt, $handler, $completionProviders, $isManual); - $this->eventDispatcher?->dispatch(new PromptListChangedEvent()); - } - - public function clear(): void - { - $this->referenceProvider->clear(); - // TODO: are there any events to dispatch here? - } -} From f3110eb35a9558a358fdc567f415c3844395783d Mon Sep 17 00:00:00 2001 From: butschster Date: Tue, 9 Sep 2025 10:49:23 +0400 Subject: [PATCH 18/26] refactor: add logger support to PromptGetter and ResourceReader classes --- src/Capability/Prompt/PromptGetter.php | 39 ++++++++---- src/Capability/Resource/ResourceReader.php | 34 ++++++++--- src/JsonRpc/Handler.php | 1 + src/Server/ServerBuilder.php | 5 +- tests/Capability/Prompt/PromptGetterTest.php | 24 +++++++- .../Resource/ResourceReaderTest.php | 23 +++++++ tests/Schema/ServerCapabilitiesTest.php | 60 ------------------- 7 files changed, 102 insertions(+), 84 deletions(-) diff --git a/src/Capability/Prompt/PromptGetter.php b/src/Capability/Prompt/PromptGetter.php index 86db84ed..5d6deedf 100644 --- a/src/Capability/Prompt/PromptGetter.php +++ b/src/Capability/Prompt/PromptGetter.php @@ -15,9 +15,10 @@ use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Exception\PromptGetException; use Mcp\Exception\PromptNotFoundException; -use Mcp\Exception\RegistryException; use Mcp\Schema\Request\GetPromptRequest; use Mcp\Schema\Result\GetPromptResult; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; /** * @author Pavel Buchnev @@ -27,27 +28,41 @@ final class PromptGetter implements PromptGetterInterface public function __construct( private readonly ReferenceProviderInterface $referenceProvider, private readonly ReferenceHandlerInterface $referenceHandler, - ) {} + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } - /** - * @throws RegistryException - * @throws \JsonException - */ public function get(GetPromptRequest $request): GetPromptResult { - $reference = $this->referenceProvider->getPrompt($request->name); + $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 { - return new GetPromptResult( - $reference->formatResult( - $this->referenceHandler->handle($reference, $request->arguments ?? []), - ), - ); + $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/Resource/ResourceReader.php b/src/Capability/Resource/ResourceReader.php index 536b1007..2496cfaf 100644 --- a/src/Capability/Resource/ResourceReader.php +++ b/src/Capability/Resource/ResourceReader.php @@ -17,6 +17,8 @@ use Mcp\Exception\ResourceReadException; use Mcp\Schema\Request\ReadResourceRequest; use Mcp\Schema\Result\ReadResourceResult; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; /** * @author Pavel Buchnev @@ -26,24 +28,40 @@ 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 { - $reference = $this->referenceProvider->getResource($request->uri); + $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 { - return new ReadResourceResult( - $reference->formatResult( - $this->referenceHandler->handle($reference, ['uri' => $request->uri]), - $request->uri, - ), - ); + $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/JsonRpc/Handler.php b/src/JsonRpc/Handler.php index 55a56e0e..32416f11 100644 --- a/src/JsonRpc/Handler.php +++ b/src/JsonRpc/Handler.php @@ -32,6 +32,7 @@ /** * @final + * * @author Christopher Hertel */ class Handler diff --git a/src/Server/ServerBuilder.php b/src/Server/ServerBuilder.php index 32abb2fd..7db58e3e 100644 --- a/src/Server/ServerBuilder.php +++ b/src/Server/ServerBuilder.php @@ -16,7 +16,6 @@ use Mcp\Capability\Discovery\DocBlockParser; use Mcp\Capability\Discovery\HandlerResolver; use Mcp\Capability\Discovery\SchemaGenerator; -use Mcp\Capability\DispatchableRegistry; use Mcp\Capability\Prompt\Completion\EnumCompletionProvider; use Mcp\Capability\Prompt\Completion\ListCompletionProvider; use Mcp\Capability\Prompt\PromptGetter; @@ -283,8 +282,8 @@ public function build(): Server $referenceHandler = new ReferenceHandler($container); $toolExecutor = $this->toolExecutor ??= new ToolExecutor($registry, $referenceHandler, $logger); - $resourceReader = $this->resourceReader ??= new ResourceReader($registry, $referenceHandler); - $promptGetter = $this->promptGetter ??= new PromptGetter($registry, $referenceHandler); + $resourceReader = $this->resourceReader ??= new ResourceReader($registry, $referenceHandler, $logger); + $promptGetter = $this->promptGetter ??= new PromptGetter($registry, $referenceHandler, $logger); $this->registerManualElements($registry, $logger); diff --git a/tests/Capability/Prompt/PromptGetterTest.php b/tests/Capability/Prompt/PromptGetterTest.php index d77f4499..46bee1cf 100644 --- a/tests/Capability/Prompt/PromptGetterTest.php +++ b/tests/Capability/Prompt/PromptGetterTest.php @@ -19,7 +19,6 @@ use Mcp\Exception\PromptGetException; use Mcp\Exception\PromptNotFoundException; use Mcp\Exception\RegistryException; -use Mcp\Exception\RuntimeException; use Mcp\Schema\Content\PromptMessage; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Enum\Role; @@ -28,6 +27,7 @@ use Mcp\Schema\Result\GetPromptResult; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; class PromptGetterTest extends TestCase { @@ -605,6 +605,28 @@ public function testGetThrowsRuntimeExceptionForObjectHandlerResult(): void $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( diff --git a/tests/Capability/Resource/ResourceReaderTest.php b/tests/Capability/Resource/ResourceReaderTest.php index ae8478c5..2206ce92 100644 --- a/tests/Capability/Resource/ResourceReaderTest.php +++ b/tests/Capability/Resource/ResourceReaderTest.php @@ -27,6 +27,7 @@ use Mcp\Schema\Result\ReadResourceResult; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; class ResourceReaderTest extends TestCase { @@ -471,6 +472,28 @@ public function testReadResourceCallsFormatResultOnReference(): void $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( diff --git a/tests/Schema/ServerCapabilitiesTest.php b/tests/Schema/ServerCapabilitiesTest.php index b3c9e2a2..3a9f2b99 100644 --- a/tests/Schema/ServerCapabilitiesTest.php +++ b/tests/Schema/ServerCapabilitiesTest.php @@ -88,66 +88,6 @@ public function testConstructorWithNullValues(): void $this->assertNull($capabilities->experimental); } - public function testWithEvents(): void - { - $capabilities = new ServerCapabilities( - tools: true, - toolsListChanged: false, - resources: true, - resourcesSubscribe: false, - resourcesListChanged: false, - prompts: true, - promptsListChanged: false, - logging: false, - completions: true - ); - - $withEvents = $capabilities->withEvents(); - - $this->assertTrue($withEvents->tools); - $this->assertTrue($withEvents->toolsListChanged); - $this->assertTrue($withEvents->resources); - $this->assertFalse($withEvents->resourcesSubscribe); - $this->assertTrue($withEvents->resourcesListChanged); - $this->assertTrue($withEvents->prompts); - $this->assertTrue($withEvents->promptsListChanged); - $this->assertFalse($withEvents->logging); - $this->assertTrue($withEvents->completions); - } - - public function testWithEventsPreservesResourcesSubscribe(): void - { - $capabilities = new ServerCapabilities( - resourcesSubscribe: true - ); - - $withEvents = $capabilities->withEvents(); - - $this->assertTrue($withEvents->resourcesSubscribe); - $this->assertTrue($withEvents->resourcesListChanged); - } - - public function testWithEventsIsImmutable(): void - { - $original = new ServerCapabilities( - toolsListChanged: false, - resourcesListChanged: false, - promptsListChanged: false - ); - - $withEvents = $original->withEvents(); - - $this->assertFalse($original->toolsListChanged); - $this->assertFalse($original->resourcesListChanged); - $this->assertFalse($original->promptsListChanged); - - $this->assertTrue($withEvents->toolsListChanged); - $this->assertTrue($withEvents->resourcesListChanged); - $this->assertTrue($withEvents->promptsListChanged); - - $this->assertNotSame($original, $withEvents); - } - public function testFromArrayWithEmptyArray(): void { $capabilities = ServerCapabilities::fromArray([]); From 79618f74e20da9f8cf0986790f45ecf6ab7d42a6 Mon Sep 17 00:00:00 2001 From: Pavel Buchnev Date: Tue, 9 Sep 2025 19:48:39 +0400 Subject: [PATCH 19/26] Update src/Capability/Prompt/PromptGetter.php Co-authored-by: Christopher Hertel --- src/Capability/Prompt/PromptGetter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Capability/Prompt/PromptGetter.php b/src/Capability/Prompt/PromptGetter.php index 5d6deedf..ec5ac31a 100644 --- a/src/Capability/Prompt/PromptGetter.php +++ b/src/Capability/Prompt/PromptGetter.php @@ -21,7 +21,7 @@ use Psr\Log\NullLogger; /** - * @author Pavel Buchnev + * @author Pavel Buchnev */ final class PromptGetter implements PromptGetterInterface { From e4135940a32b12cc263fba31b1f097e11638fcd7 Mon Sep 17 00:00:00 2001 From: butschster Date: Tue, 9 Sep 2025 20:37:36 +0400 Subject: [PATCH 20/26] refactor: Use FQN for Resource class to avoid cs-fixer misinterpreting it as `resource` --- src/Capability/Registry/ReferenceProviderInterface.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Capability/Registry/ReferenceProviderInterface.php b/src/Capability/Registry/ReferenceProviderInterface.php index 0419f7eb..6b264001 100644 --- a/src/Capability/Registry/ReferenceProviderInterface.php +++ b/src/Capability/Registry/ReferenceProviderInterface.php @@ -12,7 +12,6 @@ namespace Mcp\Capability\Registry; use Mcp\Schema\Prompt; -use Mcp\Schema\Resource; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\Tool; @@ -54,7 +53,7 @@ public function getTools(): array; /** * Gets all registered resources. * - * @return array + * @return array */ public function getResources(): array; From 9e798ade83f2e2c435c41d5baa2243cd476eb11f Mon Sep 17 00:00:00 2001 From: butschster Date: Tue, 9 Sep 2025 20:48:07 +0400 Subject: [PATCH 21/26] refactor: rename ToolExecutor to ToolCaller and related classes for clarity and consistency --- .../09-standalone-cli/src/ExampleTool.php | 4 ++-- .../Tool/{ToolExecutor.php => ToolCaller.php} | 8 +++---- ...rInterface.php => ToolCallerInterface.php} | 6 ++--- src/Capability/ToolChain.php | 10 ++++---- ...ionException.php => ToolCallException.php} | 4 ++-- src/Server/RequestHandler/CallToolHandler.php | 4 ++-- src/Server/ServerBuilder.php | 10 ++++---- ...oolExecutorTest.php => ToolCallerTest.php} | 24 +++++++++---------- .../RequestHandler/CallToolHandlerTest.php | 21 +++++++++------- .../RequestHandler/GetPromptHandlerTest.php | 7 +++++- .../ReadResourceHandlerTest.php | 3 ++- 11 files changed, 55 insertions(+), 46 deletions(-) rename src/Capability/Tool/{ToolExecutor.php => ToolCaller.php} (91%) rename src/Capability/Tool/{ToolExecutorInterface.php => ToolCallerInterface.php} (81%) rename src/Exception/{ToolExecutionException.php => ToolCallException.php} (67%) rename tests/Capability/Tool/{ToolExecutorTest.php => ToolCallerTest.php} (96%) diff --git a/examples/09-standalone-cli/src/ExampleTool.php b/examples/09-standalone-cli/src/ExampleTool.php index 0eb9010a..559de51d 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/src/Capability/Tool/ToolExecutor.php b/src/Capability/Tool/ToolCaller.php similarity index 91% rename from src/Capability/Tool/ToolExecutor.php rename to src/Capability/Tool/ToolCaller.php index 284890f8..578286aa 100644 --- a/src/Capability/Tool/ToolExecutor.php +++ b/src/Capability/Tool/ToolCaller.php @@ -13,7 +13,7 @@ use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\Registry\ReferenceProviderInterface; -use Mcp\Exception\ToolExecutionException; +use Mcp\Exception\ToolCallException; use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\Content\AudioContent; use Mcp\Schema\Content\EmbeddedResource; @@ -30,7 +30,7 @@ * * @author Pavel Buchnev */ -final class ToolExecutor implements ToolExecutorInterface +final class ToolCaller implements ToolCallerInterface { public function __construct( private readonly ReferenceProviderInterface $referenceProvider, @@ -40,7 +40,7 @@ public function __construct( } /** - * @throws ToolExecutionException if the tool execution fails + * @throws ToolCallException if the tool execution fails * @throws ToolNotFoundException if the tool is not found */ public function call(CallToolRequest $request): CallToolResult @@ -75,7 +75,7 @@ public function call(CallToolRequest $request): CallToolResult 'trace' => $e->getTraceAsString(), ]); - throw new ToolExecutionException($request, $e); + throw new ToolCallException($request, $e); } } } diff --git a/src/Capability/Tool/ToolExecutorInterface.php b/src/Capability/Tool/ToolCallerInterface.php similarity index 81% rename from src/Capability/Tool/ToolExecutorInterface.php rename to src/Capability/Tool/ToolCallerInterface.php index c72b134a..52df19f9 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,10 +19,10 @@ /** * @author Tobias Nyholm */ -interface ToolExecutorInterface +interface ToolCallerInterface { /** - * @throws ToolExecutionException if the tool execution fails + * @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 7baeee67..e500ff00 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 f2df9366..71978d9d 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/Server/RequestHandler/CallToolHandler.php b/src/Server/RequestHandler/CallToolHandler.php index a18e48f9..f40de0e6 100644 --- a/src/Server/RequestHandler/CallToolHandler.php +++ b/src/Server/RequestHandler/CallToolHandler.php @@ -11,7 +11,7 @@ namespace Mcp\Server\RequestHandler; -use Mcp\Capability\Tool\ToolExecutorInterface; +use Mcp\Capability\Tool\ToolCallerInterface; use Mcp\Exception\ExceptionInterface; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\HasMethodInterface; @@ -28,7 +28,7 @@ final class CallToolHandler implements MethodHandlerInterface { public function __construct( - private readonly ToolExecutorInterface $toolExecutor, + private readonly ToolCallerInterface $toolExecutor, private readonly LoggerInterface $logger = new NullLogger(), ) { } diff --git a/src/Server/ServerBuilder.php b/src/Server/ServerBuilder.php index 7db58e3e..719df886 100644 --- a/src/Server/ServerBuilder.php +++ b/src/Server/ServerBuilder.php @@ -25,8 +25,8 @@ use Mcp\Capability\Registry\ReferenceHandler; use Mcp\Capability\Resource\ResourceReader; use Mcp\Capability\Resource\ResourceReaderInterface; -use Mcp\Capability\Tool\ToolExecutor; -use Mcp\Capability\Tool\ToolExecutorInterface; +use Mcp\Capability\Tool\ToolCaller; +use Mcp\Capability\Tool\ToolCallerInterface; use Mcp\Exception\ConfigurationException; use Mcp\JsonRpc\Handler; use Mcp\Schema\Annotations; @@ -55,7 +55,7 @@ final class ServerBuilder private ?CacheInterface $cache = null; - private ?ToolExecutorInterface $toolExecutor = null; + private ?ToolCallerInterface $toolExecutor = null; private ?ResourceReaderInterface $resourceReader = null; @@ -161,7 +161,7 @@ public function withEventDispatcher(EventDispatcherInterface $eventDispatcher): return $this; } - public function withToolExecutor(ToolExecutorInterface $toolExecutor): self + public function withToolExecutor(ToolCallerInterface $toolExecutor): self { $this->toolExecutor = $toolExecutor; @@ -281,7 +281,7 @@ public function build(): Server $registry = new Registry($this->eventDispatcher, $logger); $referenceHandler = new ReferenceHandler($container); - $toolExecutor = $this->toolExecutor ??= new ToolExecutor($registry, $referenceHandler, $logger); + $toolExecutor = $this->toolExecutor ??= new ToolCaller($registry, $referenceHandler, $logger); $resourceReader = $this->resourceReader ??= new ResourceReader($registry, $referenceHandler, $logger); $promptGetter = $this->promptGetter ??= new PromptGetter($registry, $referenceHandler, $logger); diff --git a/tests/Capability/Tool/ToolExecutorTest.php b/tests/Capability/Tool/ToolCallerTest.php similarity index 96% rename from tests/Capability/Tool/ToolExecutorTest.php rename to tests/Capability/Tool/ToolCallerTest.php index 472388af..29f9579c 100644 --- a/tests/Capability/Tool/ToolExecutorTest.php +++ b/tests/Capability/Tool/ToolCallerTest.php @@ -14,8 +14,8 @@ use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Capability\Registry\ToolReference; -use Mcp\Capability\Tool\ToolExecutor; -use Mcp\Exception\ToolExecutionException; +use Mcp\Capability\Tool\ToolCaller; +use Mcp\Exception\ToolCallException; use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Request\CallToolRequest; @@ -25,9 +25,9 @@ use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; -class ToolExecutorTest extends TestCase +class ToolCallerTest extends TestCase { - private ToolExecutor $toolExecutor; + private ToolCaller $toolExecutor; private ReferenceProviderInterface|MockObject $referenceProvider; private ReferenceHandlerInterface|MockObject $referenceHandler; private LoggerInterface|MockObject $logger; @@ -38,7 +38,7 @@ protected function setUp(): void $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); $this->logger = $this->createMock(LoggerInterface::class); - $this->toolExecutor = new ToolExecutor( + $this->toolExecutor = new ToolCaller( $this->referenceProvider, $this->referenceHandler, $this->logger, @@ -211,13 +211,13 @@ public function testCallThrowsToolExecutionExceptionWhenHandlerThrowsException() }) ); - $this->expectException(ToolExecutionException::class); - $this->expectExceptionMessage('Execution of tool "failing_tool" failed with error: "Handler failed".'); + $this->expectException(ToolCallException::class); + $this->expectExceptionMessage('Tool call "failing_tool" failed with error: "Handler failed".'); $thrownException = null; try { $this->toolExecutor->call($request); - } catch (ToolExecutionException $e) { + } catch (ToolCallException $e) { $thrownException = $e; throw $e; } finally { @@ -404,8 +404,8 @@ public function testCallWithDifferentExceptionTypes(): void }) ); - $this->expectException(ToolExecutionException::class); - $this->expectExceptionMessage('Execution of tool "error_tool" failed with error: "Invalid input".'); + $this->expectException(ToolCallException::class); + $this->expectExceptionMessage('Tool call "error_tool" failed with error: "Invalid input".'); $this->toolExecutor->call($request); } @@ -493,10 +493,10 @@ public function testCallLogsResultTypeCorrectlyForArray(): void public function testConstructorWithDefaultLogger(): void { - $executor = new ToolExecutor($this->referenceProvider, $this->referenceHandler); + $executor = new ToolCaller($this->referenceProvider, $this->referenceHandler); // Verify it's constructed without throwing exceptions - $this->assertInstanceOf(ToolExecutor::class, $executor); + $this->assertInstanceOf(ToolCaller::class, $executor); } public function testCallHandlesEmptyArrayResult(): void diff --git a/tests/Server/RequestHandler/CallToolHandlerTest.php b/tests/Server/RequestHandler/CallToolHandlerTest.php index 74d8144a..a4673062 100644 --- a/tests/Server/RequestHandler/CallToolHandlerTest.php +++ b/tests/Server/RequestHandler/CallToolHandlerTest.php @@ -11,8 +11,8 @@ namespace Mcp\Tests\Server\RequestHandler; -use Mcp\Capability\Tool\ToolExecutorInterface; -use Mcp\Exception\ToolExecutionException; +use Mcp\Capability\Tool\ToolCallerInterface; +use Mcp\Exception\ToolCallException; use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\Content\TextContent; use Mcp\Schema\JsonRpc\Error; @@ -28,12 +28,12 @@ class CallToolHandlerTest extends TestCase { private CallToolHandler $handler; - private ToolExecutorInterface|MockObject $toolExecutor; + private ToolCallerInterface|MockObject $toolExecutor; private LoggerInterface|MockObject $logger; protected function setUp(): void { - $this->toolExecutor = $this->createMock(ToolExecutorInterface::class); + $this->toolExecutor = $this->createMock(ToolCallerInterface::class); $this->logger = $this->createMock(LoggerInterface::class); $this->handler = new CallToolHandler( @@ -145,7 +145,7 @@ public function testHandleToolNotFoundExceptionReturnsError(): void public function testHandleToolExecutionExceptionReturnsError(): void { $request = $this->createCallToolRequest('failing_tool', ['param' => 'value']); - $exception = new ToolExecutionException($request, new \RuntimeException('Tool execution failed')); + $exception = new ToolCallException($request, new \RuntimeException('Tool execution failed')); $this->toolExecutor ->expects($this->once()) @@ -157,7 +157,7 @@ public function testHandleToolExecutionExceptionReturnsError(): void ->expects($this->once()) ->method('error') ->with( - 'Error while executing tool "failing_tool": "Execution of tool "failing_tool" failed with error: "Tool execution failed".".', + 'Error while executing tool "failing_tool": "Tool call "failing_tool" failed with error: "Tool execution failed".".', [ 'tool' => 'failing_tool', 'arguments' => ['param' => 'value'], @@ -217,7 +217,7 @@ public function testConstructorWithDefaultLogger(): void public function testHandleLogsErrorWithCorrectParameters(): void { $request = $this->createCallToolRequest('test_tool', ['key1' => 'value1', 'key2' => 42]); - $exception = new ToolExecutionException($request, new \RuntimeException('Custom error message')); + $exception = new ToolCallException($request, new \RuntimeException('Custom error message')); $this->toolExecutor ->expects($this->once()) @@ -228,7 +228,7 @@ public function testHandleLogsErrorWithCorrectParameters(): void ->expects($this->once()) ->method('error') ->with( - 'Error while executing tool "test_tool": "Execution of tool "test_tool" failed with error: "Custom error message".".', + 'Error while executing tool "test_tool": "Tool call "test_tool" failed with error: "Custom error message".".', [ 'tool' => 'test_tool', 'arguments' => ['key1' => 'value1', 'key2' => 42], @@ -277,7 +277,10 @@ public function testHandleWithSpecialCharactersInArguments(): void $this->assertSame($expectedResult, $response->result); } - private function createCallToolRequest(string $name, array $arguments): Request + /** + * @param array $arguments + */ + private function createCallToolRequest(string $name, array $arguments): CallToolRequest { return CallToolRequest::fromArray([ 'jsonrpc' => '2.0', diff --git a/tests/Server/RequestHandler/GetPromptHandlerTest.php b/tests/Server/RequestHandler/GetPromptHandlerTest.php index 75da50cf..d84582e4 100644 --- a/tests/Server/RequestHandler/GetPromptHandlerTest.php +++ b/tests/Server/RequestHandler/GetPromptHandlerTest.php @@ -325,7 +325,12 @@ public function testHandlePromptGetWithLargeNumberOfArguments(): void $this->assertSame($expectedResult, $response->result); } - private function createGetPromptRequest(string $name, ?array $arguments = null): Request + /** + * @param string $name + * @param array|null $arguments + * @return GetPromptRequest + */ + private function createGetPromptRequest(string $name, ?array $arguments = null): GetPromptRequest { return GetPromptRequest::fromArray([ 'jsonrpc' => '2.0', diff --git a/tests/Server/RequestHandler/ReadResourceHandlerTest.php b/tests/Server/RequestHandler/ReadResourceHandlerTest.php index ae54b721..a2413451 100644 --- a/tests/Server/RequestHandler/ReadResourceHandlerTest.php +++ b/tests/Server/RequestHandler/ReadResourceHandlerTest.php @@ -244,6 +244,7 @@ public function testHandleResourceReadWithEmptyContent(): void $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); } @@ -337,7 +338,7 @@ public function testHandleResourceReadWithEmptyResult(): void $this->assertCount(0, $response->result->contents); } - private function createReadResourceRequest(string $uri): Request + private function createReadResourceRequest(string $uri): ReadResourceRequest { return ReadResourceRequest::fromArray([ 'jsonrpc' => '2.0', From e11c0c1c10a0ea9dbe99584d3a8be170d2a58387 Mon Sep 17 00:00:00 2001 From: butschster Date: Tue, 9 Sep 2025 20:48:19 +0400 Subject: [PATCH 22/26] refactor: add missed docblock --- src/JsonRpc/Handler.php | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/JsonRpc/Handler.php b/src/JsonRpc/Handler.php index 32416f11..42b5c126 100644 --- a/src/JsonRpc/Handler.php +++ b/src/JsonRpc/Handler.php @@ -15,7 +15,7 @@ use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Capability\Registry\ReferenceRegistryInterface; use Mcp\Capability\Resource\ResourceReaderInterface; -use Mcp\Capability\Tool\ToolExecutorInterface; +use Mcp\Capability\Tool\ToolCallerInterface; use Mcp\Exception\ExceptionInterface; use Mcp\Exception\HandlerNotFoundException; use Mcp\Exception\InvalidInputMessageException; @@ -50,14 +50,16 @@ 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( ReferenceRegistryInterface $registry, ReferenceProviderInterface $referenceProvider, Implementation $implementation, - ToolExecutorInterface $toolExecutor, + ToolCallerInterface $toolExecutor, ResourceReaderInterface $resourceReader, PromptGetterInterface $promptGetter, LoggerInterface $logger = new NullLogger(), @@ -79,6 +81,12 @@ public static function make( ); } + /** + * @return iterable + * + * @throws ExceptionInterface When a handler throws an exception during message processing + * @throws \JsonException When JSON encoding of the response fails + */ public function process(string $input): iterable { $this->logger->info('Received message to process.', ['message' => $input]); @@ -109,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) { From 3c70c5c154fc35e15bee5ea4121ed79e2ac24c4b Mon Sep 17 00:00:00 2001 From: butschster Date: Tue, 9 Sep 2025 20:52:38 +0400 Subject: [PATCH 23/26] refactor: rename ToolExecutor references to ToolCaller --- src/Capability/Tool/ToolCaller.php | 2 +- src/JsonRpc/Handler.php | 4 ++-- src/Server/RequestHandler/CallToolHandler.php | 4 ++-- src/Server/ServerBuilder.php | 10 +++++----- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/Capability/Tool/ToolCaller.php b/src/Capability/Tool/ToolCaller.php index 578286aa..ef961bb6 100644 --- a/src/Capability/Tool/ToolCaller.php +++ b/src/Capability/Tool/ToolCaller.php @@ -25,7 +25,7 @@ use Psr\Log\NullLogger; /** - * Default implementation of ToolExecutorInterface that uses ReferenceProvider + * Default implementation of ToolCallerInterface that uses ReferenceProvider * and ReferenceHandlerInterface to execute tools. * * @author Pavel Buchnev diff --git a/src/JsonRpc/Handler.php b/src/JsonRpc/Handler.php index 42b5c126..8699eab2 100644 --- a/src/JsonRpc/Handler.php +++ b/src/JsonRpc/Handler.php @@ -59,7 +59,7 @@ public static function make( ReferenceRegistryInterface $registry, ReferenceProviderInterface $referenceProvider, Implementation $implementation, - ToolCallerInterface $toolExecutor, + ToolCallerInterface $toolCaller, ResourceReaderInterface $resourceReader, PromptGetterInterface $promptGetter, LoggerInterface $logger = new NullLogger(), @@ -74,7 +74,7 @@ public static function make( new RequestHandler\GetPromptHandler($promptGetter), new RequestHandler\ListResourcesHandler($referenceProvider), new RequestHandler\ReadResourceHandler($resourceReader), - new RequestHandler\CallToolHandler($toolExecutor, $logger), + new RequestHandler\CallToolHandler($toolCaller, $logger), new RequestHandler\ListToolsHandler($referenceProvider), ], logger: $logger, diff --git a/src/Server/RequestHandler/CallToolHandler.php b/src/Server/RequestHandler/CallToolHandler.php index f40de0e6..28aab382 100644 --- a/src/Server/RequestHandler/CallToolHandler.php +++ b/src/Server/RequestHandler/CallToolHandler.php @@ -28,7 +28,7 @@ final class CallToolHandler implements MethodHandlerInterface { public function __construct( - private readonly ToolCallerInterface $toolExecutor, + private readonly ToolCallerInterface $toolCaller, private readonly LoggerInterface $logger = new NullLogger(), ) { } @@ -43,7 +43,7 @@ public function handle(CallToolRequest|HasMethodInterface $message): Response|Er \assert($message instanceof CallToolRequest); try { - $content = $this->toolExecutor->call($message); + $content = $this->toolCaller->call($message); } catch (ExceptionInterface $exception) { $this->logger->error( \sprintf('Error while executing tool "%s": "%s".', $message->name, $exception->getMessage()), diff --git a/src/Server/ServerBuilder.php b/src/Server/ServerBuilder.php index 719df886..ede6c77d 100644 --- a/src/Server/ServerBuilder.php +++ b/src/Server/ServerBuilder.php @@ -55,7 +55,7 @@ final class ServerBuilder private ?CacheInterface $cache = null; - private ?ToolCallerInterface $toolExecutor = null; + private ?ToolCallerInterface $toolCaller = null; private ?ResourceReaderInterface $resourceReader = null; @@ -161,9 +161,9 @@ public function withEventDispatcher(EventDispatcherInterface $eventDispatcher): return $this; } - public function withToolExecutor(ToolCallerInterface $toolExecutor): self + public function withToolCaller(ToolCallerInterface $toolCaller): self { - $this->toolExecutor = $toolExecutor; + $this->toolCaller = $toolCaller; return $this; } @@ -281,7 +281,7 @@ public function build(): Server $registry = new Registry($this->eventDispatcher, $logger); $referenceHandler = new ReferenceHandler($container); - $toolExecutor = $this->toolExecutor ??= new ToolCaller($registry, $referenceHandler, $logger); + $toolCaller = $this->toolCaller ??= new ToolCaller($registry, $referenceHandler, $logger); $resourceReader = $this->resourceReader ??= new ResourceReader($registry, $referenceHandler, $logger); $promptGetter = $this->promptGetter ??= new PromptGetter($registry, $referenceHandler, $logger); @@ -297,7 +297,7 @@ public function build(): Server registry: $registry, referenceProvider: $registry, implementation: $this->serverInfo, - toolExecutor: $toolExecutor, + toolCaller: $toolCaller, resourceReader: $resourceReader, promptGetter: $promptGetter, logger: $logger, From 7bd23b3c1743d26cfe6acdc8b9f7f60219f4bcb4 Mon Sep 17 00:00:00 2001 From: butschster Date: Tue, 9 Sep 2025 20:56:46 +0400 Subject: [PATCH 24/26] refactor: rename ToolCallerTest property and variable references --- tests/Capability/Tool/ToolCallerTest.php | 40 ++++++++++++------------ 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/tests/Capability/Tool/ToolCallerTest.php b/tests/Capability/Tool/ToolCallerTest.php index 29f9579c..8894dc93 100644 --- a/tests/Capability/Tool/ToolCallerTest.php +++ b/tests/Capability/Tool/ToolCallerTest.php @@ -27,7 +27,7 @@ class ToolCallerTest extends TestCase { - private ToolCaller $toolExecutor; + private ToolCaller $toolCaller; private ReferenceProviderInterface|MockObject $referenceProvider; private ReferenceHandlerInterface|MockObject $referenceHandler; private LoggerInterface|MockObject $logger; @@ -38,7 +38,7 @@ protected function setUp(): void $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); $this->logger = $this->createMock(LoggerInterface::class); - $this->toolExecutor = new ToolCaller( + $this->toolCaller = new ToolCaller( $this->referenceProvider, $this->referenceHandler, $this->logger, @@ -78,7 +78,7 @@ public function testCallExecutesToolSuccessfully(): void ) ); - $result = $this->toolExecutor->call($request); + $result = $this->toolCaller->call($request); $this->assertInstanceOf(CallToolResult::class, $result); $this->assertCount(1, $result->content); @@ -109,7 +109,7 @@ public function testCallWithEmptyArguments(): void ->expects($this->exactly(2)) ->method('debug'); - $result = $this->toolExecutor->call($request); + $result = $this->toolCaller->call($request); $this->assertInstanceOf(CallToolResult::class, $result); } @@ -139,7 +139,7 @@ public function testCallWithComplexArguments(): void ->with($toolReference, $arguments) ->willReturn(['processed' => true]); - $result = $this->toolExecutor->call($request); + $result = $this->toolCaller->call($request); $this->assertInstanceOf(CallToolResult::class, $result); $this->assertCount(1, $result->content); @@ -172,7 +172,7 @@ public function testCallThrowsToolNotFoundExceptionWhenToolNotFound(): void $this->expectException(ToolNotFoundException::class); $this->expectExceptionMessage('Tool not found for call: "nonexistent_tool".'); - $this->toolExecutor->call($request); + $this->toolCaller->call($request); } public function testCallThrowsToolExecutionExceptionWhenHandlerThrowsException(): void @@ -216,7 +216,7 @@ public function testCallThrowsToolExecutionExceptionWhenHandlerThrowsException() $thrownException = null; try { - $this->toolExecutor->call($request); + $this->toolCaller->call($request); } catch (ToolCallException $e) { $thrownException = $e; throw $e; @@ -250,7 +250,7 @@ public function testCallHandlesNullResult(): void ->expects($this->exactly(2)) ->method('debug'); - $result = $this->toolExecutor->call($request); + $result = $this->toolCaller->call($request); $this->assertInstanceOf(CallToolResult::class, $result); $this->assertCount(1, $result->content); @@ -276,7 +276,7 @@ public function testCallHandlesBooleanResults(): void ->with($toolReference, []) ->willReturn(true); - $result = $this->toolExecutor->call($request); + $result = $this->toolCaller->call($request); $this->assertInstanceOf(CallToolResult::class, $result); $this->assertCount(1, $result->content); @@ -303,7 +303,7 @@ public function testCallHandlesArrayResults(): void ->with($toolReference, []) ->willReturn($arrayResult); - $result = $this->toolExecutor->call($request); + $result = $this->toolCaller->call($request); $this->assertInstanceOf(CallToolResult::class, $result); $this->assertCount(1, $result->content); @@ -333,7 +333,7 @@ public function testCallHandlesContentObjectResults(): void ->with($toolReference, []) ->willReturn($contentResult); - $result = $this->toolExecutor->call($request); + $result = $this->toolCaller->call($request); $this->assertInstanceOf(CallToolResult::class, $result); $this->assertCount(1, $result->content); @@ -365,7 +365,7 @@ public function testCallHandlesArrayOfContentResults(): void ->with($toolReference, []) ->willReturn($contentArray); - $result = $this->toolExecutor->call($request); + $result = $this->toolCaller->call($request); $this->assertInstanceOf(CallToolResult::class, $result); $this->assertCount(2, $result->content); @@ -407,7 +407,7 @@ public function testCallWithDifferentExceptionTypes(): void $this->expectException(ToolCallException::class); $this->expectExceptionMessage('Tool call "error_tool" failed with error: "Invalid input".'); - $this->toolExecutor->call($request); + $this->toolCaller->call($request); } public function testCallLogsResultTypeCorrectlyForString(): void @@ -432,7 +432,7 @@ public function testCallLogsResultTypeCorrectlyForString(): void ->expects($this->exactly(2)) ->method('debug'); - $result = $this->toolExecutor->call($request); + $result = $this->toolCaller->call($request); $this->assertInstanceOf(CallToolResult::class, $result); } @@ -459,7 +459,7 @@ public function testCallLogsResultTypeCorrectlyForInteger(): void ->expects($this->exactly(2)) ->method('debug'); - $result = $this->toolExecutor->call($request); + $result = $this->toolCaller->call($request); $this->assertInstanceOf(CallToolResult::class, $result); } @@ -486,7 +486,7 @@ public function testCallLogsResultTypeCorrectlyForArray(): void ->expects($this->exactly(2)) ->method('debug'); - $result = $this->toolExecutor->call($request); + $result = $this->toolCaller->call($request); $this->assertInstanceOf(CallToolResult::class, $result); } @@ -517,7 +517,7 @@ public function testCallHandlesEmptyArrayResult(): void ->with($toolReference, []) ->willReturn([]); - $result = $this->toolExecutor->call($request); + $result = $this->toolCaller->call($request); $this->assertInstanceOf(CallToolResult::class, $result); $this->assertCount(1, $result->content); @@ -549,7 +549,7 @@ public function testCallHandlesMixedContentAndNonContentArray(): void ->with($toolReference, []) ->willReturn($mixedResult); - $result = $this->toolExecutor->call($request); + $result = $this->toolCaller->call($request); $this->assertInstanceOf(CallToolResult::class, $result); // The ToolReference.formatResult should handle this mixed array @@ -576,7 +576,7 @@ public function testCallHandlesStdClassResult(): void ->with($toolReference, []) ->willReturn($objectResult); - $result = $this->toolExecutor->call($request); + $result = $this->toolCaller->call($request); $this->assertInstanceOf(CallToolResult::class, $result); $this->assertCount(1, $result->content); @@ -602,7 +602,7 @@ public function testCallHandlesBooleanFalseResult(): void ->with($toolReference, []) ->willReturn(false); - $result = $this->toolExecutor->call($request); + $result = $this->toolCaller->call($request); $this->assertInstanceOf(CallToolResult::class, $result); $this->assertCount(1, $result->content); From 32f2de904db0d04084efc6713b56445a1943bc0f Mon Sep 17 00:00:00 2001 From: butschster Date: Wed, 10 Sep 2025 00:35:53 +0400 Subject: [PATCH 25/26] cs fix --- src/Capability/Tool/ToolCaller.php | 4 ++-- src/Capability/Tool/ToolCallerInterface.php | 4 ++-- tests/Server/RequestHandler/CallToolHandlerTest.php | 1 - tests/Server/RequestHandler/GetPromptHandlerTest.php | 3 --- tests/Server/RequestHandler/ReadResourceHandlerTest.php | 1 - 5 files changed, 4 insertions(+), 9 deletions(-) diff --git a/src/Capability/Tool/ToolCaller.php b/src/Capability/Tool/ToolCaller.php index ef961bb6..24bc3995 100644 --- a/src/Capability/Tool/ToolCaller.php +++ b/src/Capability/Tool/ToolCaller.php @@ -40,8 +40,8 @@ public function __construct( } /** - * @throws ToolCallException 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/Tool/ToolCallerInterface.php b/src/Capability/Tool/ToolCallerInterface.php index 52df19f9..1ef7ffea 100644 --- a/src/Capability/Tool/ToolCallerInterface.php +++ b/src/Capability/Tool/ToolCallerInterface.php @@ -22,8 +22,8 @@ interface ToolCallerInterface { /** - * @throws ToolCallException 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/tests/Server/RequestHandler/CallToolHandlerTest.php b/tests/Server/RequestHandler/CallToolHandlerTest.php index a4673062..e8f13622 100644 --- a/tests/Server/RequestHandler/CallToolHandlerTest.php +++ b/tests/Server/RequestHandler/CallToolHandlerTest.php @@ -16,7 +16,6 @@ use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\Content\TextContent; use Mcp\Schema\JsonRpc\Error; -use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\Result\CallToolResult; diff --git a/tests/Server/RequestHandler/GetPromptHandlerTest.php b/tests/Server/RequestHandler/GetPromptHandlerTest.php index d84582e4..3debaa05 100644 --- a/tests/Server/RequestHandler/GetPromptHandlerTest.php +++ b/tests/Server/RequestHandler/GetPromptHandlerTest.php @@ -18,7 +18,6 @@ use Mcp\Schema\Content\TextContent; use Mcp\Schema\Enum\Role; use Mcp\Schema\JsonRpc\Error; -use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\GetPromptRequest; use Mcp\Schema\Result\GetPromptResult; @@ -326,9 +325,7 @@ public function testHandlePromptGetWithLargeNumberOfArguments(): void } /** - * @param string $name * @param array|null $arguments - * @return GetPromptRequest */ private function createGetPromptRequest(string $name, ?array $arguments = null): GetPromptRequest { diff --git a/tests/Server/RequestHandler/ReadResourceHandlerTest.php b/tests/Server/RequestHandler/ReadResourceHandlerTest.php index a2413451..6cb8acc6 100644 --- a/tests/Server/RequestHandler/ReadResourceHandlerTest.php +++ b/tests/Server/RequestHandler/ReadResourceHandlerTest.php @@ -17,7 +17,6 @@ use Mcp\Schema\Content\BlobResourceContents; use Mcp\Schema\Content\TextResourceContents; use Mcp\Schema\JsonRpc\Error; -use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ReadResourceRequest; use Mcp\Schema\Result\ReadResourceResult; From 0ca952be9b9aa7e754273dbeac8430c9076e130f Mon Sep 17 00:00:00 2001 From: butschster Date: Thu, 11 Sep 2025 00:41:20 +0400 Subject: [PATCH 26/26] ignore some phpstan errors --- phpstan-baseline.neon | 54 ++++++++++++++++++++++++++++--------------- 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index ba5e4509..1ac806b3 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -367,59 +367,77 @@ 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 + message: '#^PHPDoc tag @return with type array is incompatible with native type object\.$#' + identifier: return.phpDocType count: 1 - path: examples/09-standalone-cli/src/Builder.php + path: src/Schema/Result/EmptyResult.php - - message: '#^Parameter \#1 \$registry of class Mcp\\Server\\RequestHandler\\ListToolsHandler constructor expects Mcp\\Capability\\Registry, Mcp\\Capability\\ToolChain given\.$#' - identifier: argument.type + message: '#^Method Mcp\\Schema\\Result\\ReadResourceResult\:\:jsonSerialize\(\) should return array\{contents\: array\\} but returns array\{contents\: array\\}\.$#' + identifier: return.type count: 1 - path: examples/09-standalone-cli/src/Builder.php + path: src/Schema/Result/ReadResourceResult.php - - message: '#^Parameter \#1 \$registry of class Mcp\\Server\\RequestHandler\\ReadResourceHandler constructor expects Mcp\\Capability\\Registry, Mcp\\Capability\\ResourceChain given\.$#' - identifier: argument.type + message: '#^Result of && is always false\.$#' + identifier: booleanAnd.alwaysFalse count: 1 - path: examples/09-standalone-cli/src/Builder.php + path: src/Server/RequestHandler/ListResourcesHandler.php - - message: '#^PHPDoc tag @return with type array is incompatible with native type object\.$#' - identifier: return.phpDocType + message: '#^Method Mcp\\Capability\\Registry\\ReferenceProviderInterface\:\:getPrompts\(\) invoked with 2 parameters, 0 required\.$#' + identifier: arguments.count count: 1 - path: src/Schema/Result/EmptyResult.php + path: src/Server/RequestHandler/ListPromptsHandler.php - - message: '#^Method Mcp\\Schema\\Result\\ReadResourceResult\:\:jsonSerialize\(\) should return array\{contents\: array\\} but returns array\{contents\: array\\}\.$#' - identifier: return.type + message: '#^Call to an undefined method Mcp\\Capability\\Registry\\ResourceTemplateReference\:\:handle\(\)\.$#' + identifier: method.notFound count: 1 - path: src/Schema/Result/ReadResourceResult.php + path: src/Capability/Registry/ResourceTemplateReference.php - message: '#^Result of && is always false\.$#' identifier: booleanAnd.alwaysFalse count: 1 + path: src/Server/RequestHandler/ListPromptsHandler.php + + - + message: '#^Strict comparison using \!\=\= between null and null will always evaluate to false\.$#' + identifier: notIdentical.alwaysFalse + count: 1 + path: src/Server/RequestHandler/ListPromptsHandler.php + + - + 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: '#^Method Mcp\\Capability\\Registry\\ReferenceProviderInterface\:\:getTools\(\) invoked with 2 parameters, 0 required\.$#' + identifier: arguments.count + count: 1 + path: src/Server/RequestHandler/ListToolsHandler.php + - message: '#^Strict comparison using \!\=\= between null and null will always evaluate to false\.$#' identifier: notIdentical.alwaysFalse