diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 8d1831cd..23418901 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -54,7 +54,6 @@ parameters: count: 1 path: examples/02-discovery-http-userprofile/server.php - - message: '#^Method Mcp\\Example\\ManualStdioExample\\SimpleHandlers\:\:getItemDetails\(\) return type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -73,7 +72,6 @@ parameters: count: 2 path: examples/04-combined-registration-http/server.php - - message: '#^Method Mcp\\Example\\StdioEnvVariables\\EnvToolHandler\:\:processData\(\) return type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -266,7 +264,6 @@ parameters: count: 2 path: examples/07-complex-tool-schema-http/McpEventScheduler.php - - message: '#^Method Mcp\\Example\\SchemaShowcaseExample\\SchemaShowcaseElements\:\:calculateRange\(\) return type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -321,8 +318,6 @@ parameters: count: 1 path: examples/08-schema-showcase-streamable/SchemaShowcaseElements.php - - - message: '#^Call to an undefined method Mcp\\Capability\\Registry\\ResourceTemplateReference\:\:handle\(\)\.$#' identifier: method.notFound @@ -342,103 +337,49 @@ parameters: path: src/Schema/Result/ReadResourceResult.php - - message: '#^Method Mcp\\Capability\\Registry\\ReferenceProviderInterface\:\:getPrompts\(\) invoked with 2 parameters, 0 required\.$#' - identifier: arguments.count - count: 1 - path: src/Server/RequestHandler/ListPromptsHandler.php - - - - message: '#^Result of && is always false\.$#' - identifier: booleanAnd.alwaysFalse - count: 1 - path: src/Server/RequestHandler/ListPromptsHandler.php - - - - message: '#^Strict comparison using \!\=\= between null and null will always evaluate to false\.$#' - identifier: notIdentical.alwaysFalse - count: 1 - path: src/Server/RequestHandler/ListPromptsHandler.php - - - - message: '#^Method Mcp\\Capability\\Registry\\ReferenceProviderInterface\:\:getResources\(\) invoked with 2 parameters, 0 required\.$#' - identifier: arguments.count - count: 1 - path: src/Server/RequestHandler/ListResourcesHandler.php - - - - message: '#^Result of && is always false\.$#' - identifier: booleanAnd.alwaysFalse - count: 1 - path: src/Server/RequestHandler/ListResourcesHandler.php - - - - message: '#^Strict comparison using \!\=\= between null and null will always evaluate to false\.$#' - identifier: notIdentical.alwaysFalse - count: 1 - path: src/Server/RequestHandler/ListResourcesHandler.php - - - - message: '#^Method Mcp\\Capability\\Registry\\ReferenceProviderInterface\:\:getTools\(\) invoked with 2 parameters, 0 required\.$#' - identifier: arguments.count - count: 1 - path: src/Server/RequestHandler/ListToolsHandler.php - - - - message: '#^Result of && is always false\.$#' - identifier: booleanAnd.alwaysFalse - count: 1 - path: src/Server/RequestHandler/ListToolsHandler.php - - - - message: '#^Strict comparison using \!\=\= between null and null will always evaluate to false\.$#' - identifier: notIdentical.alwaysFalse - count: 1 - path: src/Server/RequestHandler/ListToolsHandler.php - - - - message: '#^Method Mcp\\Server\\ServerBuilder\:\:getCompletionProviders\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method Mcp\\Server\\ServerBuilder\:\:addPrompt\(\) has parameter \$handler with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: src/Server/ServerBuilder.php - - message: '#^Method Mcp\\Server\\ServerBuilder\:\:setDiscovery\(\) has parameter \$excludeDirs with no value type specified in iterable type array\.$#' + message: '#^Method Mcp\\Server\\ServerBuilder\:\:addResource\(\) has parameter \$handler with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: src/Server/ServerBuilder.php - - message: '#^Method Mcp\\Server\\ServerBuilder\:\:setDiscovery\(\) has parameter \$scanDirs with no value type specified in iterable type array\.$#' + message: '#^Method Mcp\\Server\\ServerBuilder\:\:addResourceTemplate\(\) has parameter \$handler with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: src/Server/ServerBuilder.php - - message: '#^Method Mcp\\Server\\ServerBuilder\:\:addPrompt\(\) has parameter \$handler with no value type specified in iterable type array\.$#' + message: '#^Method Mcp\\Server\\ServerBuilder\:\:addTool\(\) has parameter \$handler with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: src/Server/ServerBuilder.php - - message: '#^Method Mcp\\Server\\ServerBuilder\:\:addResource\(\) has parameter \$handler with no value type specified in iterable type array\.$#' + message: '#^Method Mcp\\Server\\ServerBuilder\:\:addTool\(\) has parameter \$inputSchema with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: src/Server/ServerBuilder.php - - message: '#^Method Mcp\\Server\\ServerBuilder\:\:addResourceTemplate\(\) has parameter \$handler with no value type specified in iterable type array\.$#' + message: '#^Method Mcp\\Server\\ServerBuilder\:\:getCompletionProviders\(\) return type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: src/Server/ServerBuilder.php - - message: '#^Method Mcp\\Server\\ServerBuilder\:\:addTool\(\) has parameter \$handler with no value type specified in iterable type array\.$#' + message: '#^Method Mcp\\Server\\ServerBuilder\:\:setDiscovery\(\) has parameter \$excludeDirs with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: src/Server/ServerBuilder.php - - message: '#^Method Mcp\\Server\\ServerBuilder\:\:addTool\(\) has parameter \$inputSchema with no value type specified in iterable type array\.$#' + message: '#^Method Mcp\\Server\\ServerBuilder\:\:setDiscovery\(\) has parameter \$scanDirs with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: src/Server/ServerBuilder.php @@ -456,37 +397,37 @@ parameters: path: src/Server/ServerBuilder.php - - message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$prompts type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue + message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$paginationLimit \(int\|null\) is never assigned null so it can be removed from the property type\.$#' + identifier: property.unusedType count: 1 path: src/Server/ServerBuilder.php - - message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$resourceTemplates type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue + message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$paginationLimit is never read, only written\.$#' + identifier: property.onlyWritten count: 1 path: src/Server/ServerBuilder.php - - message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$resources type has no value type specified in iterable type array\.$#' + message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$prompts type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: src/Server/ServerBuilder.php - - message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$tools type has no value type specified in iterable type array\.$#' + message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$resourceTemplates type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: src/Server/ServerBuilder.php - - message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$paginationLimit \(int\|null\) is never assigned null so it can be removed from the property type\.$#' - identifier: property.unusedType + message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$resources type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue count: 1 path: src/Server/ServerBuilder.php - - message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$paginationLimit is never read, only written\.$#' - identifier: property.onlyWritten + message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$tools type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue count: 1 path: src/Server/ServerBuilder.php diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index 2e5776f5..ff836745 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -22,11 +22,13 @@ use Mcp\Event\ResourceListChangedEvent; use Mcp\Event\ResourceTemplateListChangedEvent; use Mcp\Event\ToolListChangedEvent; +use Mcp\Exception\InvalidCursorException; use Mcp\Schema\Prompt; use Mcp\Schema\Resource; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\ServerCapabilities; use Mcp\Schema\Tool; +use Mcp\Server\RequestHandler\Reference\Page; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -244,27 +246,92 @@ public function getPrompt(string $name): ?PromptReference return $this->prompts[$name] ?? null; } - public function getTools(): array + public function getTools(?int $limit = null, ?string $cursor = null): Page { - return array_map(fn (ToolReference $tool) => $tool->tool, $this->tools); + $tools = []; + foreach ($this->tools as $toolReference) { + $tools[$toolReference->tool->name] = $toolReference->tool; + } + + if (null === $limit) { + return new Page($tools, null); + } + + $paginatedTools = $this->paginateResults($tools, $limit, $cursor); + + $nextCursor = $this->calculateNextCursor( + \count($tools), + $cursor, + $limit + ); + + return new Page($paginatedTools, $nextCursor); } - public function getResources(): array + public function getResources(?int $limit = null, ?string $cursor = null): Page { - return array_map(fn (ResourceReference $resource) => $resource->schema, $this->resources); + $resources = []; + foreach ($this->resources as $resourceReference) { + $resources[$resourceReference->schema->uri] = $resourceReference->schema; + } + + if (null === $limit) { + return new Page($resources, null); + } + + $paginatedResources = $this->paginateResults($resources, $limit, $cursor); + + $nextCursor = $this->calculateNextCursor( + \count($resources), + $cursor, + $limit + ); + + return new Page($paginatedResources, $nextCursor); } - public function getPrompts(): array + public function getPrompts(?int $limit = null, ?string $cursor = null): Page { - return array_map(fn (PromptReference $prompt) => $prompt->prompt, $this->prompts); + $prompts = []; + foreach ($this->prompts as $promptReference) { + $prompts[$promptReference->prompt->name] = $promptReference->prompt; + } + + if (null === $limit) { + return new Page($prompts, null); + } + + $paginatedPrompts = $this->paginateResults($prompts, $limit, $cursor); + + $nextCursor = $this->calculateNextCursor( + \count($prompts), + $cursor, + $limit + ); + + return new Page($paginatedPrompts, $nextCursor); } - public function getResourceTemplates(): array + public function getResourceTemplates(?int $limit = null, ?string $cursor = null): Page { - return array_map( - fn (ResourceTemplateReference $template) => $template->resourceTemplate, - $this->resourceTemplates + $templates = []; + foreach ($this->resourceTemplates as $templateReference) { + $templates[$templateReference->resourceTemplate->uriTemplate] = $templateReference->resourceTemplate; + } + + if (null === $limit) { + return new Page($templates, null); + } + + $paginatedTemplates = $this->paginateResults($templates, $limit, $cursor); + + $nextCursor = $this->calculateNextCursor( + \count($templates), + $cursor, + $limit ); + + return new Page($paginatedTemplates, $nextCursor); } public function hasElements(): bool @@ -327,4 +394,63 @@ public function setDiscoveryState(DiscoveryState $state): void } } } + + /** + * Calculate next cursor for pagination. + * + * @param int $totalItems Count of all items + * @param string|null $currentCursor Current cursor position + * @param int $limit Number requested/returned per page + */ + private function calculateNextCursor(int $totalItems, ?string $currentCursor, int $limit): ?string + { + $currentOffset = 0; + + if (null !== $currentCursor) { + $decodedCursor = base64_decode($currentCursor, true); + if (false !== $decodedCursor && is_numeric($decodedCursor)) { + $currentOffset = (int) $decodedCursor; + } + } + + $nextOffset = $currentOffset + $limit; + + if ($nextOffset < $totalItems) { + return base64_encode((string) $nextOffset); + } + + return null; + } + + /** + * Helper method to paginate results using cursor-based pagination. + * + * @param array $items The full array of items to paginate The full array of items to paginate + * @param int $limit Maximum number of items to return + * @param string|null $cursor Base64 encoded offset position + * + * @return array Paginated results + * + * @throws InvalidCursorException When cursor is invalid (MCP error code -32602) + */ + private function paginateResults(array $items, int $limit, ?string $cursor = null): array + { + $offset = 0; + if (null !== $cursor) { + $decodedCursor = base64_decode($cursor, true); + + if (false === $decodedCursor || !is_numeric($decodedCursor)) { + throw new InvalidCursorException($cursor); + } + + $offset = (int) $decodedCursor; + + // Validate offset is within reasonable bounds + if ($offset < 0 || $offset > \count($items)) { + throw new InvalidCursorException($cursor); + } + } + + return array_values(\array_slice($items, $offset, $limit)); + } } diff --git a/src/Capability/Registry/ReferenceProviderInterface.php b/src/Capability/Registry/ReferenceProviderInterface.php index 6b264001..1d40359f 100644 --- a/src/Capability/Registry/ReferenceProviderInterface.php +++ b/src/Capability/Registry/ReferenceProviderInterface.php @@ -11,9 +11,7 @@ namespace Mcp\Capability\Registry; -use Mcp\Schema\Prompt; -use Mcp\Schema\ResourceTemplate; -use Mcp\Schema\Tool; +use Mcp\Server\RequestHandler\Reference\Page; /** * Interface for providing access to registered MCP elements. @@ -45,31 +43,23 @@ public function getPrompt(string $name): ?PromptReference; /** * Gets all registered tools. - * - * @return array */ - public function getTools(): array; + public function getTools(?int $limit = null, ?string $cursor = null): Page; /** * Gets all registered resources. - * - * @return array */ - public function getResources(): array; + public function getResources(?int $limit = null, ?string $cursor = null): Page; /** * Gets all registered prompts. - * - * @return array */ - public function getPrompts(): array; + public function getPrompts(?int $limit = null, ?string $cursor = null): Page; /** * Gets all registered resource templates. - * - * @return array */ - public function getResourceTemplates(): array; + public function getResourceTemplates(?int $limit = null, ?string $cursor = null): Page; /** * Checks if any elements (manual or discovered) are currently registered. diff --git a/src/Server/RequestHandler/ListPromptsHandler.php b/src/Server/RequestHandler/ListPromptsHandler.php index bd93dd60..90459b3a 100644 --- a/src/Server/RequestHandler/ListPromptsHandler.php +++ b/src/Server/RequestHandler/ListPromptsHandler.php @@ -12,6 +12,7 @@ namespace Mcp\Server\RequestHandler; use Mcp\Capability\Registry\ReferenceProviderInterface; +use Mcp\Exception\InvalidCursorException; use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ListPromptsRequest; @@ -35,17 +36,18 @@ public function supports(HasMethodInterface $message): bool return $message instanceof ListPromptsRequest; } + /** + * @throws InvalidCursorException + */ public function handle(ListPromptsRequest|HasMethodInterface $message, SessionInterface $session): Response { \assert($message instanceof ListPromptsRequest); - $cursor = null; - $prompts = $this->registry->getPrompts($this->pageSize, $message->cursor); - $nextCursor = (null !== $cursor && \count($prompts) === $this->pageSize) ? $cursor : null; + $page = $this->registry->getPrompts($this->pageSize, $message->cursor); return new Response( $message->getId(), - new ListPromptsResult($prompts, $nextCursor), + new ListPromptsResult($page->references, $page->nextCursor), ); } } diff --git a/src/Server/RequestHandler/ListResourcesHandler.php b/src/Server/RequestHandler/ListResourcesHandler.php index f0abad28..f70e63d5 100644 --- a/src/Server/RequestHandler/ListResourcesHandler.php +++ b/src/Server/RequestHandler/ListResourcesHandler.php @@ -12,6 +12,7 @@ namespace Mcp\Server\RequestHandler; use Mcp\Capability\Registry\ReferenceProviderInterface; +use Mcp\Exception\InvalidCursorException; use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ListResourcesRequest; @@ -35,17 +36,18 @@ public function supports(HasMethodInterface $message): bool return $message instanceof ListResourcesRequest; } + /** + * @throws InvalidCursorException + */ public function handle(ListResourcesRequest|HasMethodInterface $message, SessionInterface $session): Response { \assert($message instanceof ListResourcesRequest); - $cursor = null; - $resources = $this->registry->getResources($this->pageSize, $message->cursor); - $nextCursor = (null !== $cursor && \count($resources) === $this->pageSize) ? $cursor : null; + $page = $this->registry->getResources($this->pageSize, $message->cursor); return new Response( $message->getId(), - new ListResourcesResult($resources, $nextCursor), + new ListResourcesResult($page->references, $page->nextCursor), ); } } diff --git a/src/Server/RequestHandler/ListToolsHandler.php b/src/Server/RequestHandler/ListToolsHandler.php index e792b0f6..757eb742 100644 --- a/src/Server/RequestHandler/ListToolsHandler.php +++ b/src/Server/RequestHandler/ListToolsHandler.php @@ -12,6 +12,7 @@ namespace Mcp\Server\RequestHandler; use Mcp\Capability\Registry\ReferenceProviderInterface; +use Mcp\Exception\InvalidCursorException; use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ListToolsRequest; @@ -36,17 +37,18 @@ public function supports(HasMethodInterface $message): bool return $message instanceof ListToolsRequest; } + /** + * @throws InvalidCursorException When the cursor is invalid + */ public function handle(ListToolsRequest|HasMethodInterface $message, SessionInterface $session): Response { \assert($message instanceof ListToolsRequest); - $cursor = null; - $tools = $this->registry->getTools($this->pageSize, $message->cursor); - $nextCursor = (null !== $cursor && \count($tools) === $this->pageSize) ? $cursor : null; + $page = $this->registry->getTools($this->pageSize, $message->cursor); return new Response( $message->getId(), - new ListToolsResult($tools, $nextCursor), + new ListToolsResult($page->references, $page->nextCursor), ); } } diff --git a/src/Server/RequestHandler/Reference/Page.php b/src/Server/RequestHandler/Reference/Page.php new file mode 100644 index 00000000..b76aa0ba --- /dev/null +++ b/src/Server/RequestHandler/Reference/Page.php @@ -0,0 +1,52 @@ + + */ +final class Page implements \Countable, \ArrayAccess +{ + /** + * @param array $references Items can be Tool, Prompt, ResourceTemplate, or Resource + */ + public function __construct( + public readonly array $references, + public readonly ?string $nextCursor, + ) { + } + + public function count(): int + { + return \count($this->references); + } + + public function offsetExists(mixed $offset): bool + { + return isset($this->references[$offset]); + } + + public function offsetGet(mixed $offset): mixed + { + return $this->references[$offset] ?? null; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + return; + } + + public function offsetUnset(mixed $offset): void + { + return; + } +} diff --git a/tests/Capability/Discovery/DiscoveryTest.php b/tests/Capability/Discovery/DiscoveryTest.php index c6ab3e8a..71c876a7 100644 --- a/tests/Capability/Discovery/DiscoveryTest.php +++ b/tests/Capability/Discovery/DiscoveryTest.php @@ -135,7 +135,8 @@ public function testDoesNotDiscoverElementsFromExcludedDirectories() public function testHandlesEmptyDirectoriesOrDirectoriesWithNoPhpFiles() { $this->discoverer->discover(__DIR__, ['EmptyDir']); - $this->assertEmpty($this->registry->getTools()); + $tools = $this->registry->getTools(); + $this->assertEmpty($tools->references); } public function testCorrectlyInfersNamesAndDescriptionsFromMethodsOrClassesIfNotSetInAttribute() diff --git a/tests/Capability/Registry/RegistryProviderTest.php b/tests/Capability/Registry/RegistryProviderTest.php index 8669afab..604311be 100644 --- a/tests/Capability/Registry/RegistryProviderTest.php +++ b/tests/Capability/Registry/RegistryProviderTest.php @@ -161,10 +161,10 @@ public function testGetToolsReturnsAllRegisteredTools(): void $tools = $this->registry->getTools(); $this->assertCount(2, $tools); - $this->assertArrayHasKey('tool1', $tools); - $this->assertArrayHasKey('tool2', $tools); - $this->assertInstanceOf(Tool::class, $tools['tool1']); - $this->assertInstanceOf(Tool::class, $tools['tool2']); + $this->assertArrayHasKey('tool1', $tools->references); + $this->assertArrayHasKey('tool2', $tools->references); + $this->assertInstanceOf(Tool::class, $tools->references['tool1']); + $this->assertInstanceOf(Tool::class, $tools->references['tool2']); } public function testGetResourcesReturnsAllRegisteredResources(): void @@ -177,10 +177,10 @@ public function testGetResourcesReturnsAllRegisteredResources(): void $resources = $this->registry->getResources(); $this->assertCount(2, $resources); - $this->assertArrayHasKey('test://resource1', $resources); - $this->assertArrayHasKey('test://resource2', $resources); - $this->assertInstanceOf(Resource::class, $resources['test://resource1']); - $this->assertInstanceOf(Resource::class, $resources['test://resource2']); + $this->assertArrayHasKey('test://resource1', $resources->references); + $this->assertArrayHasKey('test://resource2', $resources->references); + $this->assertInstanceOf(Resource::class, $resources->references['test://resource1']); + $this->assertInstanceOf(Resource::class, $resources->references['test://resource2']); } public function testGetPromptsReturnsAllRegisteredPrompts(): void @@ -193,10 +193,10 @@ public function testGetPromptsReturnsAllRegisteredPrompts(): void $prompts = $this->registry->getPrompts(); $this->assertCount(2, $prompts); - $this->assertArrayHasKey('prompt1', $prompts); - $this->assertArrayHasKey('prompt2', $prompts); - $this->assertInstanceOf(Prompt::class, $prompts['prompt1']); - $this->assertInstanceOf(Prompt::class, $prompts['prompt2']); + $this->assertArrayHasKey('prompt1', $prompts->references); + $this->assertArrayHasKey('prompt2', $prompts->references); + $this->assertInstanceOf(Prompt::class, $prompts->references['prompt1']); + $this->assertInstanceOf(Prompt::class, $prompts->references['prompt2']); } public function testGetResourceTemplatesReturnsAllRegisteredTemplates(): void @@ -209,10 +209,10 @@ public function testGetResourceTemplatesReturnsAllRegisteredTemplates(): void $templates = $this->registry->getResourceTemplates(); $this->assertCount(2, $templates); - $this->assertArrayHasKey('test1://{id}', $templates); - $this->assertArrayHasKey('test2://{category}', $templates); - $this->assertInstanceOf(ResourceTemplate::class, $templates['test1://{id}']); - $this->assertInstanceOf(ResourceTemplate::class, $templates['test2://{category}']); + $this->assertArrayHasKey('test1://{id}', $templates->references); + $this->assertArrayHasKey('test2://{category}', $templates->references); + $this->assertInstanceOf(ResourceTemplate::class, $templates->references['test1://{id}']); + $this->assertInstanceOf(ResourceTemplate::class, $templates->references['test2://{category}']); } public function testHasElementsReturnsFalseForEmptyRegistry(): void diff --git a/tests/Server/RequestHandler/ListPromptsHandlerTest.php b/tests/Server/RequestHandler/ListPromptsHandlerTest.php new file mode 100644 index 00000000..b10d3e56 --- /dev/null +++ b/tests/Server/RequestHandler/ListPromptsHandlerTest.php @@ -0,0 +1,247 @@ +registry = new Registry(); + $this->handler = new ListPromptsHandler($this->registry, pageSize: 3); // Use small page size for testing + $this->session = new Session(new InMemorySessionStore()); + } + + #[TestDox('Returns first page when no cursor provided')] + public function testReturnsFirstPageWhenNoCursorProvided(): void + { + // Arrange + $this->addPromptsToRegistry(5); + $request = $this->createListPromptsRequest(); + + // Act + $response = $this->handler->handle($request, $this->session); + + // Assert + /** @var ListPromptsResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListPromptsResult::class, $result); + $this->assertCount(3, $result->prompts); + $this->assertNotNull($result->nextCursor); + + $this->assertEquals('prompt_0', $result->prompts[0]->name); + $this->assertEquals('prompt_1', $result->prompts[1]->name); + $this->assertEquals('prompt_2', $result->prompts[2]->name); + } + + #[TestDox('Returns paginated prompts with cursor')] + public function testReturnsPaginatedPromptsWithCursor(): void + { + // Arrange + $this->addPromptsToRegistry(10); + $request = $this->createListPromptsRequest(cursor: null); + + // Act + $response = $this->handler->handle($request, $this->session); + + // Assert + /** @var ListPromptsResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListPromptsResult::class, $result); + $this->assertCount(3, $result->prompts); + $this->assertNotNull($result->nextCursor); + + $this->assertEquals('prompt_0', $result->prompts[0]->name); + $this->assertEquals('prompt_1', $result->prompts[1]->name); + $this->assertEquals('prompt_2', $result->prompts[2]->name); + } + + #[TestDox('Returns second page with cursor')] + public function testReturnsSecondPageWithCursor(): void + { + // Arrange + $this->addPromptsToRegistry(10); + $firstPageRequest = $this->createListPromptsRequest(); + $firstPageResponse = $this->handler->handle($firstPageRequest, $this->session); + + /** @var ListPromptsResult $firstPageResult */ + $firstPageResult = $firstPageResponse->result; + $secondPageRequest = $this->createListPromptsRequest(cursor: $firstPageResult->nextCursor); + + // Act + $response = $this->handler->handle($secondPageRequest, $this->session); + + // Assert + /** @var ListPromptsResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListPromptsResult::class, $result); + $this->assertCount(3, $result->prompts); + $this->assertNotNull($result->nextCursor); + + $this->assertEquals('prompt_3', $result->prompts[0]->name); + $this->assertEquals('prompt_4', $result->prompts[1]->name); + $this->assertEquals('prompt_5', $result->prompts[2]->name); + } + + #[TestDox('Returns last page with null cursor')] + public function testReturnsLastPageWithNullCursor(): void + { + // Arrange + $this->addPromptsToRegistry(5); + $firstPageRequest = $this->createListPromptsRequest(); + $firstPageResponse = $this->handler->handle($firstPageRequest, $this->session); + + /** @var ListPromptsResult $firstPageResult */ + $firstPageResult = $firstPageResponse->result; + $secondPageRequest = $this->createListPromptsRequest(cursor: $firstPageResult->nextCursor); + + // Act + $response = $this->handler->handle($secondPageRequest, $this->session); + + // Assert + /** @var ListPromptsResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListPromptsResult::class, $result); + $this->assertCount(2, $result->prompts); + $this->assertNull($result->nextCursor); + + $this->assertEquals('prompt_3', $result->prompts[0]->name); + $this->assertEquals('prompt_4', $result->prompts[1]->name); + } + + #[TestDox('Handles empty registry')] + public function testHandlesEmptyRegistry(): void + { + // Arrange + $request = $this->createListPromptsRequest(); + + // Act + $response = $this->handler->handle($request, $this->session); + + // Assert + /** @var ListPromptsResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListPromptsResult::class, $result); + $this->assertCount(0, $result->prompts); + $this->assertNull($result->nextCursor); + } + + #[TestDox('Throws exception for invalid cursor')] + public function testThrowsExceptionForInvalidCursor(): void + { + // Arrange + $this->addPromptsToRegistry(5); + $request = $this->createListPromptsRequest(cursor: 'invalid-cursor'); + + // Assert + $this->expectException(InvalidCursorException::class); + + // Act + $this->handler->handle($request, $this->session); + } + + #[TestDox('Throws exception for cursor beyond bounds')] + public function testThrowsExceptionForCursorBeyondBounds(): void + { + // Arrange + $this->addPromptsToRegistry(5); + $outOfBoundsCursor = base64_encode('1000'); + $request = $this->createListPromptsRequest(cursor: $outOfBoundsCursor); + + // Assert + $this->expectException(InvalidCursorException::class); + + // Act + $this->handler->handle($request, $this->session); + } + + #[TestDox('Handles cursor at exact boundary')] + public function testHandlesCursorAtExactBoundary(): void + { + // Arrange + $this->addPromptsToRegistry(6); + $exactBoundaryCursor = base64_encode('6'); // Exactly at the end + $request = $this->createListPromptsRequest(cursor: $exactBoundaryCursor); + + // Act + $response = $this->handler->handle($request, $this->session); + + // Assert + /** @var ListPromptsResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListPromptsResult::class, $result); + $this->assertCount(0, $result->prompts); + $this->assertNull($result->nextCursor); + } + + #[TestDox('Maintains stable cursors across calls')] + public function testMaintainsStableCursorsAcrossCalls(): void + { + // Arrange + $this->addPromptsToRegistry(10); + + // Act + $request = $this->createListPromptsRequest(); + $response1 = $this->handler->handle($request, $this->session); + $response2 = $this->handler->handle($request, $this->session); + + // Assert + /** @var ListPromptsResult $result1 */ + $result1 = $response1->result; + /** @var ListPromptsResult $result2 */ + $result2 = $response2->result; + $this->assertEquals($result1->nextCursor, $result2->nextCursor); + $this->assertEquals($result1->prompts, $result2->prompts); + } + + private function addPromptsToRegistry(int $count): void + { + for ($i = 0; $i < $count; ++$i) { + $prompt = new Prompt( + name: "prompt_$i", + description: "Test prompt $i" + ); + + $this->registry->registerPrompt($prompt, fn () => null); + } + } + + private function createListPromptsRequest(?string $cursor = null): ListPromptsRequest + { + $data = [ + 'jsonrpc' => '2.0', + 'id' => 'test-request-id', + 'method' => 'prompts/list', + ]; + + if (null !== $cursor) { + $data['params'] = ['cursor' => $cursor]; + } + + return ListPromptsRequest::fromArray($data); + } +} diff --git a/tests/Server/RequestHandler/ListResourcesHandlerTest.php b/tests/Server/RequestHandler/ListResourcesHandlerTest.php new file mode 100644 index 00000000..13044a73 --- /dev/null +++ b/tests/Server/RequestHandler/ListResourcesHandlerTest.php @@ -0,0 +1,248 @@ +registry = new Registry(); + $this->handler = new ListResourcesHandler($this->registry, pageSize: 3); // Use small page size for testing + $this->session = new Session(new InMemorySessionStore()); + } + + #[TestDox('Returns first page when no cursor provided')] + public function testReturnsFirstPageWhenNoCursorProvided(): void + { + // Arrange + $this->addResourcesToRegistry(5); + $request = $this->createListResourcesRequest(); + + // Act + $response = $this->handler->handle($request, $this->session); + + // Assert + /** @var ListResourcesResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListResourcesResult::class, $result); + $this->assertCount(3, $result->resources); + $this->assertNotNull($result->nextCursor); + + $this->assertEquals('resource://test/resource_0', $result->resources[0]->uri); + $this->assertEquals('resource://test/resource_1', $result->resources[1]->uri); + $this->assertEquals('resource://test/resource_2', $result->resources[2]->uri); + } + + #[TestDox('Returns paginated resources with cursor')] + public function testReturnsPaginatedResourcesWithCursor(): void + { + // Arrange + $this->addResourcesToRegistry(10); + $request = $this->createListResourcesRequest(cursor: null); + + // Act + $response = $this->handler->handle($request, $this->session); + + // Assert + /** @var ListResourcesResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListResourcesResult::class, $result); + $this->assertCount(3, $result->resources); + $this->assertNotNull($result->nextCursor); + + $this->assertEquals('resource://test/resource_0', $result->resources[0]->uri); + $this->assertEquals('resource://test/resource_1', $result->resources[1]->uri); + $this->assertEquals('resource://test/resource_2', $result->resources[2]->uri); + } + + #[TestDox('Returns second page with cursor')] + public function testReturnsSecondPageWithCursor(): void + { + // Arrange + $this->addResourcesToRegistry(10); + $firstPageRequest = $this->createListResourcesRequest(); + $firstPageResponse = $this->handler->handle($firstPageRequest, $this->session); + + /** @var ListResourcesResult $firstPageResult */ + $firstPageResult = $firstPageResponse->result; + $secondPageRequest = $this->createListResourcesRequest(cursor: $firstPageResult->nextCursor); + + // Act + $response = $this->handler->handle($secondPageRequest, $this->session); + + // Assert + /** @var ListResourcesResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListResourcesResult::class, $result); + $this->assertCount(3, $result->resources); + $this->assertNotNull($result->nextCursor); + + $this->assertEquals('resource://test/resource_3', $result->resources[0]->uri); + $this->assertEquals('resource://test/resource_4', $result->resources[1]->uri); + $this->assertEquals('resource://test/resource_5', $result->resources[2]->uri); + } + + #[TestDox('Returns last page with null cursor')] + public function testReturnsLastPageWithNullCursor(): void + { + // Arrange + $this->addResourcesToRegistry(5); + $firstPageRequest = $this->createListResourcesRequest(); + $firstPageResponse = $this->handler->handle($firstPageRequest, $this->session); + + /** @var ListResourcesResult $firstPageResult */ + $firstPageResult = $firstPageResponse->result; + $secondPageRequest = $this->createListResourcesRequest(cursor: $firstPageResult->nextCursor); + + // Act + $response = $this->handler->handle($secondPageRequest, $this->session); + + // Assert + /** @var ListResourcesResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListResourcesResult::class, $result); + $this->assertCount(2, $result->resources); + $this->assertNull($result->nextCursor); + + $this->assertEquals('resource://test/resource_3', $result->resources[0]->uri); + $this->assertEquals('resource://test/resource_4', $result->resources[1]->uri); + } + + #[TestDox('Handles empty registry')] + public function testHandlesEmptyRegistry(): void + { + // Arrange + $request = $this->createListResourcesRequest(); + + // Act + $response = $this->handler->handle($request, $this->session); + + // Assert + /** @var ListResourcesResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListResourcesResult::class, $result); + $this->assertCount(0, $result->resources); + $this->assertNull($result->nextCursor); + } + + #[TestDox('Throws exception for invalid cursor')] + public function testThrowsExceptionForInvalidCursor(): void + { + // Arrange + $this->addResourcesToRegistry(5); + $request = $this->createListResourcesRequest(cursor: 'invalid-cursor'); + + // Assert + $this->expectException(InvalidCursorException::class); + + // Act + $this->handler->handle($request, $this->session); + } + + #[TestDox('Throws exception for cursor beyond bounds')] + public function testThrowsExceptionForCursorBeyondBounds(): void + { + // Arrange + $this->addResourcesToRegistry(5); + $outOfBoundsCursor = base64_encode('100'); + $request = $this->createListResourcesRequest(cursor: $outOfBoundsCursor); + + // Assert + $this->expectException(InvalidCursorException::class); + + // Act + $this->handler->handle($request, $this->session); + } + + #[TestDox('Handles cursor at exact boundary')] + public function testHandlesCursorAtExactBoundary(): void + { + // Arrange + $this->addResourcesToRegistry(6); + $exactBoundaryCursor = base64_encode('6'); + $request = $this->createListResourcesRequest(cursor: $exactBoundaryCursor); + + // Act + $response = $this->handler->handle($request, $this->session); + + // Assert + /** @var ListResourcesResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListResourcesResult::class, $result); + $this->assertCount(0, $result->resources); + $this->assertNull($result->nextCursor); + } + + #[TestDox('Maintains stable cursors across calls')] + public function testMaintainsStableCursorsAcrossCalls(): void + { + // Arrange + $this->addResourcesToRegistry(10); + + // Act + $request = $this->createListResourcesRequest(); + $response1 = $this->handler->handle($request, $this->session); + $response2 = $this->handler->handle($request, $this->session); + + // Assert + /** @var ListResourcesResult $result1 */ + $result1 = $response1->result; + /** @var ListResourcesResult $result2 */ + $result2 = $response2->result; + $this->assertEquals($result1->nextCursor, $result2->nextCursor); + $this->assertEquals($result1->resources, $result2->resources); + } + + private function addResourcesToRegistry(int $count): void + { + for ($i = 0; $i < $count; ++$i) { + $resource = new Resource( + uri: "resource://test/resource_$i", + name: "resource_$i", + description: "Test resource $i" + ); + // Use a simple callable as handler + $this->registry->registerResource($resource, fn () => null); + } + } + + private function createListResourcesRequest(?string $cursor = null): ListResourcesRequest + { + $data = [ + 'jsonrpc' => '2.0', + 'id' => 'test-request-id', + 'method' => 'resources/list', + ]; + + if (null !== $cursor) { + $data['params'] = ['cursor' => $cursor]; + } + + return ListResourcesRequest::fromArray($data); + } +} diff --git a/tests/Server/RequestHandler/ListToolsHandlerTest.php b/tests/Server/RequestHandler/ListToolsHandlerTest.php new file mode 100644 index 00000000..85ae9b63 --- /dev/null +++ b/tests/Server/RequestHandler/ListToolsHandlerTest.php @@ -0,0 +1,274 @@ +registry = new Registry(); + $this->handler = new ListToolsHandler($this->registry, pageSize: 3); // Use small page size for testing + $this->session = new Session(new InMemorySessionStore()); + } + + #[TestDox('Returns first page when no cursor provided')] + public function testReturnsFirstPageWhenNoCursorProvided(): void + { + // Arrange + $this->addToolsToRegistry(5); + $request = $this->createListToolsRequest(); + + // Act + $response = $this->handler->handle($request, $this->session); + + // Assert + /** @var ListToolsResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListToolsResult::class, $result); + $this->assertCount(3, $result->tools); + $this->assertNotNull($result->nextCursor); + + $this->assertEquals('tool_0', $result->tools[0]->name); + $this->assertEquals('tool_1', $result->tools[1]->name); + $this->assertEquals('tool_2', $result->tools[2]->name); + } + + #[TestDox('Returns paginated tools with cursor')] + public function testReturnsPaginatedToolsWithCursor(): void + { + // Arrange + $this->addToolsToRegistry(10); + $request = $this->createListToolsRequest(cursor: null); + + // Act + $response = $this->handler->handle($request, $this->session); + + // Assert + /** @var ListToolsResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListToolsResult::class, $result); + $this->assertCount(3, $result->tools); + $this->assertNotNull($result->nextCursor); + + $this->assertEquals('tool_0', $result->tools[0]->name); + $this->assertEquals('tool_1', $result->tools[1]->name); + $this->assertEquals('tool_2', $result->tools[2]->name); + } + + #[TestDox('Returns second page with cursor')] + public function testReturnsSecondPageWithCursor(): void + { + // Arrange + $this->addToolsToRegistry(10); + $firstPageRequest = $this->createListToolsRequest(); + $firstPageResponse = $this->handler->handle($firstPageRequest, $this->session); + + /** @var ListToolsResult $firstPageResult */ + $firstPageResult = $firstPageResponse->result; + $secondPageRequest = $this->createListToolsRequest(cursor: $firstPageResult->nextCursor); + + // Act + $response = $this->handler->handle($secondPageRequest, $this->session); + + // Assert + /** @var ListToolsResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListToolsResult::class, $result); + $this->assertCount(3, $result->tools); + $this->assertNotNull($result->nextCursor); + + $this->assertEquals('tool_3', $result->tools[0]->name); + $this->assertEquals('tool_4', $result->tools[1]->name); + $this->assertEquals('tool_5', $result->tools[2]->name); + } + + #[TestDox('Returns last page with null cursor')] + public function testReturnsLastPageWithNullCursor(): void + { + // Arrange + $this->addToolsToRegistry(5); + $firstPageRequest = $this->createListToolsRequest(); + $firstPageResponse = $this->handler->handle($firstPageRequest, $this->session); + + /** @var ListToolsResult $firstPageResult */ + $firstPageResult = $firstPageResponse->result; + $secondPageRequest = $this->createListToolsRequest(cursor: $firstPageResult->nextCursor); + + // Act + $response = $this->handler->handle($secondPageRequest, $this->session); + + // Assert + /** @var ListToolsResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListToolsResult::class, $result); + $this->assertCount(2, $result->tools); + $this->assertNull($result->nextCursor); + + $this->assertEquals('tool_3', $result->tools[0]->name); + $this->assertEquals('tool_4', $result->tools[1]->name); + } + + #[TestDox('Returns all tools when count is less than page size')] + public function testReturnsAllToolsWhenCountIsLessThanPageSize(): void + { + // Arrange + $this->addToolsToRegistry(2); // Less than page size (3) + $request = $this->createListToolsRequest(); + + // Act + $response = $this->handler->handle($request, $this->session); + + // Assert + /** @var ListToolsResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListToolsResult::class, $result); + $this->assertCount(2, $result->tools); + $this->assertNull($result->nextCursor); + + $this->assertEquals('tool_0', $result->tools[0]->name); + $this->assertEquals('tool_1', $result->tools[1]->name); + } + + #[TestDox('Handles empty registry')] + public function testHandlesEmptyRegistry(): void + { + // Arrange + $request = $this->createListToolsRequest(); + + // Act + $response = $this->handler->handle($request, $this->session); + + // Assert + /** @var ListToolsResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListToolsResult::class, $result); + $this->assertCount(0, $result->tools); + $this->assertNull($result->nextCursor); + } + + #[TestDox('Throws exception for invalid cursor')] + public function testThrowsExceptionForInvalidCursor(): void + { + // Arrange + $this->addToolsToRegistry(5); + $request = $this->createListToolsRequest(cursor: 'invalid-cursor'); + + // Assert + $this->expectException(InvalidCursorException::class); + + // Act + $this->handler->handle($request, $this->session); + } + + #[TestDox('Throws exception for cursor beyond bounds')] + public function testThrowsExceptionForCursorBeyondBounds(): void + { + // Arrange + $this->addToolsToRegistry(5); + $outOfBoundsCursor = base64_encode('100'); + $request = $this->createListToolsRequest(cursor: $outOfBoundsCursor); + + // Assert + $this->expectException(InvalidCursorException::class); + + // Act + $this->handler->handle($request, $this->session); + } + + #[TestDox('Handles cursor at exact boundary')] + public function testHandlesCursorAtExactBoundary(): void + { + // Arrange + $this->addToolsToRegistry(6); + $exactBoundaryCursor = base64_encode('6'); + $request = $this->createListToolsRequest(cursor: $exactBoundaryCursor); + + // Act + $response = $this->handler->handle($request, $this->session); + + // Assert + /** @var ListToolsResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListToolsResult::class, $result); + $this->assertCount(0, $result->tools); + $this->assertNull($result->nextCursor); + } + + #[TestDox('Maintains stable cursors across calls')] + public function testMaintainsStableCursorsAcrossCalls(): void + { + // Arrange + $this->addToolsToRegistry(10); + + // Act + $request = $this->createListToolsRequest(); + $response1 = $this->handler->handle($request, $this->session); + $response2 = $this->handler->handle($request, $this->session); + + // Assert + /** @var ListToolsResult $result1 */ + $result1 = $response1->result; + /** @var ListToolsResult $result2 */ + $result2 = $response2->result; + $this->assertEquals($result1->nextCursor, $result2->nextCursor); + $this->assertEquals($result1->tools, $result2->tools); + } + + private function addToolsToRegistry(int $count): void + { + for ($i = 0; $i < $count; ++$i) { + $tool = new Tool( + name: "tool_$i", + inputSchema: [ + 'type' => 'object', + 'properties' => [], + 'required' => [], + ], + description: "Test tool $i", + annotations: null + ); + + $this->registry->registerTool($tool, fn () => null); + } + } + + private function createListToolsRequest(?string $cursor = null): ListToolsRequest + { + $data = [ + 'jsonrpc' => '2.0', + 'id' => 'test-request-id', + 'method' => 'tools/list', + ]; + + if (null !== $cursor) { + $data['params'] = ['cursor' => $cursor]; + } + + return ListToolsRequest::fromArray($data); + } +}