diff --git a/docs/server-builder.md b/docs/server-builder.md index 0d51ed79..f673000c 100644 --- a/docs/server-builder.md +++ b/docs/server-builder.md @@ -12,7 +12,7 @@ various aspects of the server behavior. - [Session Management](#session-management) - [Manual Capability Registration](#manual-capability-registration) - [Service Dependencies](#service-dependencies) -- [Custom Capability Handlers](#custom-capability-handlers) +- [Custom Method Handlers](#custom-method-handlers) - [Complete Example](#complete-example) - [Method Reference](#method-reference) @@ -344,102 +344,50 @@ $server = Server::builder() ->setEventDispatcher($eventDispatcher); ``` -## Custom Capability Handlers +## Custom Method Handlers -**Advanced customization for specific use cases.** Override the default capability handlers when you need completely custom -behavior for how tools are executed, resources are read, or prompts are generated. Most users should stick with the default implementations. +**Low-level escape hatch.** Custom method handlers run before the SDK’s built-in handlers and give you total control over +individual JSON-RPC methods. They do not receive the builder’s registry, container, or discovery output unless you pass +those dependencies in yourself. -The default handlers work by: -1. Looking up registered tools/resources/prompts by name/URI -2. Resolving the handler from the container -3. Executing the handler with the provided arguments -4. Formatting the result and handling errors - -### Custom Tool Caller - -Replace how tool execution requests are processed. Your custom `ToolCallerInterface` receives a `CallToolRequest` (with -tool name and arguments) and must return a `CallToolResult`. +Attach handlers with `addMethodHandler()` (single) or `addMethodHandlers()` (multiple). You can call these methods as +many times as needed; each call prepends the handlers so they execute before the defaults: ```php -use Mcp\Capability\Tool\ToolCallerInterface; -use Mcp\Schema\Request\CallToolRequest; -use Mcp\Schema\Result\CallToolResult; - -class CustomToolCaller implements ToolCallerInterface -{ - public function call(CallToolRequest $request): CallToolResult - { - // Custom tool routing, execution, authentication, caching, etc. - // You handle finding the tool, executing it, and formatting results - $toolName = $request->name; - $arguments = $request->arguments ?? []; - - // Your custom logic here - return new CallToolResult([/* content */]); - } -} - $server = Server::builder() - ->setToolCaller(new CustomToolCaller()); + ->addMethodHandler(new AuditHandler()) + ->addMethodHandlers([ + new CustomListToolsHandler(), + new CustomCallToolHandler(), + ]) + ->build(); ``` -### Custom Resource Reader - -Replace how resource reading requests are processed. Your custom `ResourceReaderInterface` receives a `ReadResourceRequest` -(with URI) and must return a `ReadResourceResult`. +Custom handlers implement `MethodHandlerInterface`: ```php -use Mcp\Capability\Resource\ResourceReaderInterface; -use Mcp\Schema\Request\ReadResourceRequest; -use Mcp\Schema\Result\ReadResourceResult; +use Mcp\Schema\JsonRpc\HasMethodInterface; +use Mcp\Server\Handler\MethodHandlerInterface; +use Mcp\Server\Session\SessionInterface; -class CustomResourceReader implements ResourceReaderInterface +interface MethodHandlerInterface { - public function read(ReadResourceRequest $request): ReadResourceResult - { - // Custom resource resolution, caching, access control, etc. - $uri = $request->uri; - - // Your custom logic here - return new ReadResourceResult([/* content */]); - } -} + public function supports(HasMethodInterface $message): bool; -$server = Server::builder() - ->setResourceReader(new CustomResourceReader()); + public function handle(HasMethodInterface $message, SessionInterface $session); +} ``` -### Custom Prompt Getter - -Replace how prompt generation requests are processed. Your custom `PromptGetterInterface` receives a `GetPromptRequest` -(with prompt name and arguments) and must return a `GetPromptResult`. - -```php -use Mcp\Capability\Prompt\PromptGetterInterface; -use Mcp\Schema\Request\GetPromptRequest; -use Mcp\Schema\Result\GetPromptResult; - -class CustomPromptGetter implements PromptGetterInterface -{ - public function get(GetPromptRequest $request): GetPromptResult - { - // Custom prompt generation, template engines, dynamic content, etc. - $promptName = $request->name; - $arguments = $request->arguments ?? []; - - // Your custom logic here - return new GetPromptResult([/* messages */]); - } -} +- `supports()` decides if the handler should look at the incoming message. +- `handle()` must return a JSON-RPC `Response`, an `Error`, or `null`. -$server = Server::builder() - ->setPromptGetter(new CustomPromptGetter()); -``` +Check out `examples/custom-method-handlers/server.php` for a complete example showing how to implement +custom `tool/list` and `tool/call` methods independently of the registry. -> **Warning**: Custom capability handlers bypass the entire default registration system (discovered attributes, manual -> registration, container resolution, etc.). You become responsible for all aspect of execution, including error handling, -> logging, and result formatting. Only use this for very specific advanced use cases like custom authentication, complex -> routing, or integration with external systems. +> **Warning**: Custom method handlers bypass discovery, manual capability registration, and container lookups (unlesss +> you explicitly pass them). Tools, resources, and prompts you register elsewhere will not show up unless your handler +> loads and executes them manually. +> Reach for this API only when you need that level of control and are comfortable taking on the additional plumbing. ## Complete Example @@ -505,9 +453,8 @@ $server = Server::builder() | `setLogger()` | logger | Set PSR-3 logger | | `setContainer()` | container | Set PSR-11 container | | `setEventDispatcher()` | dispatcher | Set PSR-14 event dispatcher | -| `setToolCaller()` | caller | Set custom tool caller | -| `setResourceReader()` | reader | Set custom resource reader | -| `setPromptGetter()` | getter | Set custom prompt getter | +| `addMethodHandler()` | handler | Prepend a single custom method handler | +| `addMethodHandlers()` | handlers | Prepend multiple custom method handlers | | `addTool()` | handler, name?, description?, annotations?, inputSchema? | Register tool | | `addResource()` | handler, uri, name?, description?, mimeType?, size?, annotations? | Register resource | | `addResourceTemplate()` | handler, uriTemplate, name?, description?, mimeType?, annotations? | Register resource template | diff --git a/examples/custom-method-handlers/server.php b/examples/custom-method-handlers/server.php new file mode 100644 index 00000000..b1db5f8f --- /dev/null +++ b/examples/custom-method-handlers/server.php @@ -0,0 +1,144 @@ +#!/usr/bin/env php +info('Starting MCP Custom Method Handlers (Stdio) Server...'); + +$toolDefinitions = [ + 'say_hello' => new Tool( + name: 'say_hello', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string', 'description' => 'Name to greet'], + ], + 'required' => ['name'], + ], + description: 'Greets a user by name.', + annotations: null, + ), + 'sum' => new Tool( + name: 'sum', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'a' => ['type' => 'number'], + 'b' => ['type' => 'number'], + ], + 'required' => ['a', 'b'], + ], + description: 'Returns a+b.', + annotations: null, + ), +]; + +$listToolsHandler = new class($toolDefinitions) implements MethodHandlerInterface { + /** + * @param array $toolDefinitions + */ + public function __construct(private array $toolDefinitions) + { + } + + public function supports(HasMethodInterface $message): bool + { + return $message instanceof ListToolsRequest; + } + + public function handle(ListToolsRequest|HasMethodInterface $message, SessionInterface $session): Response + { + assert($message instanceof ListToolsRequest); + + return new Response($message->getId(), new ListToolsResult(array_values($this->toolDefinitions), null)); + } +}; + +$callToolHandler = new class($toolDefinitions) implements MethodHandlerInterface { + /** + * @param array $toolDefinitions + */ + public function __construct(private array $toolDefinitions) + { + } + + public function supports(HasMethodInterface $message): bool + { + return $message instanceof CallToolRequest; + } + + public function handle(CallToolRequest|HasMethodInterface $message, SessionInterface $session): Response|Error + { + assert($message instanceof CallToolRequest); + + $name = $message->name; + $args = $message->arguments ?? []; + + if (!isset($this->toolDefinitions[$name])) { + return new Error($message->getId(), Error::METHOD_NOT_FOUND, sprintf('Tool not found: %s', $name)); + } + + try { + switch ($name) { + case 'say_hello': + $greetName = (string) ($args['name'] ?? 'world'); + $result = [new TextContent(sprintf('Hello, %s!', $greetName))]; + break; + case 'sum': + $a = (float) ($args['a'] ?? 0); + $b = (float) ($args['b'] ?? 0); + $result = [new TextContent((string) ($a + $b))]; + break; + default: + $result = [new TextContent('Unknown tool')]; + } + + return new Response($message->getId(), new CallToolResult($result)); + } catch (Throwable $e) { + return new Response($message->getId(), new CallToolResult([new TextContent('Tool execution failed')], true)); + } + } +}; + +$capabilities = new ServerCapabilities(tools: true, resources: false, prompts: false); + +$server = Server::builder() + ->setServerInfo('Custom Handlers Server', '1.0.0') + ->setLogger(logger()) + ->setContainer(container()) + ->setCapabilities($capabilities) + ->addMethodHandlers([$listToolsHandler, $callToolHandler]) + ->build(); + +$transport = new StdioTransport(logger: logger()); + +$server->connect($transport); + +$transport->listen(); + +logger()->info('Server listener stopped gracefully.'); diff --git a/src/Capability/Completion/Completer.php b/src/Capability/Completion/Completer.php deleted file mode 100644 index eaffaee1..00000000 --- a/src/Capability/Completion/Completer.php +++ /dev/null @@ -1,75 +0,0 @@ - - */ -final class Completer implements CompleterInterface -{ - public function __construct( - private readonly ReferenceProviderInterface $referenceProvider, - private readonly ?ContainerInterface $container = null, - ) { - } - - public function complete(CompletionCompleteRequest $request): CompletionCompleteResult - { - $argumentName = $request->argument['name'] ?? ''; - $currentValue = $request->argument['value'] ?? ''; - - $reference = match (true) { - 'ref/prompt' === $request->ref->type => $this->referenceProvider->getPrompt($request->ref->name), - 'ref/resource' === $request->ref->type => $this->referenceProvider->getResourceTemplate($request->ref->uri), - default => null, - }; - - if (null === $reference) { - return new CompletionCompleteResult([]); - } - - $providerClassOrInstance = $reference->completionProviders[$argumentName] ?? null; - if (null === $providerClassOrInstance) { - return new CompletionCompleteResult([]); - } - - if (\is_string($providerClassOrInstance)) { - if (!class_exists($providerClassOrInstance)) { - throw new RuntimeException(\sprintf('Completion provider class "%s" does not exist.', $providerClassOrInstance)); - } - - $provider = $this->container?->has($providerClassOrInstance) - ? $this->container->get($providerClassOrInstance) - : new $providerClassOrInstance(); - } else { - $provider = $providerClassOrInstance; - } - - if (!$provider instanceof ProviderInterface) { - throw new RuntimeException('Completion provider must implement ProviderInterface.'); - } - - $completions = $provider->getCompletions($currentValue); - - $total = \count($completions); - $hasMore = $total > 100; - $pagedCompletions = \array_slice($completions, 0, 100); - - return new CompletionCompleteResult($pagedCompletions, $total, $hasMore); - } -} diff --git a/src/Capability/Completion/CompleterInterface.php b/src/Capability/Completion/CompleterInterface.php deleted file mode 100644 index 9f9f871c..00000000 --- a/src/Capability/Completion/CompleterInterface.php +++ /dev/null @@ -1,25 +0,0 @@ - - */ -interface CompleterInterface -{ - public function complete(CompletionCompleteRequest $request): CompletionCompleteResult; -} diff --git a/src/Capability/Prompt/PromptGetter.php b/src/Capability/Prompt/PromptGetter.php deleted file mode 100644 index ec5ac31a..00000000 --- a/src/Capability/Prompt/PromptGetter.php +++ /dev/null @@ -1,69 +0,0 @@ - - */ -final class PromptGetter implements PromptGetterInterface -{ - public function __construct( - private readonly ReferenceProviderInterface $referenceProvider, - private readonly ReferenceHandlerInterface $referenceHandler, - private readonly LoggerInterface $logger = new NullLogger(), - ) { - } - - public function get(GetPromptRequest $request): GetPromptResult - { - $promptName = $request->name; - $arguments = $request->arguments ?? []; - - $this->logger->debug('Getting prompt', ['name' => $promptName, 'arguments' => $arguments]); - - $reference = $this->referenceProvider->getPrompt($promptName); - - if (null === $reference) { - $this->logger->warning('Prompt not found', ['name' => $promptName]); - throw new PromptNotFoundException($request); - } - - try { - $result = $this->referenceHandler->handle($reference, $arguments); - $formattedResult = $reference->formatResult($result); - - $this->logger->debug('Prompt retrieved successfully', [ - 'name' => $promptName, - 'result_type' => \gettype($result), - ]); - - return new GetPromptResult($formattedResult); - } catch (\Throwable $e) { - $this->logger->error('Prompt retrieval failed', [ - 'name' => $promptName, - 'exception' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - ]); - - throw new PromptGetException($request, $e); - } - } -} diff --git a/src/Capability/Prompt/PromptGetterInterface.php b/src/Capability/Prompt/PromptGetterInterface.php deleted file mode 100644 index 35d7b9fb..00000000 --- a/src/Capability/Prompt/PromptGetterInterface.php +++ /dev/null @@ -1,29 +0,0 @@ - - */ -interface PromptGetterInterface -{ - /** - * @throws PromptGetException if the prompt execution fails - * @throws PromptNotFoundException if the prompt is not found - */ - public function get(GetPromptRequest $request): GetPromptResult; -} diff --git a/src/Capability/Resource/ResourceReader.php b/src/Capability/Resource/ResourceReader.php deleted file mode 100644 index 2496cfaf..00000000 --- a/src/Capability/Resource/ResourceReader.php +++ /dev/null @@ -1,68 +0,0 @@ - - */ -final class ResourceReader implements ResourceReaderInterface -{ - public function __construct( - private readonly ReferenceProviderInterface $referenceProvider, - private readonly ReferenceHandlerInterface $referenceHandler, - private readonly LoggerInterface $logger = new NullLogger(), - ) { - } - - public function read(ReadResourceRequest $request): ReadResourceResult - { - $uri = $request->uri; - - $this->logger->debug('Reading resource', ['uri' => $uri]); - - $reference = $this->referenceProvider->getResource($uri); - - if (null === $reference) { - $this->logger->warning('Resource not found', ['uri' => $uri]); - throw new ResourceNotFoundException($request); - } - - try { - $result = $this->referenceHandler->handle($reference, ['uri' => $uri]); - $formattedResult = $reference->formatResult($result, $uri); - - $this->logger->debug('Resource read successfully', [ - 'uri' => $uri, - 'result_type' => \gettype($result), - ]); - - return new ReadResourceResult($formattedResult); - } catch (\Throwable $e) { - $this->logger->error('Resource read failed', [ - 'uri' => $uri, - 'exception' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - ]); - - throw new ResourceReadException($request, $e); - } - } -} diff --git a/src/Capability/Resource/ResourceReaderInterface.php b/src/Capability/Resource/ResourceReaderInterface.php deleted file mode 100644 index cba468cd..00000000 --- a/src/Capability/Resource/ResourceReaderInterface.php +++ /dev/null @@ -1,29 +0,0 @@ - - */ -interface ResourceReaderInterface -{ - /** - * @throws ResourceReadException if the resource execution fails - * @throws ResourceNotFoundException if the resource is not found - */ - public function read(ReadResourceRequest $request): ReadResourceResult; -} diff --git a/src/Capability/Tool/ToolCaller.php b/src/Capability/Tool/ToolCaller.php deleted file mode 100644 index 24bc3995..00000000 --- a/src/Capability/Tool/ToolCaller.php +++ /dev/null @@ -1,81 +0,0 @@ - - */ -final class ToolCaller implements ToolCallerInterface -{ - public function __construct( - private readonly ReferenceProviderInterface $referenceProvider, - private readonly ReferenceHandlerInterface $referenceHandler, - private readonly LoggerInterface $logger = new NullLogger(), - ) { - } - - /** - * @throws ToolCallException if the tool execution fails - * @throws ToolNotFoundException if the tool is not found - */ - public function call(CallToolRequest $request): CallToolResult - { - $toolName = $request->name; - $arguments = $request->arguments ?? []; - - $this->logger->debug('Executing tool', ['name' => $toolName, 'arguments' => $arguments]); - - $toolReference = $this->referenceProvider->getTool($toolName); - - if (null === $toolReference) { - $this->logger->warning('Tool not found', ['name' => $toolName]); - throw new ToolNotFoundException($request); - } - - try { - $result = $this->referenceHandler->handle($toolReference, $arguments); - /** @var TextContent[]|ImageContent[]|EmbeddedResource[]|AudioContent[] $formattedResult */ - $formattedResult = $toolReference->formatResult($result); - - $this->logger->debug('Tool executed successfully', [ - 'name' => $toolName, - 'result_type' => \gettype($result), - ]); - - return new CallToolResult($formattedResult); - } catch (\Throwable $e) { - $this->logger->error('Tool execution failed', [ - 'name' => $toolName, - 'exception' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - ]); - - throw new ToolCallException($request, $e); - } - } -} diff --git a/src/Capability/Tool/ToolCallerInterface.php b/src/Capability/Tool/ToolCallerInterface.php deleted file mode 100644 index 1ef7ffea..00000000 --- a/src/Capability/Tool/ToolCallerInterface.php +++ /dev/null @@ -1,29 +0,0 @@ - - */ -interface ToolCallerInterface -{ - /** - * @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/Schema/Result/CallToolResult.php b/src/Schema/Result/CallToolResult.php index 4345f81b..8a01bbb2 100644 --- a/src/Schema/Result/CallToolResult.php +++ b/src/Schema/Result/CallToolResult.php @@ -17,7 +17,6 @@ use Mcp\Schema\Content\EmbeddedResource; use Mcp\Schema\Content\ImageContent; use Mcp\Schema\Content\TextContent; -use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\JsonRpc\ResultInterface; /** @@ -32,11 +31,6 @@ * server does not support tool calls, or any other exceptional conditions, * should be reported as an MCP error response. * - * @phpstan-import-type TextContentData from TextContent - * @phpstan-import-type ImageContentData from ImageContent - * @phpstan-import-type AudioContentData from AudioContent - * @phpstan-import-type EmbeddedResourceData from EmbeddedResource - * * @author Kyrian Obikwelu */ class CallToolResult implements ResultInterface @@ -44,8 +38,8 @@ class CallToolResult implements ResultInterface /** * Create a new CallToolResult. * - * @param array $content The content of the tool result - * @param bool $isError Whether the tool execution resulted in an error. If not set, this is assumed to be false (the call was successful). + * @param Content[] $content The content of the tool result + * @param bool $isError Whether the tool execution resulted in an error. If not set, this is assumed to be false (the call was successful). */ public function __construct( public readonly array $content, @@ -61,7 +55,7 @@ public function __construct( /** * Create a new CallToolResult with success status. * - * @param array $content The content of the tool result + * @param Content[] $content The content of the tool result */ public static function success(array $content): self { @@ -71,7 +65,7 @@ public static function success(array $content): self /** * Create a new CallToolResult with error status. * - * @param array $content The content of the tool result + * @param Content[] $content The content of the tool result */ public static function error(array $content): self { @@ -80,7 +74,7 @@ public static function error(array $content): self /** * @param array{ - * content: array, + * content: array, * isError?: bool, * } $data */ @@ -107,7 +101,7 @@ public static function fromArray(array $data): self /** * @return array{ - * content: array, + * content: array, * isError: bool, * } */ diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 82c50f2a..8ac2dc4a 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -20,27 +20,24 @@ use Mcp\Capability\Discovery\DocBlockParser; use Mcp\Capability\Discovery\HandlerResolver; use Mcp\Capability\Discovery\SchemaGenerator; -use Mcp\Capability\Prompt\PromptGetter; -use Mcp\Capability\Prompt\PromptGetterInterface; use Mcp\Capability\Registry; use Mcp\Capability\Registry\Container; use Mcp\Capability\Registry\ElementReference; use Mcp\Capability\Registry\ReferenceHandler; -use Mcp\Capability\Resource\ResourceReader; -use Mcp\Capability\Resource\ResourceReaderInterface; -use Mcp\Capability\Tool\ToolCaller; -use Mcp\Capability\Tool\ToolCallerInterface; use Mcp\Exception\ConfigurationException; +use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\Annotations; use Mcp\Schema\Implementation; use Mcp\Schema\Prompt; use Mcp\Schema\PromptArgument; use Mcp\Schema\Resource; use Mcp\Schema\ResourceTemplate; +use Mcp\Schema\ServerCapabilities; use Mcp\Schema\Tool; use Mcp\Schema\ToolAnnotations; use Mcp\Server; use Mcp\Server\Handler\JsonRpcHandler; +use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\InMemorySessionStore; use Mcp\Server\Session\SessionFactory; use Mcp\Server\Session\SessionFactoryInterface; @@ -64,12 +61,6 @@ final class Builder private ?CacheInterface $discoveryCache = null; - private ?ToolCallerInterface $toolCaller = null; - - private ?ResourceReaderInterface $resourceReader = null; - - private ?PromptGetterInterface $promptGetter = null; - private ?EventDispatcherInterface $eventDispatcher = null; private ?ContainerInterface $container = null; @@ -84,6 +75,13 @@ final class Builder private ?string $instructions = null; + private ?ServerCapabilities $explicitCapabilities = null; + + /** + * @var array + */ + private array $customMethodHandlers = []; + /** * @var array{ * handler: Handler, @@ -175,39 +173,52 @@ public function setInstructions(?string $instructions): self } /** - * Provides a PSR-3 logger instance. Defaults to NullLogger. + * Explicitly set server capabilities. If set, this overrides automatic detection. */ - public function setLogger(LoggerInterface $logger): self + public function setCapabilities(ServerCapabilities $capabilities): self { - $this->logger = $logger; + $this->explicitCapabilities = $capabilities; return $this; } - public function setEventDispatcher(EventDispatcherInterface $eventDispatcher): self + /** + * Register a single custom method handler. + */ + public function addMethodHandler(MethodHandlerInterface $handler): self { - $this->eventDispatcher = $eventDispatcher; + $this->customMethodHandlers[] = $handler; return $this; } - public function setToolCaller(ToolCallerInterface $toolCaller): self + /** + * Register multiple custom method handlers. + * + * @param iterable $handlers + */ + public function addMethodHandlers(iterable $handlers): self { - $this->toolCaller = $toolCaller; + foreach ($handlers as $handler) { + $this->customMethodHandlers[] = $handler; + } return $this; } - public function setResourceReader(ResourceReaderInterface $resourceReader): self + /** + * Provides a PSR-3 logger instance. Defaults to NullLogger. + */ + public function setLogger(LoggerInterface $logger): self { - $this->resourceReader = $resourceReader; + $this->logger = $logger; return $this; } - public function setPromptGetter(PromptGetterInterface $promptGetter): self + public function setEventDispatcher(EventDispatcherInterface $eventDispatcher): self { - $this->promptGetter = $promptGetter; + $this->eventDispatcher = $eventDispatcher; return $this; } @@ -333,49 +344,60 @@ public function addPrompt(\Closure|array|string $handler, ?string $name = null, public function build(): Server { $logger = $this->logger ?? new NullLogger(); - $container = $this->container ?? new Container(); $registry = new Registry($this->eventDispatcher, $logger); - $referenceHandler = new ReferenceHandler($container); - $toolCaller = $this->toolCaller ??= new ToolCaller($registry, $referenceHandler, $logger); - $resourceReader = $this->resourceReader ??= new ResourceReader($registry, $referenceHandler, $logger); - $promptGetter = $this->promptGetter ??= new PromptGetter($registry, $referenceHandler, $logger); - $this->registerCapabilities($registry, $logger); if (null !== $this->discoveryBasePath) { - $discovery = new Discoverer($registry, $logger); - - if (null !== $this->discoveryCache) { - $discovery = new CachedDiscoverer($discovery, $this->discoveryCache, $logger); - } - - $discovery->discover($this->discoveryBasePath, $this->discoveryScanDirs, $this->discoveryExcludeDirs); + $this->performDiscovery($registry, $logger); } $sessionTtl = $this->sessionTtl ?? 3600; $sessionFactory = $this->sessionFactory ?? new SessionFactory(); $sessionStore = $this->sessionStore ?? new InMemorySessionStore($sessionTtl); + $messageFactory = MessageFactory::make(); - return new Server( - jsonRpcHandler: JsonRpcHandler::make( - registry: $registry, - referenceProvider: $registry, - configuration: new Configuration( - $this->serverInfo, - $registry->getCapabilities(), - $this->paginationLimit, $this->instructions, - ), - toolCaller: $toolCaller, - resourceReader: $resourceReader, - promptGetter: $promptGetter, - sessionStore: $sessionStore, - sessionFactory: $sessionFactory, - logger: $logger, - ), + $capabilities = $this->explicitCapabilities ?? $registry->getCapabilities(); + $configuration = new Configuration($this->serverInfo, $capabilities, $this->paginationLimit, $this->instructions); + $referenceHandler = new ReferenceHandler($container); + + $methodHandlers = array_merge($this->customMethodHandlers, [ + new Handler\Request\PingHandler(), + new Handler\Request\InitializeHandler($configuration), + new Handler\Request\ListToolsHandler($registry, $this->paginationLimit), + new Handler\Request\CallToolHandler($registry, $referenceHandler, $logger), + new Handler\Request\ListResourcesHandler($registry, $this->paginationLimit), + new Handler\Request\ListResourceTemplatesHandler($registry, $this->paginationLimit), + new Handler\Request\ReadResourceHandler($registry, $referenceHandler, $logger), + new Handler\Request\ListPromptsHandler($registry, $this->paginationLimit), + new Handler\Request\GetPromptHandler($registry, $referenceHandler, $logger), + + new Handler\Notification\InitializedHandler(), + ]); + + $jsonRpcHandler = new JsonRpcHandler( + methodHandlers: $methodHandlers, + messageFactory: $messageFactory, + sessionFactory: $sessionFactory, + sessionStore: $sessionStore, logger: $logger, ); + + return new Server($jsonRpcHandler, $logger); + } + + private function performDiscovery( + Registry\ReferenceRegistryInterface $registry, + LoggerInterface $logger, + ): void { + $discovery = new Discoverer($registry, $logger); + + if (null !== $this->discoveryCache) { + $discovery = new CachedDiscoverer($discovery, $this->discoveryCache, $logger); + } + + $discovery->discover($this->discoveryBasePath, $this->discoveryScanDirs, $this->discoveryExcludeDirs); } /** diff --git a/src/Server/Handler/JsonRpcHandler.php b/src/Server/Handler/JsonRpcHandler.php index ab1eb106..5f785bc5 100644 --- a/src/Server/Handler/JsonRpcHandler.php +++ b/src/Server/Handler/JsonRpcHandler.php @@ -11,12 +11,6 @@ namespace Mcp\Server\Handler; -use Mcp\Capability\Completion\Completer; -use Mcp\Capability\Prompt\PromptGetterInterface; -use Mcp\Capability\Registry\ReferenceProviderInterface; -use Mcp\Capability\Registry\ReferenceRegistryInterface; -use Mcp\Capability\Resource\ResourceReaderInterface; -use Mcp\Capability\Tool\ToolCallerInterface; use Mcp\Exception\ExceptionInterface; use Mcp\Exception\HandlerNotFoundException; use Mcp\Exception\InvalidInputMessageException; @@ -27,8 +21,6 @@ use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\InitializeRequest; -use Mcp\Server\Configuration; -use Mcp\Server\Handler; use Mcp\Server\Session\SessionFactoryInterface; use Mcp\Server\Session\SessionInterface; use Mcp\Server\Session\SessionStoreInterface; @@ -44,56 +36,15 @@ class JsonRpcHandler { /** - * @var array - */ - private readonly array $methodHandlers; - - /** - * @param iterable $methodHandlers + * @param array $methodHandlers */ public function __construct( + private readonly array $methodHandlers, private readonly MessageFactory $messageFactory, private readonly SessionFactoryInterface $sessionFactory, private readonly SessionStoreInterface $sessionStore, - iterable $methodHandlers, private readonly LoggerInterface $logger = new NullLogger(), ) { - $this->methodHandlers = $methodHandlers instanceof \Traversable ? iterator_to_array( - $methodHandlers, - ) : $methodHandlers; - } - - public static function make( - ReferenceRegistryInterface $registry, - ReferenceProviderInterface $referenceProvider, - Configuration $configuration, - ToolCallerInterface $toolCaller, - ResourceReaderInterface $resourceReader, - PromptGetterInterface $promptGetter, - SessionStoreInterface $sessionStore, - SessionFactoryInterface $sessionFactory, - LoggerInterface $logger = new NullLogger(), - int $paginationLimit = 50, - ): self { - return new self( - messageFactory: MessageFactory::make(), - sessionFactory: $sessionFactory, - sessionStore: $sessionStore, - methodHandlers: [ - new Notification\InitializedHandler(), - new Handler\Request\InitializeHandler($configuration), - new Handler\Request\PingHandler(), - new Handler\Request\ListPromptsHandler($referenceProvider, $paginationLimit), - new Handler\Request\GetPromptHandler($promptGetter), - new Handler\Request\ListResourcesHandler($referenceProvider, $paginationLimit), - new Handler\Request\ReadResourceHandler($resourceReader), - new Handler\Request\ListResourceTemplatesHandler($referenceProvider, $paginationLimit), - new Handler\Request\CallToolHandler($toolCaller, $logger), - new Handler\Request\ListToolsHandler($referenceProvider, $paginationLimit), - new Handler\Request\CompletionCompleteHandler(new Completer($referenceProvider)), - ], - logger: $logger, - ); } /** diff --git a/src/Server/Handler/Request/CallToolHandler.php b/src/Server/Handler/Request/CallToolHandler.php index 9e83037e..d9b36066 100644 --- a/src/Server/Handler/Request/CallToolHandler.php +++ b/src/Server/Handler/Request/CallToolHandler.php @@ -11,12 +11,16 @@ namespace Mcp\Server\Handler\Request; -use Mcp\Capability\Tool\ToolCallerInterface; +use Mcp\Capability\Registry\ReferenceHandlerInterface; +use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Exception\ExceptionInterface; +use Mcp\Exception\ToolCallException; +use Mcp\Exception\ToolNotFoundException; 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\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; use Psr\Log\LoggerInterface; @@ -29,7 +33,8 @@ final class CallToolHandler implements MethodHandlerInterface { public function __construct( - private readonly ToolCallerInterface $toolCaller, + private readonly ReferenceProviderInterface $referenceProvider, + private readonly ReferenceHandlerInterface $referenceHandler, private readonly LoggerInterface $logger = new NullLogger(), ) { } @@ -43,20 +48,44 @@ public function handle(CallToolRequest|HasMethodInterface $message, SessionInter { \assert($message instanceof CallToolRequest); + $toolName = $message->name; + $arguments = $message->arguments ?? []; + + $this->logger->debug('Executing tool', ['name' => $toolName, 'arguments' => $arguments]); + try { - $content = $this->toolCaller->call($message); - } catch (ExceptionInterface $exception) { - $this->logger->error( - \sprintf('Error while executing tool "%s": "%s".', $message->name, $exception->getMessage()), - [ - 'tool' => $message->name, - 'arguments' => $message->arguments, - ], - ); + $reference = $this->referenceProvider->getTool($toolName); + if (null === $reference) { + throw new ToolNotFoundException($message); + } + + $result = $this->referenceHandler->handle($reference, $arguments); + $formatted = $reference->formatResult($result); + + $this->logger->debug('Tool executed successfully', [ + 'name' => $toolName, + 'result_type' => \gettype($result), + ]); + + return new Response($message->getId(), new CallToolResult($formatted)); + } catch (ToolNotFoundException $e) { + $this->logger->error('Tool not found', ['name' => $toolName]); + + return new Error($message->getId(), Error::METHOD_NOT_FOUND, $e->getMessage()); + } catch (ToolCallException|ExceptionInterface $e) { + $this->logger->error(\sprintf('Error while executing tool "%s": "%s".', $toolName, $e->getMessage()), [ + 'tool' => $toolName, + 'arguments' => $arguments, + ]); return Error::forInternalError('Error while executing tool', $message->getId()); - } + } catch (\Throwable $e) { + $this->logger->error('Unhandled error during tool execution', [ + 'name' => $toolName, + 'exception' => $e->getMessage(), + ]); - return new Response($message->getId(), $content); + return Error::forInternalError('Error while executing tool', $message->getId()); + } } } diff --git a/src/Server/Handler/Request/CompletionCompleteHandler.php b/src/Server/Handler/Request/CompletionCompleteHandler.php index ef4b3020..be4d67d1 100644 --- a/src/Server/Handler/Request/CompletionCompleteHandler.php +++ b/src/Server/Handler/Request/CompletionCompleteHandler.php @@ -11,14 +11,16 @@ namespace Mcp\Server\Handler\Request; -use Mcp\Capability\Completion\CompleterInterface; -use Mcp\Exception\ExceptionInterface; +use Mcp\Capability\Completion\ProviderInterface; +use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\CompletionCompleteRequest; +use Mcp\Schema\Result\CompletionCompleteResult; use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; +use Psr\Container\ContainerInterface; /** * Handles completion/complete requests. @@ -28,7 +30,8 @@ final class CompletionCompleteHandler implements MethodHandlerInterface { public function __construct( - private readonly CompleterInterface $completer, + private readonly ReferenceProviderInterface $referenceProvider, + private readonly ?ContainerInterface $container = null, ) { } @@ -41,12 +44,45 @@ public function handle(CompletionCompleteRequest|HasMethodInterface $message, Se { \assert($message instanceof CompletionCompleteRequest); + $name = $message->argument['name'] ?? ''; + $value = $message->argument['value'] ?? ''; + + $reference = match ($message->ref->type) { + 'ref/prompt' => $this->referenceProvider->getPrompt($message->ref->name), + 'ref/resource' => $this->referenceProvider->getResourceTemplate($message->ref->uri), + default => null, + }; + + if (null === $reference) { + return new Response($message->getId(), new CompletionCompleteResult([])); + } + + $providers = $reference->completionProviders; + $provider = $providers[$name] ?? null; + if (null === $provider) { + return new Response($message->getId(), new CompletionCompleteResult([])); + } + + if (\is_string($provider)) { + if (!class_exists($provider)) { + return Error::forInternalError('Invalid completion provider', $message->getId()); + } + $provider = $this->container?->has($provider) ? $this->container->get($provider) : new $provider(); + } + + if (!$provider instanceof ProviderInterface) { + return Error::forInternalError('Invalid completion provider type', $message->getId()); + } + try { - $result = $this->completer->complete($message); - } catch (ExceptionInterface) { + $completions = $provider->getCompletions($value); + $total = \count($completions); + $hasMore = $total > 100; + $paged = \array_slice($completions, 0, 100); + + return new Response($message->getId(), new CompletionCompleteResult($paged, $total, $hasMore)); + } catch (\Throwable) { return Error::forInternalError('Error while handling completion request', $message->getId()); } - - return new Response($message->getId(), $result); } } diff --git a/src/Server/Handler/Request/GetPromptHandler.php b/src/Server/Handler/Request/GetPromptHandler.php index 07a5bebf..758ab9de 100644 --- a/src/Server/Handler/Request/GetPromptHandler.php +++ b/src/Server/Handler/Request/GetPromptHandler.php @@ -11,14 +11,20 @@ namespace Mcp\Server\Handler\Request; -use Mcp\Capability\Prompt\PromptGetterInterface; +use Mcp\Capability\Registry\ReferenceHandlerInterface; +use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Exception\ExceptionInterface; +use Mcp\Exception\PromptGetException; +use Mcp\Exception\PromptNotFoundException; 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\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; /** * @author Tobias Nyholm @@ -26,7 +32,9 @@ final class GetPromptHandler implements MethodHandlerInterface { public function __construct( - private readonly PromptGetterInterface $promptGetter, + private readonly ReferenceProviderInterface $referenceProvider, + private readonly ReferenceHandlerInterface $referenceHandler, + private readonly LoggerInterface $logger = new NullLogger(), ) { } @@ -39,12 +47,32 @@ public function handle(GetPromptRequest|HasMethodInterface $message, SessionInte { \assert($message instanceof GetPromptRequest); + $promptName = $message->name; + $arguments = $message->arguments ?? []; + try { - $messages = $this->promptGetter->get($message); - } catch (ExceptionInterface) { + $reference = $this->referenceProvider->getPrompt($promptName); + if (null === $reference) { + throw new PromptNotFoundException($message); + } + + $result = $this->referenceHandler->handle($reference, $arguments); + + $formatted = $reference->formatResult($result); + + return new Response($message->getId(), new GetPromptResult($formatted)); + } catch (PromptNotFoundException $e) { + $this->logger->error('Prompt not found', ['prompt_name' => $promptName]); + + return new Error($message->getId(), Error::METHOD_NOT_FOUND, $e->getMessage()); + } catch (PromptGetException|ExceptionInterface $e) { + $this->logger->error('Error while handling prompt', ['prompt_name' => $promptName]); + return Error::forInternalError('Error while handling prompt', $message->getId()); - } + } catch (\Throwable $e) { + $this->logger->error('Error while handling prompt', ['prompt_name' => $promptName]); - return new Response($message->getId(), $messages); + return Error::forInternalError('Error while handling prompt', $message->getId()); + } } } diff --git a/src/Server/Handler/Request/ReadResourceHandler.php b/src/Server/Handler/Request/ReadResourceHandler.php index fd208917..6691021a 100644 --- a/src/Server/Handler/Request/ReadResourceHandler.php +++ b/src/Server/Handler/Request/ReadResourceHandler.php @@ -11,15 +11,19 @@ namespace Mcp\Server\Handler\Request; -use Mcp\Capability\Resource\ResourceReaderInterface; -use Mcp\Exception\ExceptionInterface; +use Mcp\Capability\Registry\ReferenceHandlerInterface; +use Mcp\Capability\Registry\ReferenceProviderInterface; +use Mcp\Capability\Registry\ResourceTemplateReference; 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\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; /** * @author Tobias Nyholm @@ -27,7 +31,9 @@ final class ReadResourceHandler implements MethodHandlerInterface { public function __construct( - private readonly ResourceReaderInterface $resourceReader, + private readonly ReferenceProviderInterface $referenceProvider, + private readonly ReferenceHandlerInterface $referenceHandler, + private readonly LoggerInterface $logger = new NullLogger(), ) { } @@ -40,14 +46,33 @@ public function handle(ReadResourceRequest|HasMethodInterface $message, SessionI { \assert($message instanceof ReadResourceRequest); + $uri = $message->uri; + + $this->logger->debug('Reading resource', ['uri' => $uri]); + try { - $contents = $this->resourceReader->read($message); + $reference = $this->referenceProvider->getResource($uri); + if (null === $reference) { + throw new ResourceNotFoundException($message); + } + + $result = $this->referenceHandler->handle($reference, ['uri' => $uri]); + + if ($reference instanceof ResourceTemplateReference) { + $formatted = $reference->formatResult($result, $uri, $reference->resourceTemplate->mimeType); + } else { + $formatted = $reference->formatResult($result, $uri, $reference->schema->mimeType); + } + + return new Response($message->getId(), new ReadResourceResult($formatted)); } catch (ResourceNotFoundException $e) { + $this->logger->error('Resource not found', ['uri' => $uri]); + return new Error($message->getId(), Error::RESOURCE_NOT_FOUND, $e->getMessage()); - } catch (ExceptionInterface) { + } catch (\Throwable $e) { + $this->logger->error(\sprintf('Error while reading resource "%s": "%s".', $uri, $e->getMessage())); + return Error::forInternalError('Error while reading resource', $message->getId()); } - - return new Response($message->getId(), $contents); } } diff --git a/tests/Unit/Capability/Prompt/Completion/EnumCompletionProviderTest.php b/tests/Unit/Capability/Prompt/Completion/EnumCompletionProviderTest.php deleted file mode 100644 index 6321fdc6..00000000 --- a/tests/Unit/Capability/Prompt/Completion/EnumCompletionProviderTest.php +++ /dev/null @@ -1,85 +0,0 @@ -getCompletions(''); - $this->assertSame(['draft', 'published', 'archived'], $result); - } - - public function testCreatesProviderFromIntBackedEnumUsingNames() - { - $provider = new EnumCompletionProvider(PriorityEnum::class); - $result = $provider->getCompletions(''); - - $this->assertSame(['LOW', 'MEDIUM', 'HIGH'], $result); - } - - public function testCreatesProviderFromUnitEnumUsingNames() - { - $provider = new EnumCompletionProvider(UnitEnum::class); - $result = $provider->getCompletions(''); - - $this->assertSame(['Yes', 'No'], $result); - } - - public function testFiltersStringEnumValuesByPrefix() - { - $provider = new EnumCompletionProvider(StatusEnum::class); - $result = $provider->getCompletions('ar'); - - $this->assertEquals(['archived'], $result); - } - - public function testFiltersUnitEnumValuesByPrefix() - { - $provider = new EnumCompletionProvider(UnitEnum::class); - $result = $provider->getCompletions('Y'); - - $this->assertSame(['Yes'], $result); - } - - public function testReturnsEmptyArrayWhenNoValuesMatchPrefix() - { - $provider = new EnumCompletionProvider(StatusEnum::class); - $result = $provider->getCompletions('xyz'); - - $this->assertSame([], $result); - } - - public function testThrowsExceptionForNonEnumClass() - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Class "stdClass" is not an enum.'); - - new EnumCompletionProvider(\stdClass::class); - } - - public function testThrowsExceptionForNonExistentClass() - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Class "NonExistentClass" is not an enum.'); - - new EnumCompletionProvider('NonExistentClass'); /* @phpstan-ignore argument.type */ - } -} diff --git a/tests/Unit/Capability/Prompt/Completion/ListCompletionProviderTest.php b/tests/Unit/Capability/Prompt/Completion/ListCompletionProviderTest.php deleted file mode 100644 index dd3439d8..00000000 --- a/tests/Unit/Capability/Prompt/Completion/ListCompletionProviderTest.php +++ /dev/null @@ -1,80 +0,0 @@ -getCompletions(''); - - $this->assertSame($values, $result); - } - - public function testFiltersValuesBasedOnCurrentValuePrefix() - { - $values = ['apple', 'apricot', 'banana', 'cherry']; - $provider = new ListCompletionProvider($values); - $result = $provider->getCompletions('ap'); - - $this->assertSame(['apple', 'apricot'], $result); - } - - public function testReturnsEmptyArrayWhenNoValuesMatch() - { - $values = ['apple', 'banana', 'cherry']; - $provider = new ListCompletionProvider($values); - $result = $provider->getCompletions('xyz'); - - $this->assertSame([], $result); - } - - public function testWorksWithSingleCharacterPrefix() - { - $values = ['apple', 'banana', 'cherry']; - $provider = new ListCompletionProvider($values); - $result = $provider->getCompletions('a'); - - $this->assertSame(['apple'], $result); - } - - public function testIsCaseSensitiveByDefault() - { - $values = ['Apple', 'apple', 'APPLE']; - $provider = new ListCompletionProvider($values); - $result = $provider->getCompletions('A'); - - $this->assertEquals(['Apple', 'APPLE'], $result); - } - - public function testHandlesEmptyValuesArray() - { - $provider = new ListCompletionProvider([]); - $result = $provider->getCompletions('test'); - - $this->assertSame([], $result); - } - - public function testPreservesArrayOrder() - { - $values = ['zebra', 'apple', 'banana']; - $provider = new ListCompletionProvider($values); - $result = $provider->getCompletions(''); - - $this->assertSame(['zebra', 'apple', 'banana'], $result); - } -} diff --git a/tests/Unit/Capability/Prompt/PromptGetterTest.php b/tests/Unit/Capability/Prompt/PromptGetterTest.php deleted file mode 100644 index 874dbe3c..00000000 --- a/tests/Unit/Capability/Prompt/PromptGetterTest.php +++ /dev/null @@ -1,638 +0,0 @@ -referenceProvider = $this->createMock(ReferenceProviderInterface::class); - $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); - - $this->promptGetter = new PromptGetter( - $this->referenceProvider, - $this->referenceHandler, - ); - } - - public function testGetExecutesPromptSuccessfully(): void - { - $request = new GetPromptRequest('test_prompt', ['param' => 'value']); - $prompt = $this->createValidPrompt('test_prompt'); - $promptReference = new PromptReference($prompt, fn () => 'test result'); - $handlerResult = [ - 'role' => 'user', - 'content' => 'Generated prompt content', - ]; - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('test_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, ['param' => 'value']) - ->willReturn($handlerResult); - - $result = $this->promptGetter->get($request); - - $this->assertInstanceOf(GetPromptResult::class, $result); - $this->assertCount(1, $result->messages); - $this->assertInstanceOf(PromptMessage::class, $result->messages[0]); - $this->assertEquals(Role::User, $result->messages[0]->role); - $this->assertInstanceOf(TextContent::class, $result->messages[0]->content); - $this->assertEquals('Generated prompt content', $result->messages[0]->content->text); - } - - public function testGetWithEmptyArguments(): void - { - $request = new GetPromptRequest('empty_args_prompt', []); - $prompt = $this->createValidPrompt('empty_args_prompt'); - $promptReference = new PromptReference($prompt, fn () => 'Empty args content'); - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('empty_args_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, []) - ->willReturn([ - 'role' => 'user', - 'content' => 'Empty args content', - ]); - - $result = $this->promptGetter->get($request); - - $this->assertInstanceOf(GetPromptResult::class, $result); - $this->assertCount(1, $result->messages); - } - - public function testGetWithComplexArguments(): void - { - $arguments = [ - 'string_param' => 'value', - 'int_param' => 42, - 'bool_param' => true, - 'array_param' => ['nested' => 'data'], - 'null_param' => null, - ]; - $request = new GetPromptRequest('complex_prompt', $arguments); - $prompt = $this->createValidPrompt('complex_prompt'); - $promptReference = new PromptReference($prompt, fn () => 'Complex content'); - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('complex_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, $arguments) - ->willReturn([ - 'role' => 'assistant', - 'content' => 'Complex response', - ]); - - $result = $this->promptGetter->get($request); - - $this->assertInstanceOf(GetPromptResult::class, $result); - $this->assertCount(1, $result->messages); - } - - public function testGetThrowsInvalidArgumentExceptionWhenPromptNotFound(): void - { - $request = new GetPromptRequest('nonexistent_prompt', ['param' => 'value']); - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('nonexistent_prompt') - ->willReturn(null); - - $this->referenceHandler - ->expects($this->never()) - ->method('handle'); - - $this->expectException(PromptNotFoundException::class); - $this->expectExceptionMessage('Prompt not found for name: "nonexistent_prompt".'); - - $this->promptGetter->get($request); - } - - public function testGetThrowsRegistryExceptionWhenHandlerFails(): void - { - $request = new GetPromptRequest('failing_prompt', ['param' => 'value']); - $prompt = $this->createValidPrompt('failing_prompt'); - $promptReference = new PromptReference($prompt, fn () => throw new \RuntimeException('Handler failed')); - $handlerException = RegistryException::internalError('Handler failed'); - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('failing_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, ['param' => 'value']) - ->willThrowException($handlerException); - - $this->expectException(PromptGetException::class); - - $this->promptGetter->get($request); - } - - public function testGetHandlesJsonExceptionDuringFormatting(): void - { - $request = new GetPromptRequest('json_error_prompt', []); - $prompt = $this->createValidPrompt('json_error_prompt'); - - // Create a mock PromptReference that will throw JsonException during formatResult - $promptReference = $this->createMock(PromptReference::class); - $promptReference->expects($this->once()) - ->method('formatResult') - ->willThrowException(new \JsonException('JSON encoding failed')); - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('json_error_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, []) - ->willReturn('some result'); - - $this->expectException(PromptGetException::class); - $this->expectExceptionMessage('JSON encoding failed'); - - $this->promptGetter->get($request); - } - - public function testGetHandlesArrayOfMessages(): void - { - $request = new GetPromptRequest('multi_message_prompt', ['context' => 'test']); - $prompt = $this->createValidPrompt('multi_message_prompt'); - $promptReference = new PromptReference($prompt, fn () => 'Multiple messages'); - $handlerResult = [ - [ - 'role' => 'user', - 'content' => 'First message', - ], - [ - 'role' => 'assistant', - 'content' => 'Second message', - ], - ]; - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('multi_message_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, ['context' => 'test']) - ->willReturn($handlerResult); - - $result = $this->promptGetter->get($request); - - $this->assertInstanceOf(GetPromptResult::class, $result); - $this->assertCount(2, $result->messages); - $this->assertEquals(Role::User, $result->messages[0]->role); - $this->assertEquals('First message', $result->messages[0]->content->text); - $this->assertEquals(Role::Assistant, $result->messages[1]->role); - $this->assertEquals('Second message', $result->messages[1]->content->text); - } - - public function testGetHandlesPromptMessageObjects(): void - { - $request = new GetPromptRequest('prompt_message_prompt', []); - $prompt = $this->createValidPrompt('prompt_message_prompt'); - $promptMessage = new PromptMessage( - Role::User, - new TextContent('Direct prompt message') - ); - $promptReference = new PromptReference($prompt, fn () => $promptMessage); - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('prompt_message_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, []) - ->willReturn($promptMessage); - - $result = $this->promptGetter->get($request); - - $this->assertInstanceOf(GetPromptResult::class, $result); - $this->assertCount(1, $result->messages); - $this->assertSame($promptMessage, $result->messages[0]); - } - - public function testGetHandlesUserAssistantStructure(): void - { - $request = new GetPromptRequest('user_assistant_prompt', []); - $prompt = $this->createValidPrompt('user_assistant_prompt'); - $promptReference = new PromptReference($prompt, fn () => 'Conversation content'); - $handlerResult = [ - 'user' => 'What is the weather?', - 'assistant' => 'I can help you check the weather.', - ]; - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('user_assistant_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, []) - ->willReturn($handlerResult); - - $result = $this->promptGetter->get($request); - - $this->assertInstanceOf(GetPromptResult::class, $result); - $this->assertCount(2, $result->messages); - $this->assertEquals(Role::User, $result->messages[0]->role); - $this->assertEquals('What is the weather?', $result->messages[0]->content->text); - $this->assertEquals(Role::Assistant, $result->messages[1]->role); - $this->assertEquals('I can help you check the weather.', $result->messages[1]->content->text); - } - - public function testGetHandlesEmptyArrayResult(): void - { - $request = new GetPromptRequest('empty_array_prompt', []); - $prompt = $this->createValidPrompt('empty_array_prompt'); - $promptReference = new PromptReference($prompt, fn () => []); - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('empty_array_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, []) - ->willReturn([]); - - $result = $this->promptGetter->get($request); - - $this->assertInstanceOf(GetPromptResult::class, $result); - $this->assertCount(0, $result->messages); - } - - public function testGetWithTypedContentStructure(): void - { - $request = new GetPromptRequest('typed_content_prompt', []); - $prompt = $this->createValidPrompt('typed_content_prompt'); - $promptReference = new PromptReference($prompt, fn () => 'Typed content'); - $handlerResult = [ - 'role' => 'user', - 'content' => [ - 'type' => 'text', - 'text' => 'Typed text content', - ], - ]; - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('typed_content_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, []) - ->willReturn($handlerResult); - - $result = $this->promptGetter->get($request); - - $this->assertInstanceOf(GetPromptResult::class, $result); - $this->assertCount(1, $result->messages); - $this->assertEquals(Role::User, $result->messages[0]->role); - $this->assertEquals('Typed text content', $result->messages[0]->content->text); - } - - public function testGetWithPromptReferenceHavingCompletionProviders(): void - { - $request = new GetPromptRequest('completion_prompt', ['param' => 'value']); - $prompt = $this->createValidPrompt('completion_prompt'); - $completionProviders = ['param' => EnumCompletionProvider::class]; - $promptReference = new PromptReference( - $prompt, - fn () => 'Completion content', - false, - $completionProviders - ); - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('completion_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, ['param' => 'value']) - ->willReturn([ - 'role' => 'user', - 'content' => 'Completion content', - ]); - - $result = $this->promptGetter->get($request); - - $this->assertInstanceOf(GetPromptResult::class, $result); - $this->assertCount(1, $result->messages); - } - - public function testGetHandlesMixedMessageArray(): void - { - $request = new GetPromptRequest('mixed_prompt', []); - $prompt = $this->createValidPrompt('mixed_prompt'); - $promptMessage = new PromptMessage(Role::Assistant, new TextContent('Direct message')); - $promptReference = new PromptReference($prompt, fn () => 'Mixed content'); - $handlerResult = [ - $promptMessage, - [ - 'role' => 'user', - 'content' => 'Regular message', - ], - ]; - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('mixed_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, []) - ->willReturn($handlerResult); - - $result = $this->promptGetter->get($request); - - $this->assertInstanceOf(GetPromptResult::class, $result); - $this->assertCount(2, $result->messages); - $this->assertSame($promptMessage, $result->messages[0]); - $this->assertEquals(Role::User, $result->messages[1]->role); - $this->assertEquals('Regular message', $result->messages[1]->content->text); - } - - public function testGetReflectsFormattedMessagesCorrectly(): void - { - $request = new GetPromptRequest('format_test_prompt', []); - $prompt = $this->createValidPrompt('format_test_prompt'); - $promptReference = new PromptReference($prompt, fn () => 'Format test'); - - // Test that the formatted result from PromptReference.formatResult is properly returned - $handlerResult = [ - 'role' => 'user', - 'content' => 'Test formatting', - ]; - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('format_test_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, []) - ->willReturn($handlerResult); - - $result = $this->promptGetter->get($request); - - $this->assertInstanceOf(GetPromptResult::class, $result); - $this->assertCount(1, $result->messages); - $this->assertEquals('Test formatting', $result->messages[0]->content->text); - $this->assertEquals(Role::User, $result->messages[0]->role); - } - - /** - * Test that invalid handler results throw RuntimeException from PromptReference.formatResult(). - */ - public function testGetThrowsRuntimeExceptionForInvalidHandlerResult(): void - { - $request = new GetPromptRequest('invalid_prompt', []); - $prompt = $this->createValidPrompt('invalid_prompt'); - $promptReference = new PromptReference($prompt, fn () => 'Invalid content'); - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('invalid_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, []) - ->willReturn('This is not a valid prompt format'); - - $this->expectException(PromptGetException::class); - $this->expectExceptionMessage('Prompt generator method must return an array of messages.'); - - $this->promptGetter->get($request); - } - - /** - * Test that null result from handler throws RuntimeException. - */ - public function testGetThrowsRuntimeExceptionForNullHandlerResult(): void - { - $request = new GetPromptRequest('null_prompt', []); - $prompt = $this->createValidPrompt('null_prompt'); - $promptReference = new PromptReference($prompt, fn () => null); - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('null_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, []) - ->willReturn(null); - - $this->expectException(PromptGetException::class); - $this->expectExceptionMessage('Prompt generator method must return an array of messages.'); - - $this->promptGetter->get($request); - } - - /** - * Test that scalar result from handler throws RuntimeException. - */ - public function testGetThrowsRuntimeExceptionForScalarHandlerResult(): void - { - $request = new GetPromptRequest('scalar_prompt', []); - $prompt = $this->createValidPrompt('scalar_prompt'); - $promptReference = new PromptReference($prompt, fn () => 42); - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('scalar_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, []) - ->willReturn(42); - - $this->expectException(PromptGetException::class); - $this->expectExceptionMessage('Prompt generator method must return an array of messages.'); - - $this->promptGetter->get($request); - } - - /** - * Test that boolean result from handler throws RuntimeException. - */ - public function testGetThrowsRuntimeExceptionForBooleanHandlerResult(): void - { - $request = new GetPromptRequest('boolean_prompt', []); - $prompt = $this->createValidPrompt('boolean_prompt'); - $promptReference = new PromptReference($prompt, fn () => true); - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('boolean_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, []) - ->willReturn(true); - - $this->expectException(PromptGetException::class); - $this->expectExceptionMessage('Prompt generator method must return an array of messages.'); - - $this->promptGetter->get($request); - } - - /** - * Test that object result from handler throws RuntimeException. - */ - public function testGetThrowsRuntimeExceptionForObjectHandlerResult(): void - { - $request = new GetPromptRequest('object_prompt', []); - $prompt = $this->createValidPrompt('object_prompt'); - $objectResult = new \stdClass(); - $objectResult->property = 'value'; - $promptReference = new PromptReference($prompt, fn () => $objectResult); - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('object_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, []) - ->willReturn($objectResult); - - $this->expectException(PromptGetException::class); - $this->expectExceptionMessage('Prompt generator method must return an array of messages.'); - - $this->promptGetter->get($request); - } - - public function testConstructorWithDefaultLogger(): void - { - $promptGetter = new PromptGetter( - $this->referenceProvider, - $this->referenceHandler, - ); - - $this->assertInstanceOf(PromptGetter::class, $promptGetter); - } - - public function testConstructorWithCustomLogger(): void - { - $logger = $this->createMock(LoggerInterface::class); - $promptGetter = new PromptGetter( - $this->referenceProvider, - $this->referenceHandler, - $logger, - ); - - $this->assertInstanceOf(PromptGetter::class, $promptGetter); - } - - private function createValidPrompt(string $name): Prompt - { - return new Prompt( - name: $name, - description: "Test prompt: {$name}", - arguments: null, - ); - } -} diff --git a/tests/Unit/Capability/Resource/ResourceReaderTest.php b/tests/Unit/Capability/Resource/ResourceReaderTest.php deleted file mode 100644 index 73c967ff..00000000 --- a/tests/Unit/Capability/Resource/ResourceReaderTest.php +++ /dev/null @@ -1,522 +0,0 @@ -referenceProvider = $this->createMock(ReferenceProviderInterface::class); - $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); - - $this->resourceReader = new ResourceReader( - $this->referenceProvider, - $this->referenceHandler, - ); - } - - public function testReadResourceSuccessfullyWithStringResult(): void - { - $request = new ReadResourceRequest('file://test.txt'); - $resource = $this->createValidResource('file://test.txt', 'test', 'text/plain'); - $resourceReference = new ResourceReference($resource, fn () => 'test content'); - $handlerResult = 'test content'; - - $this->referenceProvider - ->expects($this->once()) - ->method('getResource') - ->with('file://test.txt') - ->willReturn($resourceReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($resourceReference, ['uri' => 'file://test.txt']) - ->willReturn($handlerResult); - - $result = $this->resourceReader->read($request); - - $this->assertInstanceOf(ReadResourceResult::class, $result); - $this->assertCount(1, $result->contents); - $this->assertInstanceOf(TextResourceContents::class, $result->contents[0]); - $this->assertEquals('test content', $result->contents[0]->text); - $this->assertEquals('file://test.txt', $result->contents[0]->uri); - $this->assertEquals('text/plain', $result->contents[0]->mimeType); - } - - public function testReadResourceSuccessfullyWithArrayResult(): void - { - $request = new ReadResourceRequest('api://data'); - $resource = $this->createValidResource('api://data', 'data', 'application/json'); - $resourceReference = new ResourceReference($resource, fn () => ['key' => 'value', 'count' => 42]); - $handlerResult = ['key' => 'value', 'count' => 42]; - - $this->referenceProvider - ->expects($this->once()) - ->method('getResource') - ->with('api://data') - ->willReturn($resourceReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($resourceReference, ['uri' => 'api://data']) - ->willReturn($handlerResult); - - $result = $this->resourceReader->read($request); - - $this->assertInstanceOf(ReadResourceResult::class, $result); - $this->assertCount(1, $result->contents); - $this->assertInstanceOf(TextResourceContents::class, $result->contents[0]); - $this->assertJsonStringEqualsJsonString( - json_encode($handlerResult, \JSON_PRETTY_PRINT), - $result->contents[0]->text, - ); - $this->assertEquals('api://data', $result->contents[0]->uri); - $this->assertEquals('application/json', $result->contents[0]->mimeType); - } - - public function testReadResourceSuccessfullyWithBlobResult(): void - { - $request = new ReadResourceRequest('file://image.png'); - $resource = $this->createValidResource('file://image.png', 'image', 'image/png'); - - $handlerResult = [ - 'blob' => base64_encode('binary data'), - 'mimeType' => 'image/png', - ]; - - $resourceReference = new ResourceReference($resource, fn () => $handlerResult); - - $this->referenceProvider - ->expects($this->once()) - ->method('getResource') - ->with('file://image.png') - ->willReturn($resourceReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($resourceReference, ['uri' => 'file://image.png']) - ->willReturn($handlerResult); - - $result = $this->resourceReader->read($request); - - $this->assertInstanceOf(ReadResourceResult::class, $result); - $this->assertCount(1, $result->contents); - $this->assertInstanceOf(BlobResourceContents::class, $result->contents[0]); - $this->assertEquals(base64_encode('binary data'), $result->contents[0]->blob); - $this->assertEquals('file://image.png', $result->contents[0]->uri); - $this->assertEquals('image/png', $result->contents[0]->mimeType); - } - - public function testReadResourceSuccessfullyWithResourceContentResult(): void - { - $request = new ReadResourceRequest('custom://resource'); - $resource = $this->createValidResource('custom://resource', 'resource', 'text/plain'); - $textContent = new TextResourceContents('custom://resource', 'text/plain', 'direct content'); - $resourceReference = new ResourceReference($resource, fn () => $textContent); - - $this->referenceProvider - ->expects($this->once()) - ->method('getResource') - ->with('custom://resource') - ->willReturn($resourceReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($resourceReference, ['uri' => 'custom://resource']) - ->willReturn($textContent); - - $result = $this->resourceReader->read($request); - - $this->assertInstanceOf(ReadResourceResult::class, $result); - $this->assertCount(1, $result->contents); - $this->assertSame($textContent, $result->contents[0]); - } - - public function testReadResourceSuccessfullyWithMultipleContentResults(): void - { - $request = new ReadResourceRequest('multi://content'); - $resource = $this->createValidResource('multi://content', 'content', 'application/json'); - $content1 = new TextResourceContents('multi://content', 'text/plain', 'first content'); - $content2 = new TextResourceContents('multi://content', 'text/plain', 'second content'); - $handlerResult = [$content1, $content2]; - $resourceReference = new ResourceReference($resource, fn () => $handlerResult); - - $this->referenceProvider - ->expects($this->once()) - ->method('getResource') - ->with('multi://content') - ->willReturn($resourceReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($resourceReference, ['uri' => 'multi://content']) - ->willReturn($handlerResult); - - $result = $this->resourceReader->read($request); - - $this->assertInstanceOf(ReadResourceResult::class, $result); - $this->assertCount(2, $result->contents); - $this->assertSame($content1, $result->contents[0]); - $this->assertSame($content2, $result->contents[1]); - } - - public function testReadResourceTemplate(): void - { - $request = new ReadResourceRequest('users://123'); - $resourceTemplate = $this->createValidResourceTemplate('users://{id}', 'user_template'); - $templateReference = new ResourceTemplateReference( - $resourceTemplate, - fn () => ['id' => 123, 'name' => 'Test User'], - ); - - $this->referenceProvider - ->expects($this->once()) - ->method('getResource') - ->with('users://123') - ->willReturn($templateReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($templateReference, ['uri' => 'users://123']) - ->willReturn(['id' => 123, 'name' => 'Test User']); - - $result = $this->resourceReader->read($request); - - $this->assertInstanceOf(ReadResourceResult::class, $result); - $this->assertCount(1, $result->contents); - $this->assertInstanceOf(TextResourceContents::class, $result->contents[0]); - $this->assertJsonStringEqualsJsonString( - json_encode(['id' => 123, 'name' => 'Test User'], \JSON_PRETTY_PRINT), - $result->contents[0]->text, - ); - } - - public function testReadResourceThrowsExceptionWhenResourceNotFound(): void - { - $request = new ReadResourceRequest('nonexistent://resource'); - - $this->referenceProvider - ->expects($this->once()) - ->method('getResource') - ->with('nonexistent://resource') - ->willReturn(null); - - $this->referenceHandler - ->expects($this->never()) - ->method('handle'); - - $this->expectException(ResourceNotFoundException::class); - $this->expectExceptionMessage('Resource not found for uri: "nonexistent://resource".'); - - $this->resourceReader->read($request); - } - - public function testReadResourceThrowsRegistryExceptionWhenHandlerFails(): void - { - $request = new ReadResourceRequest('failing://resource'); - $resource = $this->createValidResource('failing://resource', 'failing', 'text/plain'); - $resourceReference = new ResourceReference($resource, fn () => throw new \RuntimeException('Handler failed')); - $handlerException = new RegistryException('Handler execution failed'); - - $this->referenceProvider - ->expects($this->once()) - ->method('getResource') - ->with('failing://resource') - ->willReturn($resourceReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($resourceReference, ['uri' => 'failing://resource']) - ->willThrowException($handlerException); - - $this->expectException(ResourceReadException::class); - $this->expectExceptionMessage('Handler execution failed'); - - $this->resourceReader->read($request); - } - - public function testReadResourcePassesCorrectArgumentsToHandler(): void - { - $request = new ReadResourceRequest('test://resource'); - $resource = $this->createValidResource('test://resource', 'test', 'text/plain'); - $resourceReference = new ResourceReference($resource, fn () => 'test'); - - $this->referenceProvider - ->expects($this->once()) - ->method('getResource') - ->with('test://resource') - ->willReturn($resourceReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with( - $this->identicalTo($resourceReference), - $this->equalTo(['uri' => 'test://resource']), - ) - ->willReturn('test'); - - $result = $this->resourceReader->read($request); - - $this->assertInstanceOf(ReadResourceResult::class, $result); - } - - public function testReadResourceWithEmptyStringResult(): void - { - $request = new ReadResourceRequest('empty://resource'); - $resource = $this->createValidResource('empty://resource', 'empty', 'text/plain'); - $resourceReference = new ResourceReference($resource, fn () => ''); - - $this->referenceProvider - ->expects($this->once()) - ->method('getResource') - ->with('empty://resource') - ->willReturn($resourceReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($resourceReference, ['uri' => 'empty://resource']) - ->willReturn(''); - - $result = $this->resourceReader->read($request); - - $this->assertInstanceOf(ReadResourceResult::class, $result); - $this->assertCount(1, $result->contents); - $this->assertInstanceOf(TextResourceContents::class, $result->contents[0]); - $this->assertEquals('', $result->contents[0]->text); - } - - public function testReadResourceWithEmptyArrayResult(): void - { - $request = new ReadResourceRequest('empty://array'); - $resource = $this->createValidResource('empty://array', 'array', 'application/json'); - $resourceReference = new ResourceReference($resource, fn () => []); - - $this->referenceProvider - ->expects($this->once()) - ->method('getResource') - ->with('empty://array') - ->willReturn($resourceReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($resourceReference, ['uri' => 'empty://array']) - ->willReturn([]); - - $result = $this->resourceReader->read($request); - - $this->assertInstanceOf(ReadResourceResult::class, $result); - $this->assertCount(1, $result->contents); - $this->assertInstanceOf(TextResourceContents::class, $result->contents[0]); - $this->assertEquals('[]', $result->contents[0]->text); - $this->assertEquals('application/json', $result->contents[0]->mimeType); - } - - public function testReadResourceWithNullResult(): void - { - $request = new ReadResourceRequest('null://resource'); - $resource = $this->createValidResource('null://resource', 'null', 'text/plain'); - $resourceReference = new ResourceReference($resource, fn () => null); - - $this->referenceProvider - ->expects($this->once()) - ->method('getResource') - ->with('null://resource') - ->willReturn($resourceReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($resourceReference, ['uri' => 'null://resource']) - ->willReturn(null); - - // The formatResult method in ResourceReference should handle null values - $this->expectException(\RuntimeException::class); - - $this->resourceReader->read($request); - } - - public function testReadResourceWithDifferentMimeTypes(): void - { - $request = new ReadResourceRequest('xml://data'); - $resource = $this->createValidResource('xml://data', 'data', 'application/xml'); - $resourceReference = new ResourceReference($resource, fn () => 'value'); - - $this->referenceProvider - ->expects($this->once()) - ->method('getResource') - ->with('xml://data') - ->willReturn($resourceReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($resourceReference, ['uri' => 'xml://data']) - ->willReturn('value'); - - $result = $this->resourceReader->read($request); - - $this->assertInstanceOf(ReadResourceResult::class, $result); - $this->assertCount(1, $result->contents); - $this->assertInstanceOf(TextResourceContents::class, $result->contents[0]); - // The MIME type should be guessed from content since formatResult handles the conversion - $this->assertEquals('value', $result->contents[0]->text); - } - - public function testReadResourceWithJsonMimeTypeAndArrayResult(): void - { - $request = new ReadResourceRequest('api://json'); - $resource = $this->createValidResource('api://json', 'json', 'application/json'); - $resourceReference = new ResourceReference($resource, fn () => ['formatted' => true, 'data' => [1, 2, 3]]); - - $this->referenceProvider - ->expects($this->once()) - ->method('getResource') - ->with('api://json') - ->willReturn($resourceReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($resourceReference, ['uri' => 'api://json']) - ->willReturn(['formatted' => true, 'data' => [1, 2, 3]]); - - $result = $this->resourceReader->read($request); - - $this->assertInstanceOf(ReadResourceResult::class, $result); - $this->assertCount(1, $result->contents); - $this->assertInstanceOf(TextResourceContents::class, $result->contents[0]); - $this->assertEquals('application/json', $result->contents[0]->mimeType); - $this->assertJsonStringEqualsJsonString( - json_encode(['formatted' => true, 'data' => [1, 2, 3]], \JSON_PRETTY_PRINT), - $result->contents[0]->text, - ); - } - - public function testReadResourceCallsFormatResultOnReference(): void - { - $request = new ReadResourceRequest('format://test'); - $resource = $this->createValidResource('format://test', 'format', 'text/plain'); - - // Create a mock ResourceReference to verify formatResult is called - $resourceReference = $this - ->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([$resource, fn () => 'test', false]) - ->onlyMethods(['formatResult']) - ->getMock(); - - $handlerResult = 'test result'; - $formattedResult = [new TextResourceContents('format://test', 'text/plain', 'formatted content')]; - - $this->referenceProvider - ->expects($this->once()) - ->method('getResource') - ->with('format://test') - ->willReturn($resourceReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($resourceReference, ['uri' => 'format://test']) - ->willReturn($handlerResult); - - $resourceReference - ->expects($this->once()) - ->method('formatResult') - ->with($handlerResult, 'format://test') - ->willReturn($formattedResult); - - $result = $this->resourceReader->read($request); - - $this->assertInstanceOf(ReadResourceResult::class, $result); - $this->assertSame($formattedResult, $result->contents); - } - - public function testConstructorWithDefaultLogger(): void - { - $resourceReader = new ResourceReader( - $this->referenceProvider, - $this->referenceHandler, - ); - - $this->assertInstanceOf(ResourceReader::class, $resourceReader); - } - - public function testConstructorWithCustomLogger(): void - { - $logger = $this->createMock(LoggerInterface::class); - $resourceReader = new ResourceReader( - $this->referenceProvider, - $this->referenceHandler, - $logger, - ); - - $this->assertInstanceOf(ResourceReader::class, $resourceReader); - } - - private function createValidResource(string $uri, string $name, ?string $mimeType = null): Resource - { - return new Resource( - uri: $uri, - name: $name, - description: "Test resource: {$name}", - mimeType: $mimeType, - size: null, - annotations: null, - ); - } - - private function createValidResourceTemplate( - string $uriTemplate, - string $name, - ?string $mimeType = null, - ): ResourceTemplate { - return new ResourceTemplate( - uriTemplate: $uriTemplate, - name: $name, - description: "Test resource template: {$name}", - mimeType: $mimeType, - annotations: null, - ); - } -} diff --git a/tests/Unit/Capability/Tool/ToolCallerTest.php b/tests/Unit/Capability/Tool/ToolCallerTest.php deleted file mode 100644 index 889c2ec4..00000000 --- a/tests/Unit/Capability/Tool/ToolCallerTest.php +++ /dev/null @@ -1,628 +0,0 @@ -referenceProvider = $this->createMock(ReferenceProviderInterface::class); - $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); - $this->logger = $this->createMock(LoggerInterface::class); - - $this->toolCaller = new ToolCaller( - $this->referenceProvider, - $this->referenceHandler, - $this->logger, - ); - } - - public function testCallExecutesToolSuccessfully(): void - { - $request = new CallToolRequest('test_tool', ['param' => 'value']); - $tool = $this->createValidTool('test_tool'); - $toolReference = new ToolReference($tool, fn () => 'test result'); - $handlerResult = 'test result'; - - $this->referenceProvider - ->expects($this->once()) - ->method('getTool') - ->with('test_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, ['param' => 'value']) - ->willReturn($handlerResult); - - $this->logger - ->expects($this->exactly(2)) - ->method('debug') - ->with( - $this->logicalOr( - $this->equalTo('Executing tool'), - $this->equalTo('Tool executed successfully') - ), - $this->logicalOr( - $this->equalTo(['name' => 'test_tool', 'arguments' => ['param' => 'value']]), - $this->equalTo(['name' => 'test_tool', 'result_type' => 'string']) - ) - ); - - $result = $this->toolCaller->call($request); - - $this->assertInstanceOf(CallToolResult::class, $result); - $this->assertCount(1, $result->content); - $this->assertInstanceOf(TextContent::class, $result->content[0]); - $this->assertEquals('test result', $result->content[0]->text); - $this->assertFalse($result->isError); - } - - public function testCallWithEmptyArguments(): void - { - $request = new CallToolRequest('test_tool', []); - $tool = $this->createValidTool('test_tool'); - $toolReference = new ToolReference($tool, fn () => 'result'); - - $this->referenceProvider - ->expects($this->once()) - ->method('getTool') - ->with('test_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, []) - ->willReturn('result'); - - $this->logger - ->expects($this->exactly(2)) - ->method('debug'); - - $result = $this->toolCaller->call($request); - - $this->assertInstanceOf(CallToolResult::class, $result); - } - - public function testCallWithComplexArguments(): void - { - $arguments = [ - 'string_param' => 'value', - 'int_param' => 42, - 'bool_param' => true, - 'array_param' => ['nested' => 'data'], - 'null_param' => null, - ]; - $request = new CallToolRequest('complex_tool', $arguments); - $tool = $this->createValidTool('complex_tool'); - $toolReference = new ToolReference($tool, fn () => ['processed' => true]); - - $this->referenceProvider - ->expects($this->once()) - ->method('getTool') - ->with('complex_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, $arguments) - ->willReturn(['processed' => true]); - - $result = $this->toolCaller->call($request); - - $this->assertInstanceOf(CallToolResult::class, $result); - $this->assertCount(1, $result->content); - } - - public function testCallThrowsToolNotFoundExceptionWhenToolNotFound(): void - { - $request = new CallToolRequest('nonexistent_tool', ['param' => 'value']); - - $this->referenceProvider - ->expects($this->once()) - ->method('getTool') - ->with('nonexistent_tool') - ->willReturn(null); - - $this->referenceHandler - ->expects($this->never()) - ->method('handle'); - - $this->logger - ->expects($this->once()) - ->method('debug') - ->with('Executing tool', ['name' => 'nonexistent_tool', 'arguments' => ['param' => 'value']]); - - $this->logger - ->expects($this->once()) - ->method('warning') - ->with('Tool not found', ['name' => 'nonexistent_tool']); - - $this->expectException(ToolNotFoundException::class); - $this->expectExceptionMessage('Tool not found for call: "nonexistent_tool".'); - - $this->toolCaller->call($request); - } - - public function testCallThrowsToolExecutionExceptionWhenHandlerThrowsException(): void - { - $request = new CallToolRequest('failing_tool', ['param' => 'value']); - $tool = $this->createValidTool('failing_tool'); - $toolReference = new ToolReference($tool, fn () => throw new \RuntimeException('Handler failed')); - $handlerException = new \RuntimeException('Handler failed'); - - $this->referenceProvider - ->expects($this->once()) - ->method('getTool') - ->with('failing_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, ['param' => 'value']) - ->willThrowException($handlerException); - - $this->logger - ->expects($this->once()) - ->method('debug') - ->with('Executing tool', ['name' => 'failing_tool', 'arguments' => ['param' => 'value']]); - - $this->logger - ->expects($this->once()) - ->method('error') - ->with( - 'Tool execution failed', - $this->callback(function ($context) { - return 'failing_tool' === $context['name'] - && 'Handler failed' === $context['exception'] - && isset($context['trace']); - }) - ); - - $this->expectException(ToolCallException::class); - $this->expectExceptionMessage('Tool call "failing_tool" failed with error: "Handler failed".'); - - $thrownException = null; - try { - $this->toolCaller->call($request); - } catch (ToolCallException $e) { - $thrownException = $e; - throw $e; - } finally { - if ($thrownException) { - $this->assertSame($request, $thrownException->request); - $this->assertSame($handlerException, $thrownException->getPrevious()); - } - } - } - - public function testCallHandlesNullResult(): void - { - $request = new CallToolRequest('null_tool', []); - $tool = $this->createValidTool('null_tool'); - $toolReference = new ToolReference($tool, fn () => null); - - $this->referenceProvider - ->expects($this->once()) - ->method('getTool') - ->with('null_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, []) - ->willReturn(null); - - $this->logger - ->expects($this->exactly(2)) - ->method('debug'); - - $result = $this->toolCaller->call($request); - - $this->assertInstanceOf(CallToolResult::class, $result); - $this->assertCount(1, $result->content); - $this->assertInstanceOf(TextContent::class, $result->content[0]); - $this->assertEquals('(null)', $result->content[0]->text); - } - - public function testCallHandlesBooleanResults(): void - { - $request = new CallToolRequest('bool_tool', []); - $tool = $this->createValidTool('bool_tool'); - $toolReference = new ToolReference($tool, fn () => true); - - $this->referenceProvider - ->expects($this->once()) - ->method('getTool') - ->with('bool_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, []) - ->willReturn(true); - - $result = $this->toolCaller->call($request); - - $this->assertInstanceOf(CallToolResult::class, $result); - $this->assertCount(1, $result->content); - $this->assertInstanceOf(TextContent::class, $result->content[0]); - $this->assertEquals('true', $result->content[0]->text); - } - - public function testCallHandlesArrayResults(): void - { - $request = new CallToolRequest('array_tool', []); - $tool = $this->createValidTool('array_tool'); - $toolReference = new ToolReference($tool, fn () => ['key' => 'value', 'number' => 42]); - $arrayResult = ['key' => 'value', 'number' => 42]; - - $this->referenceProvider - ->expects($this->once()) - ->method('getTool') - ->with('array_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, []) - ->willReturn($arrayResult); - - $result = $this->toolCaller->call($request); - - $this->assertInstanceOf(CallToolResult::class, $result); - $this->assertCount(1, $result->content); - $this->assertInstanceOf(TextContent::class, $result->content[0]); - $this->assertJsonStringEqualsJsonString( - json_encode($arrayResult, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE), - $result->content[0]->text - ); - } - - public function testCallHandlesContentObjectResults(): void - { - $request = new CallToolRequest('content_tool', []); - $tool = $this->createValidTool('content_tool'); - $toolReference = new ToolReference($tool, fn () => new TextContent('Direct content')); - $contentResult = new TextContent('Direct content'); - - $this->referenceProvider - ->expects($this->once()) - ->method('getTool') - ->with('content_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, []) - ->willReturn($contentResult); - - $result = $this->toolCaller->call($request); - - $this->assertInstanceOf(CallToolResult::class, $result); - $this->assertCount(1, $result->content); - $this->assertSame($contentResult, $result->content[0]); - } - - public function testCallHandlesArrayOfContentResults(): void - { - $request = new CallToolRequest('content_array_tool', []); - $tool = $this->createValidTool('content_array_tool'); - $toolReference = new ToolReference($tool, fn () => [ - new TextContent('First content'), - new TextContent('Second content'), - ]); - $contentArray = [ - new TextContent('First content'), - new TextContent('Second content'), - ]; - - $this->referenceProvider - ->expects($this->once()) - ->method('getTool') - ->with('content_array_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, []) - ->willReturn($contentArray); - - $result = $this->toolCaller->call($request); - - $this->assertInstanceOf(CallToolResult::class, $result); - $this->assertCount(2, $result->content); - $this->assertSame($contentArray[0], $result->content[0]); - $this->assertSame($contentArray[1], $result->content[1]); - } - - public function testCallWithDifferentExceptionTypes(): void - { - $request = new CallToolRequest('error_tool', []); - $tool = $this->createValidTool('error_tool'); - $toolReference = new ToolReference($tool, fn () => throw new \InvalidArgumentException('Invalid input')); - $handlerException = new \InvalidArgumentException('Invalid input'); - - $this->referenceProvider - ->expects($this->once()) - ->method('getTool') - ->with('error_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, []) - ->willThrowException($handlerException); - - $this->logger - ->expects($this->once()) - ->method('error') - ->with( - 'Tool execution failed', - $this->callback(function ($context) { - return 'error_tool' === $context['name'] - && 'Invalid input' === $context['exception'] - && isset($context['trace']); - }) - ); - - $this->expectException(ToolCallException::class); - $this->expectExceptionMessage('Tool call "error_tool" failed with error: "Invalid input".'); - - $this->toolCaller->call($request); - } - - public function testCallLogsResultTypeCorrectlyForString(): void - { - $request = new CallToolRequest('string_tool', []); - $tool = $this->createValidTool('string_tool'); - $toolReference = new ToolReference($tool, fn () => 'string result'); - - $this->referenceProvider - ->expects($this->once()) - ->method('getTool') - ->with('string_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, []) - ->willReturn('string result'); - - $this->logger - ->expects($this->exactly(2)) - ->method('debug'); - - $result = $this->toolCaller->call($request); - - $this->assertInstanceOf(CallToolResult::class, $result); - } - - public function testCallLogsResultTypeCorrectlyForInteger(): void - { - $request = new CallToolRequest('int_tool', []); - $tool = $this->createValidTool('int_tool'); - $toolReference = new ToolReference($tool, fn () => 42); - - $this->referenceProvider - ->expects($this->once()) - ->method('getTool') - ->with('int_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, []) - ->willReturn(42); - - $this->logger - ->expects($this->exactly(2)) - ->method('debug'); - - $result = $this->toolCaller->call($request); - - $this->assertInstanceOf(CallToolResult::class, $result); - } - - public function testCallLogsResultTypeCorrectlyForArray(): void - { - $request = new CallToolRequest('array_tool', []); - $tool = $this->createValidTool('array_tool'); - $toolReference = new ToolReference($tool, fn () => ['test']); - - $this->referenceProvider - ->expects($this->once()) - ->method('getTool') - ->with('array_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, []) - ->willReturn(['test']); - - $this->logger - ->expects($this->exactly(2)) - ->method('debug'); - - $result = $this->toolCaller->call($request); - - $this->assertInstanceOf(CallToolResult::class, $result); - } - - public function testConstructorWithDefaultLogger(): void - { - $executor = new ToolCaller($this->referenceProvider, $this->referenceHandler); - - // Verify it's constructed without throwing exceptions - $this->assertInstanceOf(ToolCaller::class, $executor); - } - - public function testCallHandlesEmptyArrayResult(): void - { - $request = new CallToolRequest('empty_array_tool', []); - $tool = $this->createValidTool('empty_array_tool'); - $toolReference = new ToolReference($tool, fn () => []); - - $this->referenceProvider - ->expects($this->once()) - ->method('getTool') - ->with('empty_array_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, []) - ->willReturn([]); - - $result = $this->toolCaller->call($request); - - $this->assertInstanceOf(CallToolResult::class, $result); - $this->assertCount(1, $result->content); - $this->assertInstanceOf(TextContent::class, $result->content[0]); - $this->assertEquals('[]', $result->content[0]->text); - } - - public function testCallHandlesMixedContentAndNonContentArray(): void - { - $request = new CallToolRequest('mixed_tool', []); - $tool = $this->createValidTool('mixed_tool'); - $mixedResult = [ - new TextContent('First content'), - 'plain string', - 42, - new TextContent('Second content'), - ]; - $toolReference = new ToolReference($tool, fn () => $mixedResult); - - $this->referenceProvider - ->expects($this->once()) - ->method('getTool') - ->with('mixed_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, []) - ->willReturn($mixedResult); - - $result = $this->toolCaller->call($request); - - $this->assertInstanceOf(CallToolResult::class, $result); - // The ToolReference.formatResult should handle this mixed array - $this->assertGreaterThan(1, \count($result->content)); - } - - public function testCallHandlesStdClassResult(): void - { - $request = new CallToolRequest('object_tool', []); - $tool = $this->createValidTool('object_tool'); - $objectResult = new \stdClass(); - $objectResult->property = 'value'; - $toolReference = new ToolReference($tool, fn () => $objectResult); - - $this->referenceProvider - ->expects($this->once()) - ->method('getTool') - ->with('object_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, []) - ->willReturn($objectResult); - - $result = $this->toolCaller->call($request); - - $this->assertInstanceOf(CallToolResult::class, $result); - $this->assertCount(1, $result->content); - $this->assertInstanceOf(TextContent::class, $result->content[0]); - $this->assertStringContainsString('"property": "value"', $result->content[0]->text); - } - - public function testCallHandlesBooleanFalseResult(): void - { - $request = new CallToolRequest('false_tool', []); - $tool = $this->createValidTool('false_tool'); - $toolReference = new ToolReference($tool, fn () => false); - - $this->referenceProvider - ->expects($this->once()) - ->method('getTool') - ->with('false_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, []) - ->willReturn(false); - - $result = $this->toolCaller->call($request); - - $this->assertInstanceOf(CallToolResult::class, $result); - $this->assertCount(1, $result->content); - $this->assertInstanceOf(TextContent::class, $result->content[0]); - $this->assertEquals('false', $result->content[0]->text); - } - - private function createValidTool(string $name): Tool - { - return new Tool( - name: $name, - inputSchema: [ - 'type' => 'object', - 'properties' => [ - 'param' => ['type' => 'string'], - ], - 'required' => null, - ], - description: "Test tool: {$name}", - annotations: null, - ); - } -} diff --git a/tests/Unit/JsonRpc/HandlerTest.php b/tests/Unit/JsonRpc/HandlerTest.php index 63c8e0f6..be9820ed 100644 --- a/tests/Unit/JsonRpc/HandlerTest.php +++ b/tests/Unit/JsonRpc/HandlerTest.php @@ -57,10 +57,10 @@ public function testHandleMultipleNotifications() $sessionStore->method('exists')->willReturn(true); $jsonRpc = new JsonRpcHandler( - MessageFactory::make(), - $sessionFactory, - $sessionStore, - [$handlerA, $handlerB, $handlerC] + methodHandlers: [$handlerA, $handlerB, $handlerC], + messageFactory: MessageFactory::make(), + sessionFactory: $sessionFactory, + sessionStore: $sessionStore, ); $sessionId = Uuid::v4(); $result = $jsonRpc->process( @@ -103,10 +103,10 @@ public function testHandleMultipleRequests() $sessionStore->method('exists')->willReturn(true); $jsonRpc = new JsonRpcHandler( - MessageFactory::make(), - $sessionFactory, - $sessionStore, - [$handlerA, $handlerB, $handlerC] + methodHandlers: [$handlerA, $handlerB, $handlerC], + messageFactory: MessageFactory::make(), + sessionFactory: $sessionFactory, + sessionStore: $sessionStore, ); $sessionId = Uuid::v4(); $result = $jsonRpc->process( diff --git a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php index da092c7e..4423cbd6 100644 --- a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php @@ -11,7 +11,9 @@ namespace Mcp\Tests\Unit\Server\Handler\Request; -use Mcp\Capability\Tool\ToolCallerInterface; +use Mcp\Capability\Registry\ReferenceHandlerInterface; +use Mcp\Capability\Registry\ReferenceProviderInterface; +use Mcp\Capability\Registry\ToolReference; use Mcp\Exception\ToolCallException; use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\Content\TextContent; @@ -28,18 +30,21 @@ class CallToolHandlerTest extends TestCase { private CallToolHandler $handler; - private ToolCallerInterface|MockObject $toolCaller; + private ReferenceProviderInterface|MockObject $referenceProvider; + private ReferenceHandlerInterface|MockObject $referenceHandler; private LoggerInterface|MockObject $logger; private SessionInterface|MockObject $session; protected function setUp(): void { - $this->toolCaller = $this->createMock(ToolCallerInterface::class); + $this->referenceProvider = $this->createMock(ReferenceProviderInterface::class); + $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); $this->logger = $this->createMock(LoggerInterface::class); $this->session = $this->createMock(SessionInterface::class); $this->handler = new CallToolHandler( - $this->toolCaller, + $this->referenceProvider, + $this->referenceHandler, $this->logger, ); } @@ -54,13 +59,26 @@ public function testSupportsCallToolRequest(): void public function testHandleSuccessfulToolCall(): void { $request = $this->createCallToolRequest('greet_user', ['name' => 'John']); + $toolReference = $this->createMock(ToolReference::class); $expectedResult = new CallToolResult([new TextContent('Hello, John!')]); - $this->toolCaller + $this->referenceProvider ->expects($this->once()) - ->method('call') - ->with($request) - ->willReturn($expectedResult); + ->method('getTool') + ->with('greet_user') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, ['name' => 'John']) + ->willReturn('Hello, John!'); + + $toolReference + ->expects($this->once()) + ->method('formatResult') + ->with('Hello, John!') + ->willReturn([new TextContent('Hello, John!')]); $this->logger ->expects($this->never()) @@ -70,24 +88,37 @@ public function testHandleSuccessfulToolCall(): void $this->assertInstanceOf(Response::class, $response); $this->assertEquals($request->getId(), $response->id); - $this->assertSame($expectedResult, $response->result); + $this->assertEquals($expectedResult, $response->result); } public function testHandleToolCallWithEmptyArguments(): void { $request = $this->createCallToolRequest('simple_tool', []); + $toolReference = $this->createMock(ToolReference::class); $expectedResult = new CallToolResult([new TextContent('Simple result')]); - $this->toolCaller + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('simple_tool') + ->willReturn($toolReference); + + $this->referenceHandler ->expects($this->once()) - ->method('call') - ->with($request) - ->willReturn($expectedResult); + ->method('handle') + ->with($toolReference, []) + ->willReturn('Simple result'); + + $toolReference + ->expects($this->once()) + ->method('formatResult') + ->with('Simple result') + ->willReturn([new TextContent('Simple result')]); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); - $this->assertSame($expectedResult, $response->result); + $this->assertEquals($expectedResult, $response->result); } public function testHandleToolCallWithComplexArguments(): void @@ -100,48 +131,52 @@ public function testHandleToolCallWithComplexArguments(): void 'null_param' => null, ]; $request = $this->createCallToolRequest('complex_tool', $arguments); + $toolReference = $this->createMock(ToolReference::class); $expectedResult = new CallToolResult([new TextContent('Complex result')]); - $this->toolCaller + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('complex_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, $arguments) + ->willReturn('Complex result'); + + $toolReference ->expects($this->once()) - ->method('call') - ->with($request) - ->willReturn($expectedResult); + ->method('formatResult') + ->with('Complex result') + ->willReturn([new TextContent('Complex result')]); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); - $this->assertSame($expectedResult, $response->result); + $this->assertEquals($expectedResult, $response->result); } public function testHandleToolNotFoundExceptionReturnsError(): void { $request = $this->createCallToolRequest('nonexistent_tool', ['param' => 'value']); - $exception = new ToolNotFoundException($request); - $this->toolCaller + $this->referenceProvider ->expects($this->once()) - ->method('call') - ->with($request) - ->willThrowException($exception); + ->method('getTool') + ->with('nonexistent_tool') + ->willThrowException(new ToolNotFoundException($request)); $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'], - ], - ); + ->method('error'); $response = $this->handler->handle($request, $this->session); $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); + $this->assertEquals(Error::METHOD_NOT_FOUND, $response->code); } public function testHandleToolExecutionExceptionReturnsError(): void @@ -149,29 +184,28 @@ public function testHandleToolExecutionExceptionReturnsError(): void $request = $this->createCallToolRequest('failing_tool', ['param' => 'value']); $exception = new ToolCallException($request, new \RuntimeException('Tool execution failed')); - $this->toolCaller + $toolReference = $this->createMock(ToolReference::class); + $this->referenceProvider ->expects($this->once()) - ->method('call') - ->with($request) + ->method('getTool') + ->with('failing_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, ['param' => 'value']) ->willThrowException($exception); $this->logger ->expects($this->once()) - ->method('error') - ->with( - 'Error while executing tool "failing_tool": "Tool call "failing_tool" failed with error: "Tool execution failed".".', - [ - 'tool' => 'failing_tool', - 'arguments' => ['param' => 'value'], - ], - ); + ->method('error'); $response = $this->handler->handle($request, $this->session); $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 @@ -179,39 +213,34 @@ public function testHandleWithNullResult(): void $request = $this->createCallToolRequest('null_tool', []); $expectedResult = new CallToolResult([]); - $this->toolCaller + $toolReference = $this->createMock(ToolReference::class); + $this->referenceProvider ->expects($this->once()) - ->method('call') - ->with($request) - ->willReturn($expectedResult); + ->method('getTool') + ->with('null_tool') + ->willReturn($toolReference); - $response = $this->handler->handle($request, $this->session); - - $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->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, []) + ->willReturn(null); - $this->toolCaller + $toolReference ->expects($this->once()) - ->method('call') - ->with($request) - ->willReturn($expectedResult); + ->method('formatResult') + ->with(null) + ->willReturn([]); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); - $this->assertSame($expectedResult, $response->result); - $this->assertTrue($response->result->isError); + $this->assertEquals($expectedResult, $response->result); } public function testConstructorWithDefaultLogger(): void { - $handler = new CallToolHandler($this->toolCaller); + $handler = new CallToolHandler($this->referenceProvider, $this->referenceHandler); $this->assertInstanceOf(CallToolHandler::class, $handler); } @@ -221,9 +250,17 @@ public function testHandleLogsErrorWithCorrectParameters(): void $request = $this->createCallToolRequest('test_tool', ['key1' => 'value1', 'key2' => 42]); $exception = new ToolCallException($request, new \RuntimeException('Custom error message')); - $this->toolCaller + $toolReference = $this->createMock(ToolReference::class); + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('test_tool') + ->willReturn($toolReference); + + $this->referenceHandler ->expects($this->once()) - ->method('call') + ->method('handle') + ->with($toolReference, ['key1' => 'value1', 'key2' => 42]) ->willThrowException($exception); $this->logger @@ -245,16 +282,29 @@ public function testHandleWithSpecialCharactersInToolName(): void $request = $this->createCallToolRequest('tool-with_special.chars', []); $expectedResult = new CallToolResult([new TextContent('Special tool result')]); - $this->toolCaller + $toolReference = $this->createMock(ToolReference::class); + $this->referenceProvider ->expects($this->once()) - ->method('call') - ->with($request) - ->willReturn($expectedResult); + ->method('getTool') + ->with('tool-with_special.chars') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, []) + ->willReturn('Special tool result'); + + $toolReference + ->expects($this->once()) + ->method('formatResult') + ->with('Special tool result') + ->willReturn([new TextContent('Special tool result')]); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); - $this->assertSame($expectedResult, $response->result); + $this->assertEquals($expectedResult, $response->result); } public function testHandleWithSpecialCharactersInArguments(): void @@ -267,16 +317,29 @@ public function testHandleWithSpecialCharactersInArguments(): void $request = $this->createCallToolRequest('unicode_tool', $arguments); $expectedResult = new CallToolResult([new TextContent('Unicode handled')]); - $this->toolCaller + $toolReference = $this->createMock(ToolReference::class); + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('unicode_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, $arguments) + ->willReturn('Unicode handled'); + + $toolReference ->expects($this->once()) - ->method('call') - ->with($request) - ->willReturn($expectedResult); + ->method('formatResult') + ->with('Unicode handled') + ->willReturn([new TextContent('Unicode handled')]); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); - $this->assertSame($expectedResult, $response->result); + $this->assertEquals($expectedResult, $response->result); } /** diff --git a/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php b/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php index 9fba1b29..3f5171b1 100644 --- a/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php @@ -11,7 +11,9 @@ namespace Mcp\Tests\Unit\Server\Handler\Request; -use Mcp\Capability\Prompt\PromptGetterInterface; +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\Schema\Content\PromptMessage; @@ -29,15 +31,17 @@ class GetPromptHandlerTest extends TestCase { private GetPromptHandler $handler; - private PromptGetterInterface|MockObject $promptGetter; + private ReferenceProviderInterface|MockObject $referenceProvider; + private ReferenceHandlerInterface|MockObject $referenceHandler; private SessionInterface|MockObject $session; protected function setUp(): void { - $this->promptGetter = $this->createMock(PromptGetterInterface::class); + $this->referenceProvider = $this->createMock(ReferenceProviderInterface::class); + $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); $this->session = $this->createMock(SessionInterface::class); - $this->handler = new GetPromptHandler($this->promptGetter); + $this->handler = new GetPromptHandler($this->referenceProvider, $this->referenceHandler); } public function testSupportsGetPromptRequest(): void @@ -53,22 +57,33 @@ public function testHandleSuccessfulPromptGet(): void $expectedMessages = [ new PromptMessage(Role::User, new TextContent('Hello, how can I help you?')), ]; - $expectedResult = new GetPromptResult( - description: 'A greeting prompt', - messages: $expectedMessages, - ); + $expectedResult = new GetPromptResult($expectedMessages); - $this->promptGetter + $promptReference = $this->createMock(PromptReference::class); + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('greeting_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, []) + ->willReturn($expectedMessages); + + $promptReference ->expects($this->once()) - ->method('get') - ->with($request) - ->willReturn($expectedResult); + ->method('formatResult') + ->with($expectedMessages) + ->willReturn($expectedMessages); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertEquals($request->getId(), $response->id); - $this->assertSame($expectedResult, $response->result); + $this->assertEquals($expectedResult, $response->result); } public function testHandlePromptGetWithArguments(): void @@ -85,21 +100,31 @@ public function testHandlePromptGetWithArguments(): void new TextContent('Good morning, John. How may I assist you in your business meeting?'), ), ]; - $expectedResult = new GetPromptResult( - description: 'A personalized greeting prompt', - messages: $expectedMessages, - ); + $expectedResult = new GetPromptResult($expectedMessages); - $this->promptGetter + $promptReference = $this->createMock(PromptReference::class); + $this->referenceProvider ->expects($this->once()) - ->method('get') - ->with($request) - ->willReturn($expectedResult); + ->method('getPrompt') + ->with('personalized_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, $arguments) + ->willReturn($expectedMessages); + + $promptReference + ->expects($this->once()) + ->method('formatResult') + ->with($expectedMessages) + ->willReturn($expectedMessages); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); - $this->assertSame($expectedResult, $response->result); + $this->assertEquals($expectedResult, $response->result); } public function testHandlePromptGetWithNullArguments(): void @@ -108,21 +133,31 @@ public function testHandlePromptGetWithNullArguments(): void $expectedMessages = [ new PromptMessage(Role::Assistant, new TextContent('I am ready to help.')), ]; - $expectedResult = new GetPromptResult( - description: 'A simple prompt', - messages: $expectedMessages, - ); + $expectedResult = new GetPromptResult($expectedMessages); + + $promptReference = $this->createMock(PromptReference::class); + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('simple_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, []) + ->willReturn($expectedMessages); - $this->promptGetter + $promptReference ->expects($this->once()) - ->method('get') - ->with($request) - ->willReturn($expectedResult); + ->method('formatResult') + ->with($expectedMessages) + ->willReturn($expectedMessages); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); - $this->assertSame($expectedResult, $response->result); + $this->assertEquals($expectedResult, $response->result); } public function testHandlePromptGetWithEmptyArguments(): void @@ -131,21 +166,31 @@ public function testHandlePromptGetWithEmptyArguments(): void $expectedMessages = [ new PromptMessage(Role::User, new TextContent('Default message')), ]; - $expectedResult = new GetPromptResult( - description: 'A prompt with empty arguments', - messages: $expectedMessages, - ); + $expectedResult = new GetPromptResult($expectedMessages); + + $promptReference = $this->createMock(PromptReference::class); + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('empty_args_prompt') + ->willReturn($promptReference); - $this->promptGetter + $this->referenceHandler ->expects($this->once()) - ->method('get') - ->with($request) - ->willReturn($expectedResult); + ->method('handle') + ->with($promptReference, []) + ->willReturn($expectedMessages); + + $promptReference + ->expects($this->once()) + ->method('formatResult') + ->with($expectedMessages) + ->willReturn($expectedMessages); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); - $this->assertSame($expectedResult, $response->result); + $this->assertEquals($expectedResult, $response->result); } public function testHandlePromptGetWithMultipleMessages(): void @@ -156,22 +201,31 @@ public function testHandlePromptGetWithMultipleMessages(): void 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, - ); + $expectedResult = new GetPromptResult($expectedMessages); - $this->promptGetter + $promptReference = $this->createMock(PromptReference::class); + $this->referenceProvider ->expects($this->once()) - ->method('get') - ->with($request) - ->willReturn($expectedResult); + ->method('getPrompt') + ->with('conversation_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, []) + ->willReturn($expectedMessages); + + $promptReference + ->expects($this->once()) + ->method('formatResult') + ->with($expectedMessages) + ->willReturn($expectedMessages); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); - $this->assertSame($expectedResult, $response->result); - $this->assertCount(3, $response->result->messages); + $this->assertEquals($expectedResult, $response->result); } public function testHandlePromptNotFoundExceptionReturnsError(): void @@ -179,18 +233,18 @@ public function testHandlePromptNotFoundExceptionReturnsError(): void $request = $this->createGetPromptRequest('nonexistent_prompt'); $exception = new PromptNotFoundException($request); - $this->promptGetter + $this->referenceProvider ->expects($this->once()) - ->method('get') - ->with($request) + ->method('getPrompt') + ->with('nonexistent_prompt') ->willThrowException($exception); $response = $this->handler->handle($request, $this->session); $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); + $this->assertEquals(Error::METHOD_NOT_FOUND, $response->code); + $this->assertEquals('Prompt not found for name: "nonexistent_prompt".', $response->message); } public function testHandlePromptGetExceptionReturnsError(): void @@ -198,10 +252,10 @@ public function testHandlePromptGetExceptionReturnsError(): void $request = $this->createGetPromptRequest('failing_prompt'); $exception = new PromptGetException($request, new \RuntimeException('Failed to get prompt')); - $this->promptGetter + $this->referenceProvider ->expects($this->once()) - ->method('get') - ->with($request) + ->method('getPrompt') + ->with('failing_prompt') ->willThrowException($exception); $response = $this->handler->handle($request, $this->session); @@ -233,21 +287,31 @@ public function testHandlePromptGetWithComplexArguments(): void $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, - ); + $expectedResult = new GetPromptResult($expectedMessages); + + $promptReference = $this->createMock(PromptReference::class); + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('complex_prompt') + ->willReturn($promptReference); - $this->promptGetter + $this->referenceHandler ->expects($this->once()) - ->method('get') - ->with($request) - ->willReturn($expectedResult); + ->method('handle') + ->with($promptReference, $arguments) + ->willReturn($expectedMessages); + + $promptReference + ->expects($this->once()) + ->method('formatResult') + ->with($expectedMessages) + ->willReturn($expectedMessages); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); - $this->assertSame($expectedResult, $response->result); + $this->assertEquals($expectedResult, $response->result); } public function testHandlePromptGetWithSpecialCharacters(): void @@ -261,42 +325,61 @@ public function testHandlePromptGetWithSpecialCharacters(): void $expectedMessages = [ new PromptMessage(Role::User, new TextContent('Unicode message processed')), ]; - $expectedResult = new GetPromptResult( - description: 'A prompt handling unicode characters', - messages: $expectedMessages, - ); + $expectedResult = new GetPromptResult($expectedMessages); - $this->promptGetter + $promptReference = $this->createMock(PromptReference::class); + $this->referenceProvider ->expects($this->once()) - ->method('get') - ->with($request) - ->willReturn($expectedResult); + ->method('getPrompt') + ->with('unicode_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, $arguments) + ->willReturn($expectedMessages); + + $promptReference + ->expects($this->once()) + ->method('formatResult') + ->with($expectedMessages) + ->willReturn($expectedMessages); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); - $this->assertSame($expectedResult, $response->result); + $this->assertEquals($expectedResult, $response->result); } public function testHandlePromptGetReturnsEmptyMessages(): void { $request = $this->createGetPromptRequest('empty_prompt'); - $expectedResult = new GetPromptResult( - description: 'An empty prompt', - messages: [], - ); + $expectedResult = new GetPromptResult([]); + + $promptReference = $this->createMock(PromptReference::class); + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('empty_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, []) + ->willReturn([]); - $this->promptGetter + $promptReference ->expects($this->once()) - ->method('get') - ->with($request) - ->willReturn($expectedResult); + ->method('formatResult') + ->with([]) + ->willReturn([]); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); - $this->assertSame($expectedResult, $response->result); - $this->assertCount(0, $response->result->messages); + $this->assertEquals($expectedResult, $response->result); } public function testHandlePromptGetWithLargeNumberOfArguments(): void @@ -310,21 +393,31 @@ public function testHandlePromptGetWithLargeNumberOfArguments(): void $expectedMessages = [ new PromptMessage(Role::User, new TextContent('Processed 100 arguments')), ]; - $expectedResult = new GetPromptResult( - description: 'A prompt with many arguments', - messages: $expectedMessages, - ); + $expectedResult = new GetPromptResult($expectedMessages); + + $promptReference = $this->createMock(PromptReference::class); + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('many_args_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, $arguments) + ->willReturn($expectedMessages); - $this->promptGetter + $promptReference ->expects($this->once()) - ->method('get') - ->with($request) - ->willReturn($expectedResult); + ->method('formatResult') + ->with($expectedMessages) + ->willReturn($expectedMessages); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); - $this->assertSame($expectedResult, $response->result); + $this->assertEquals($expectedResult, $response->result); } /** diff --git a/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php b/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php index fd67becd..440005d4 100644 --- a/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php @@ -11,7 +11,9 @@ namespace Mcp\Tests\Unit\Server\Handler\Request; -use Mcp\Capability\Resource\ResourceReaderInterface; +use Mcp\Capability\Registry\ReferenceHandlerInterface; +use Mcp\Capability\Registry\ReferenceProviderInterface; +use Mcp\Capability\Registry\ResourceReference; use Mcp\Exception\ResourceNotFoundException; use Mcp\Exception\ResourceReadException; use Mcp\Schema\Content\BlobResourceContents; @@ -19,6 +21,7 @@ use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ReadResourceRequest; +use Mcp\Schema\Resource; use Mcp\Schema\Result\ReadResourceResult; use Mcp\Server\Handler\Request\ReadResourceHandler; use Mcp\Server\Session\SessionInterface; @@ -28,15 +31,17 @@ class ReadResourceHandlerTest extends TestCase { private ReadResourceHandler $handler; - private ResourceReaderInterface|MockObject $resourceReader; + private ReferenceProviderInterface|MockObject $referenceProvider; + private ReferenceHandlerInterface|MockObject $referenceHandler; private SessionInterface|MockObject $session; protected function setUp(): void { - $this->resourceReader = $this->createMock(ResourceReaderInterface::class); + $this->referenceProvider = $this->createMock(ReferenceProviderInterface::class); + $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); $this->session = $this->createMock(SessionInterface::class); - $this->handler = new ReadResourceHandler($this->resourceReader); + $this->handler = new ReadResourceHandler($this->referenceProvider, $this->referenceHandler); } public function testSupportsReadResourceRequest(): void @@ -57,17 +62,33 @@ public function testHandleSuccessfulResourceRead(): void ); $expectedResult = new ReadResourceResult([$expectedContent]); - $this->resourceReader + $resourceReference = $this->getMockBuilder(ResourceReference::class) + ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'text/plain'), []]) + ->getMock(); + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with($uri) + ->willReturn($resourceReference); + + $this->referenceHandler ->expects($this->once()) - ->method('read') - ->with($request) - ->willReturn($expectedResult); + ->method('handle') + ->with($resourceReference, ['uri' => $uri]) + ->willReturn('test'); + + $resourceReference + ->expects($this->once()) + ->method('formatResult') + ->with('test', $uri, 'text/plain') + ->willReturn([$expectedContent]); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertEquals($request->getId(), $response->id); - $this->assertSame($expectedResult, $response->result); + $this->assertEquals($expectedResult, $response->result); } public function testHandleResourceReadWithBlobContent(): void @@ -81,16 +102,32 @@ public function testHandleResourceReadWithBlobContent(): void ); $expectedResult = new ReadResourceResult([$expectedContent]); - $this->resourceReader + $resourceReference = $this->getMockBuilder(ResourceReference::class) + ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'image/png'), []]) + ->getMock(); + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with($uri) + ->willReturn($resourceReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($resourceReference, ['uri' => $uri]) + ->willReturn('fake-image-data'); + + $resourceReference ->expects($this->once()) - ->method('read') - ->with($request) - ->willReturn($expectedResult); + ->method('formatResult') + ->with('fake-image-data', $uri, 'image/png') + ->willReturn([$expectedContent]); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); - $this->assertSame($expectedResult, $response->result); + $this->assertEquals($expectedResult, $response->result); } public function testHandleResourceReadWithMultipleContents(): void @@ -109,17 +146,32 @@ public function testHandleResourceReadWithMultipleContents(): void ); $expectedResult = new ReadResourceResult([$textContent, $blobContent]); - $this->resourceReader + $resourceReference = $this->getMockBuilder(ResourceReference::class) + ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'application/octet-stream'), []]) + ->getMock(); + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with($uri) + ->willReturn($resourceReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($resourceReference, ['uri' => $uri]) + ->willReturn('binary-data'); + + $resourceReference ->expects($this->once()) - ->method('read') - ->with($request) - ->willReturn($expectedResult); + ->method('formatResult') + ->with('binary-data', $uri, 'application/octet-stream') + ->willReturn([$textContent, $blobContent]); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); - $this->assertSame($expectedResult, $response->result); - $this->assertCount(2, $response->result->contents); + $this->assertEquals($expectedResult, $response->result); } public function testHandleResourceNotFoundExceptionReturnsSpecificError(): void @@ -128,10 +180,10 @@ public function testHandleResourceNotFoundExceptionReturnsSpecificError(): void $request = $this->createReadResourceRequest($uri); $exception = new ResourceNotFoundException($request); - $this->resourceReader + $this->referenceProvider ->expects($this->once()) - ->method('read') - ->with($request) + ->method('getResource') + ->with($uri) ->willThrowException($exception); $response = $this->handler->handle($request, $this->session); @@ -151,10 +203,10 @@ public function testHandleResourceReadExceptionReturnsGenericError(): void new \RuntimeException('Failed to read resource: corrupted data'), ); - $this->resourceReader + $this->referenceProvider ->expects($this->once()) - ->method('read') - ->with($request) + ->method('getResource') + ->with($uri) ->willThrowException($exception); $response = $this->handler->handle($request, $this->session); @@ -185,46 +237,40 @@ public function testHandleResourceReadWithDifferentUriSchemes(): void ); $expectedResult = new ReadResourceResult([$expectedContent]); - $this->resourceReader + $resourceReference = $this->getMockBuilder(ResourceReference::class) + ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'text/plain'), []]) + ->getMock(); + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with($uri) + ->willReturn($resourceReference); + + $this->referenceHandler ->expects($this->once()) - ->method('read') - ->with($request) - ->willReturn($expectedResult); + ->method('handle') + ->with($resourceReference, ['uri' => $uri]) + ->willReturn('test'); + + $resourceReference + ->expects($this->once()) + ->method('formatResult') + ->with('test', $uri, 'text/plain') + ->willReturn([$expectedContent]); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); - $this->assertSame($expectedResult, $response->result); + $this->assertEquals($expectedResult, $response->result); // Reset the mock for next iteration - $this->resourceReader = $this->createMock(ResourceReaderInterface::class); - $this->handler = new ReadResourceHandler($this->resourceReader); + $this->referenceProvider = $this->createMock(ReferenceProviderInterface::class); + $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); + $this->handler = new ReadResourceHandler($this->referenceProvider, $this->referenceHandler); } } - 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->session); - - $this->assertInstanceOf(Response::class, $response); - $this->assertSame($expectedResult, $response->result); - } - public function testHandleResourceReadWithEmptyContent(): void { $uri = 'file://empty/file.txt'; @@ -236,18 +282,32 @@ public function testHandleResourceReadWithEmptyContent(): void ); $expectedResult = new ReadResourceResult([$expectedContent]); - $this->resourceReader + $resourceReference = $this->getMockBuilder(ResourceReference::class) + ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'text/plain'), []]) + ->getMock(); + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with($uri) + ->willReturn($resourceReference); + + $this->referenceHandler ->expects($this->once()) - ->method('read') - ->with($request) - ->willReturn($expectedResult); + ->method('handle') + ->with($resourceReference, ['uri' => $uri]) + ->willReturn(''); + + $resourceReference + ->expects($this->once()) + ->method('formatResult') + ->with('', $uri, 'text/plain') + ->willReturn([$expectedContent]); $response = $this->handler->handle($request, $this->session); $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); + $this->assertEquals($expectedResult, $response->result); } public function testHandleResourceReadWithDifferentMimeTypes(): void @@ -284,21 +344,37 @@ public function testHandleResourceReadWithDifferentMimeTypes(): void } $expectedResult = new ReadResourceResult([$expectedContent]); - $this->resourceReader + $resourceReference = $this->getMockBuilder(ResourceReference::class) + ->setConstructorArgs([new Resource($uri, 'test', mimeType: $mimeType), []]) + ->getMock(); + + $this->referenceProvider ->expects($this->once()) - ->method('read') - ->with($request) - ->willReturn($expectedResult); + ->method('getResource') + ->with($uri) + ->willReturn($resourceReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($resourceReference, ['uri' => $uri]) + ->willReturn($expectedContent); + + $resourceReference + ->expects($this->once()) + ->method('formatResult') + ->with($expectedContent, $uri, $mimeType) + ->willReturn([$expectedContent]); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); - $this->assertSame($expectedResult, $response->result); - $this->assertEquals($mimeType, $response->result->contents[0]->mimeType); + $this->assertEquals($expectedResult, $response->result); // Reset the mock for next iteration - $this->resourceReader = $this->createMock(ResourceReaderInterface::class); - $this->handler = new ReadResourceHandler($this->resourceReader); + $this->referenceProvider = $this->createMock(ReferenceProviderInterface::class); + $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); + $this->handler = new ReadResourceHandler($this->referenceProvider, $this->referenceHandler); } } @@ -308,10 +384,10 @@ public function testHandleResourceNotFoundWithCustomMessage(): void $request = $this->createReadResourceRequest($uri); $exception = new ResourceNotFoundException($request); - $this->resourceReader + $this->referenceProvider ->expects($this->once()) - ->method('read') - ->with($request) + ->method('getResource') + ->with($uri) ->willThrowException($exception); $response = $this->handler->handle($request, $this->session); @@ -321,25 +397,6 @@ public function testHandleResourceNotFoundWithCustomMessage(): void $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->session); - - $this->assertInstanceOf(Response::class, $response); - $this->assertSame($expectedResult, $response->result); - $this->assertCount(0, $response->result->contents); - } - private function createReadResourceRequest(string $uri): ReadResourceRequest { return ReadResourceRequest::fromArray([