From fc17966096dca00155296d2437b5f291854d15f4 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 29 Sep 2025 23:52:05 +0200 Subject: [PATCH] Add request handler for resource templates --- src/Server/Handler/JsonRpcHandler.php | 1 + .../Request/ListResourceTemplatesHandler.php | 53 +++++ tests/Inspector/InspectorSnapshotTestCase.php | 2 +- ...oExampleTest-resources_templates_list.json | 10 + ...rExampleTest-resources_templates_list.json | 3 + .../ListResourceTemplatesHandlerTest.php | 217 ++++++++++++++++++ 6 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 src/Server/Handler/Request/ListResourceTemplatesHandler.php create mode 100644 tests/Inspector/snapshots/ManualStdioExampleTest-resources_templates_list.json create mode 100644 tests/Inspector/snapshots/StdioCalculatorExampleTest-resources_templates_list.json create mode 100644 tests/Unit/Server/Handler/Request/ListResourceTemplatesHandlerTest.php diff --git a/src/Server/Handler/JsonRpcHandler.php b/src/Server/Handler/JsonRpcHandler.php index a29dc99..1ee39a2 100644 --- a/src/Server/Handler/JsonRpcHandler.php +++ b/src/Server/Handler/JsonRpcHandler.php @@ -86,6 +86,7 @@ public static function make( 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), ], diff --git a/src/Server/Handler/Request/ListResourceTemplatesHandler.php b/src/Server/Handler/Request/ListResourceTemplatesHandler.php new file mode 100644 index 0000000..eadd342 --- /dev/null +++ b/src/Server/Handler/Request/ListResourceTemplatesHandler.php @@ -0,0 +1,53 @@ + + */ +final class ListResourceTemplatesHandler implements MethodHandlerInterface +{ + public function __construct( + private readonly ReferenceProviderInterface $registry, + private readonly int $pageSize = 20, + ) { + } + + public function supports(HasMethodInterface $message): bool + { + return $message instanceof ListResourceTemplatesRequest; + } + + /** + * @throws InvalidCursorException + */ + public function handle(ListResourceTemplatesRequest|HasMethodInterface $message, SessionInterface $session): Response + { + \assert($message instanceof ListResourceTemplatesRequest); + + $page = $this->registry->getResourceTemplates($this->pageSize, $message->cursor); + + return new Response( + $message->getId(), + new ListResourceTemplatesResult($page->references, $page->nextCursor), + ); + } +} diff --git a/tests/Inspector/InspectorSnapshotTestCase.php b/tests/Inspector/InspectorSnapshotTestCase.php index bca8179..e576795 100644 --- a/tests/Inspector/InspectorSnapshotTestCase.php +++ b/tests/Inspector/InspectorSnapshotTestCase.php @@ -54,7 +54,7 @@ protected static function provideListMethods(): array return [ 'Prompt Listing' => ['method' => 'prompts/list'], 'Resource Listing' => ['method' => 'resources/list'], - // 'Resource Template Listing' => ['method' => 'resources/templates/list'], + 'Resource Template Listing' => ['method' => 'resources/templates/list'], 'Tool Listing' => ['method' => 'tools/list'], ]; } diff --git a/tests/Inspector/snapshots/ManualStdioExampleTest-resources_templates_list.json b/tests/Inspector/snapshots/ManualStdioExampleTest-resources_templates_list.json new file mode 100644 index 0000000..6588380 --- /dev/null +++ b/tests/Inspector/snapshots/ManualStdioExampleTest-resources_templates_list.json @@ -0,0 +1,10 @@ +{ + "resourceTemplates": [ + { + "name": "get_item_details", + "uriTemplate": "item://{itemId}/details", + "description": "A manually registered resource template.", + "mimeType": "application/json" + } + ] +} diff --git a/tests/Inspector/snapshots/StdioCalculatorExampleTest-resources_templates_list.json b/tests/Inspector/snapshots/StdioCalculatorExampleTest-resources_templates_list.json new file mode 100644 index 0000000..e867d9d --- /dev/null +++ b/tests/Inspector/snapshots/StdioCalculatorExampleTest-resources_templates_list.json @@ -0,0 +1,3 @@ +{ + "resourceTemplates": [] +} diff --git a/tests/Unit/Server/Handler/Request/ListResourceTemplatesHandlerTest.php b/tests/Unit/Server/Handler/Request/ListResourceTemplatesHandlerTest.php new file mode 100644 index 0000000..9d7c2ec --- /dev/null +++ b/tests/Unit/Server/Handler/Request/ListResourceTemplatesHandlerTest.php @@ -0,0 +1,217 @@ +registry = new Registry(); + $this->handler = new ListResourceTemplatesHandler($this->registry, pageSize: 3); + $this->session = new Session(new InMemorySessionStore()); + } + + public function testReturnsFirstPageWhenNoCursorProvided(): void + { + // Arrange + $this->addResourcesToRegistry(5); + $request = $this->createListResourcesRequest(); + + // Act + $response = $this->handler->handle($request, $this->session); + + // Assert + /** @var ListResourceTemplatesResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListResourceTemplatesResult::class, $result); + $this->assertCount(3, $result->resourceTemplates); + $this->assertNotNull($result->nextCursor); + + $this->assertEquals('resource://{test}/resource_0', $result->resourceTemplates[0]->uriTemplate); + $this->assertEquals('resource://{test}/resource_1', $result->resourceTemplates[1]->uriTemplate); + $this->assertEquals('resource://{test}/resource_2', $result->resourceTemplates[2]->uriTemplate); + } + + public function testReturnsSecondPageWithCursor(): void + { + // Arrange + $this->addResourcesToRegistry(10); + $firstPageRequest = $this->createListResourcesRequest(); + $firstPageResponse = $this->handler->handle($firstPageRequest, $this->session); + + /** @var ListResourceTemplatesResult $firstPageResult */ + $firstPageResult = $firstPageResponse->result; + $secondPageRequest = $this->createListResourcesRequest(cursor: $firstPageResult->nextCursor); + + // Act + $response = $this->handler->handle($secondPageRequest, $this->session); + + // Assert + /** @var ListResourceTemplatesResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListResourceTemplatesResult::class, $result); + $this->assertCount(3, $result->resourceTemplates); + $this->assertNotNull($result->nextCursor); + + $this->assertEquals('resource://{test}/resource_3', $result->resourceTemplates[0]->uriTemplate); + $this->assertEquals('resource://{test}/resource_4', $result->resourceTemplates[1]->uriTemplate); + $this->assertEquals('resource://{test}/resource_5', $result->resourceTemplates[2]->uriTemplate); + } + + public function testReturnsLastPageWithNullCursor(): void + { + // Arrange + $this->addResourcesToRegistry(5); + $firstPageRequest = $this->createListResourcesRequest(); + $firstPageResponse = $this->handler->handle($firstPageRequest, $this->session); + + /** @var ListResourceTemplatesResult $firstPageResult */ + $firstPageResult = $firstPageResponse->result; + $secondPageRequest = $this->createListResourcesRequest(cursor: $firstPageResult->nextCursor); + + // Act + $response = $this->handler->handle($secondPageRequest, $this->session); + + // Assert + /** @var ListResourceTemplatesResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListResourceTemplatesResult::class, $result); + $this->assertCount(2, $result->resourceTemplates); + $this->assertNull($result->nextCursor); + + $this->assertEquals('resource://{test}/resource_3', $result->resourceTemplates[0]->uriTemplate); + $this->assertEquals('resource://{test}/resource_4', $result->resourceTemplates[1]->uriTemplate); + } + + public function testHandlesEmptyRegistry(): void + { + // Arrange + $request = $this->createListResourcesRequest(); + + // Act + $response = $this->handler->handle($request, $this->session); + + // Assert + /** @var ListResourceTemplatesResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListResourceTemplatesResult::class, $result); + $this->assertCount(0, $result->resourceTemplates); + $this->assertNull($result->nextCursor); + } + + 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); + } + + 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); + } + + 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 ListResourceTemplatesResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListResourceTemplatesResult::class, $result); + $this->assertCount(0, $result->resourceTemplates); + $this->assertNull($result->nextCursor); + } + + 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 ListResourceTemplatesResult $result1 */ + $result1 = $response1->result; + /** @var ListResourceTemplatesResult $result2 */ + $result2 = $response2->result; + $this->assertEquals($result1->nextCursor, $result2->nextCursor); + $this->assertEquals($result1->resourceTemplates, $result2->resourceTemplates); + } + + private function addResourcesToRegistry(int $count): void + { + for ($i = 0; $i < $count; ++$i) { + $resourceTemplate = new ResourceTemplate( + uriTemplate: "resource://{test}/resource_$i", + name: "resource_$i", + description: "Test resource $i" + ); + // Use a simple callable as handler + $this->registry->registerResourceTemplate($resourceTemplate, fn () => null); + } + } + + private function createListResourcesRequest(?string $cursor = null): ListResourceTemplatesRequest + { + $data = [ + 'jsonrpc' => '2.0', + 'id' => 'test-request-id', + 'method' => 'resources/list', + ]; + + if (null !== $cursor) { + $data['params'] = ['cursor' => $cursor]; + } + + return ListResourceTemplatesRequest::fromArray($data); + } +}