Skip to content

Commit 2efb9af

Browse files
committed
Add request handler for resource templates
1 parent 41486bf commit 2efb9af

File tree

3 files changed

+275
-0
lines changed

3 files changed

+275
-0
lines changed

src/Server/Handler/JsonRpcHandler.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ public static function make(
8686
new Handler\Request\GetPromptHandler($promptGetter),
8787
new Handler\Request\ListResourcesHandler($referenceProvider, $paginationLimit),
8888
new Handler\Request\ReadResourceHandler($resourceReader),
89+
new Handler\Request\ListResourceTemplatesHandler($referenceProvider, $paginationLimit),
8990
new Handler\Request\CallToolHandler($toolCaller, $logger),
9091
new Handler\Request\ListToolsHandler($referenceProvider, $paginationLimit),
9192
],
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Mcp\Server\Handler\Request;
13+
14+
use Mcp\Capability\Registry\ReferenceProviderInterface;
15+
use Mcp\Exception\InvalidCursorException;
16+
use Mcp\Schema\JsonRpc\HasMethodInterface;
17+
use Mcp\Schema\JsonRpc\Response;
18+
use Mcp\Schema\Request\ListResourcesRequest;
19+
use Mcp\Schema\Request\ListResourceTemplatesRequest;
20+
use Mcp\Schema\Result\ListResourcesResult;
21+
use Mcp\Schema\Result\ListResourceTemplatesResult;
22+
use Mcp\Server\Handler\MethodHandlerInterface;
23+
use Mcp\Server\Session\SessionInterface;
24+
25+
/**
26+
* @author Christopher Hertel <[email protected]>
27+
*/
28+
final class ListResourceTemplatesHandler implements MethodHandlerInterface
29+
{
30+
public function __construct(
31+
private readonly ReferenceProviderInterface $registry,
32+
private readonly int $pageSize = 20,
33+
) {
34+
}
35+
36+
public function supports(HasMethodInterface $message): bool
37+
{
38+
return $message instanceof ListResourceTemplatesRequest;
39+
}
40+
41+
/**
42+
* @throws InvalidCursorException
43+
*/
44+
public function handle(ListResourceTemplatesRequest|HasMethodInterface $message, SessionInterface $session): Response
45+
{
46+
\assert($message instanceof ListResourceTemplatesRequest);
47+
48+
$page = $this->registry->getResourceTemplates($this->pageSize, $message->cursor);
49+
50+
return new Response(
51+
$message->getId(),
52+
new ListResourceTemplatesResult($page->references, $page->nextCursor),
53+
);
54+
}
55+
}
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Mcp\Tests\Unit\Server\Handler\Request;
13+
14+
use Mcp\Capability\Registry;
15+
use Mcp\Exception\InvalidCursorException;
16+
use Mcp\Schema\Request\ListResourceTemplatesRequest;
17+
use Mcp\Schema\ResourceTemplate;
18+
use Mcp\Schema\Result\ListResourcesResult;
19+
use Mcp\Schema\Result\ListResourceTemplatesResult;
20+
use Mcp\Server\Handler\Request\ListResourceTemplatesHandler;
21+
use Mcp\Server\Session\InMemorySessionStore;
22+
use Mcp\Server\Session\Session;
23+
use Mcp\Server\Session\SessionInterface;
24+
use PHPUnit\Framework\Attributes\TestDox;
25+
use PHPUnit\Framework\TestCase;
26+
27+
class ListResourceTemplatesHandlerTest extends TestCase
28+
{
29+
private Registry $registry;
30+
private ListResourceTemplatesHandler $handler;
31+
private SessionInterface $session;
32+
33+
protected function setUp(): void
34+
{
35+
$this->registry = new Registry();
36+
$this->handler = new ListResourceTemplatesHandler($this->registry, pageSize: 3);
37+
$this->session = new Session(new InMemorySessionStore());
38+
}
39+
40+
public function testReturnsFirstPageWhenNoCursorProvided(): void
41+
{
42+
// Arrange
43+
$this->addResourcesToRegistry(5);
44+
$request = $this->createListResourcesRequest();
45+
46+
// Act
47+
$response = $this->handler->handle($request, $this->session);
48+
49+
// Assert
50+
/** @var ListResourceTemplatesResult $result */
51+
$result = $response->result;
52+
$this->assertInstanceOf(ListResourceTemplatesResult::class, $result);
53+
$this->assertCount(3, $result->resourceTemplates);
54+
$this->assertNotNull($result->nextCursor);
55+
56+
$this->assertEquals('resource://{test}/resource_0', $result->resourceTemplates[0]->uriTemplate);
57+
$this->assertEquals('resource://{test}/resource_1', $result->resourceTemplates[1]->uriTemplate);
58+
$this->assertEquals('resource://{test}/resource_2', $result->resourceTemplates[2]->uriTemplate);
59+
}
60+
61+
public function testReturnsSecondPageWithCursor(): void
62+
{
63+
// Arrange
64+
$this->addResourcesToRegistry(10);
65+
$firstPageRequest = $this->createListResourcesRequest();
66+
$firstPageResponse = $this->handler->handle($firstPageRequest, $this->session);
67+
68+
/** @var ListResourceTemplatesResult $firstPageResult */
69+
$firstPageResult = $firstPageResponse->result;
70+
$secondPageRequest = $this->createListResourcesRequest(cursor: $firstPageResult->nextCursor);
71+
72+
// Act
73+
$response = $this->handler->handle($secondPageRequest, $this->session);
74+
75+
// Assert
76+
/** @var ListResourceTemplatesResult $result */
77+
$result = $response->result;
78+
$this->assertInstanceOf(ListResourceTemplatesResult::class, $result);
79+
$this->assertCount(3, $result->resourceTemplates);
80+
$this->assertNotNull($result->nextCursor);
81+
82+
$this->assertEquals('resource://{test}/resource_3', $result->resourceTemplates[0]->uriTemplate);
83+
$this->assertEquals('resource://{test}/resource_4', $result->resourceTemplates[1]->uriTemplate);
84+
$this->assertEquals('resource://{test}/resource_5', $result->resourceTemplates[2]->uriTemplate);
85+
}
86+
87+
public function testReturnsLastPageWithNullCursor(): void
88+
{
89+
// Arrange
90+
$this->addResourcesToRegistry(5);
91+
$firstPageRequest = $this->createListResourcesRequest();
92+
$firstPageResponse = $this->handler->handle($firstPageRequest, $this->session);
93+
94+
/** @var ListResourceTemplatesResult $firstPageResult */
95+
$firstPageResult = $firstPageResponse->result;
96+
$secondPageRequest = $this->createListResourcesRequest(cursor: $firstPageResult->nextCursor);
97+
98+
// Act
99+
$response = $this->handler->handle($secondPageRequest, $this->session);
100+
101+
// Assert
102+
/** @var ListResourceTemplatesResult $result */
103+
$result = $response->result;
104+
$this->assertInstanceOf(ListResourceTemplatesResult::class, $result);
105+
$this->assertCount(2, $result->resourceTemplates);
106+
$this->assertNull($result->nextCursor);
107+
108+
$this->assertEquals('resource://{test}/resource_3', $result->resourceTemplates[0]->uriTemplate);
109+
$this->assertEquals('resource://{test}/resource_4', $result->resourceTemplates[1]->uriTemplate);
110+
}
111+
112+
public function testHandlesEmptyRegistry(): void
113+
{
114+
// Arrange
115+
$request = $this->createListResourcesRequest();
116+
117+
// Act
118+
$response = $this->handler->handle($request, $this->session);
119+
120+
// Assert
121+
/** @var ListResourceTemplatesResult $result */
122+
$result = $response->result;
123+
$this->assertInstanceOf(ListResourceTemplatesResult::class, $result);
124+
$this->assertCount(0, $result->resourceTemplates);
125+
$this->assertNull($result->nextCursor);
126+
}
127+
128+
public function testThrowsExceptionForInvalidCursor(): void
129+
{
130+
// Arrange
131+
$this->addResourcesToRegistry(5);
132+
$request = $this->createListResourcesRequest(cursor: 'invalid-cursor');
133+
134+
// Assert
135+
$this->expectException(InvalidCursorException::class);
136+
137+
// Act
138+
$this->handler->handle($request, $this->session);
139+
}
140+
141+
public function testThrowsExceptionForCursorBeyondBounds(): void
142+
{
143+
// Arrange
144+
$this->addResourcesToRegistry(5);
145+
$outOfBoundsCursor = base64_encode('100');
146+
$request = $this->createListResourcesRequest(cursor: $outOfBoundsCursor);
147+
148+
// Assert
149+
$this->expectException(InvalidCursorException::class);
150+
151+
// Act
152+
$this->handler->handle($request, $this->session);
153+
}
154+
155+
public function testHandlesCursorAtExactBoundary(): void
156+
{
157+
// Arrange
158+
$this->addResourcesToRegistry(6);
159+
$exactBoundaryCursor = base64_encode('6');
160+
$request = $this->createListResourcesRequest(cursor: $exactBoundaryCursor);
161+
162+
// Act
163+
$response = $this->handler->handle($request, $this->session);
164+
165+
// Assert
166+
/** @var ListResourceTemplatesResult $result */
167+
$result = $response->result;
168+
$this->assertInstanceOf(ListResourceTemplatesResult::class, $result);
169+
$this->assertCount(0, $result->resourceTemplates);
170+
$this->assertNull($result->nextCursor);
171+
}
172+
173+
public function testMaintainsStableCursorsAcrossCalls(): void
174+
{
175+
// Arrange
176+
$this->addResourcesToRegistry(10);
177+
178+
// Act
179+
$request = $this->createListResourcesRequest();
180+
$response1 = $this->handler->handle($request, $this->session);
181+
$response2 = $this->handler->handle($request, $this->session);
182+
183+
// Assert
184+
/** @var ListResourceTemplatesResult $result1 */
185+
$result1 = $response1->result;
186+
/** @var ListResourceTemplatesResult $result2 */
187+
$result2 = $response2->result;
188+
$this->assertEquals($result1->nextCursor, $result2->nextCursor);
189+
$this->assertEquals($result1->resourceTemplates, $result2->resourceTemplates);
190+
}
191+
192+
private function addResourcesToRegistry(int $count): void
193+
{
194+
for ($i = 0; $i < $count; ++$i) {
195+
$resourceTemplate = new ResourceTemplate(
196+
uriTemplate: "resource://{test}/resource_$i",
197+
name: "resource_$i",
198+
description: "Test resource $i"
199+
);
200+
// Use a simple callable as handler
201+
$this->registry->registerResourceTemplate($resourceTemplate, fn () => null);
202+
}
203+
}
204+
205+
private function createListResourcesRequest(?string $cursor = null): ListResourceTemplatesRequest
206+
{
207+
$data = [
208+
'jsonrpc' => '2.0',
209+
'id' => 'test-request-id',
210+
'method' => 'resources/list',
211+
];
212+
213+
if (null !== $cursor) {
214+
$data['params'] = ['cursor' => $cursor];
215+
}
216+
217+
return ListResourceTemplatesRequest::fromArray($data);
218+
}
219+
}

0 commit comments

Comments
 (0)