Skip to content

Commit fc17966

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

File tree

6 files changed

+285
-1
lines changed

6 files changed

+285
-1
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: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
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\ListResourceTemplatesRequest;
19+
use Mcp\Schema\Result\ListResourceTemplatesResult;
20+
use Mcp\Server\Handler\MethodHandlerInterface;
21+
use Mcp\Server\Session\SessionInterface;
22+
23+
/**
24+
* @author Christopher Hertel <[email protected]>
25+
*/
26+
final class ListResourceTemplatesHandler implements MethodHandlerInterface
27+
{
28+
public function __construct(
29+
private readonly ReferenceProviderInterface $registry,
30+
private readonly int $pageSize = 20,
31+
) {
32+
}
33+
34+
public function supports(HasMethodInterface $message): bool
35+
{
36+
return $message instanceof ListResourceTemplatesRequest;
37+
}
38+
39+
/**
40+
* @throws InvalidCursorException
41+
*/
42+
public function handle(ListResourceTemplatesRequest|HasMethodInterface $message, SessionInterface $session): Response
43+
{
44+
\assert($message instanceof ListResourceTemplatesRequest);
45+
46+
$page = $this->registry->getResourceTemplates($this->pageSize, $message->cursor);
47+
48+
return new Response(
49+
$message->getId(),
50+
new ListResourceTemplatesResult($page->references, $page->nextCursor),
51+
);
52+
}
53+
}

tests/Inspector/InspectorSnapshotTestCase.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ protected static function provideListMethods(): array
5454
return [
5555
'Prompt Listing' => ['method' => 'prompts/list'],
5656
'Resource Listing' => ['method' => 'resources/list'],
57-
// 'Resource Template Listing' => ['method' => 'resources/templates/list'],
57+
'Resource Template Listing' => ['method' => 'resources/templates/list'],
5858
'Tool Listing' => ['method' => 'tools/list'],
5959
];
6060
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"resourceTemplates": [
3+
{
4+
"name": "get_item_details",
5+
"uriTemplate": "item://{itemId}/details",
6+
"description": "A manually registered resource template.",
7+
"mimeType": "application/json"
8+
}
9+
]
10+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"resourceTemplates": []
3+
}
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
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\ListResourceTemplatesResult;
19+
use Mcp\Server\Handler\Request\ListResourceTemplatesHandler;
20+
use Mcp\Server\Session\InMemorySessionStore;
21+
use Mcp\Server\Session\Session;
22+
use Mcp\Server\Session\SessionInterface;
23+
use PHPUnit\Framework\TestCase;
24+
25+
class ListResourceTemplatesHandlerTest extends TestCase
26+
{
27+
private Registry $registry;
28+
private ListResourceTemplatesHandler $handler;
29+
private SessionInterface $session;
30+
31+
protected function setUp(): void
32+
{
33+
$this->registry = new Registry();
34+
$this->handler = new ListResourceTemplatesHandler($this->registry, pageSize: 3);
35+
$this->session = new Session(new InMemorySessionStore());
36+
}
37+
38+
public function testReturnsFirstPageWhenNoCursorProvided(): void
39+
{
40+
// Arrange
41+
$this->addResourcesToRegistry(5);
42+
$request = $this->createListResourcesRequest();
43+
44+
// Act
45+
$response = $this->handler->handle($request, $this->session);
46+
47+
// Assert
48+
/** @var ListResourceTemplatesResult $result */
49+
$result = $response->result;
50+
$this->assertInstanceOf(ListResourceTemplatesResult::class, $result);
51+
$this->assertCount(3, $result->resourceTemplates);
52+
$this->assertNotNull($result->nextCursor);
53+
54+
$this->assertEquals('resource://{test}/resource_0', $result->resourceTemplates[0]->uriTemplate);
55+
$this->assertEquals('resource://{test}/resource_1', $result->resourceTemplates[1]->uriTemplate);
56+
$this->assertEquals('resource://{test}/resource_2', $result->resourceTemplates[2]->uriTemplate);
57+
}
58+
59+
public function testReturnsSecondPageWithCursor(): void
60+
{
61+
// Arrange
62+
$this->addResourcesToRegistry(10);
63+
$firstPageRequest = $this->createListResourcesRequest();
64+
$firstPageResponse = $this->handler->handle($firstPageRequest, $this->session);
65+
66+
/** @var ListResourceTemplatesResult $firstPageResult */
67+
$firstPageResult = $firstPageResponse->result;
68+
$secondPageRequest = $this->createListResourcesRequest(cursor: $firstPageResult->nextCursor);
69+
70+
// Act
71+
$response = $this->handler->handle($secondPageRequest, $this->session);
72+
73+
// Assert
74+
/** @var ListResourceTemplatesResult $result */
75+
$result = $response->result;
76+
$this->assertInstanceOf(ListResourceTemplatesResult::class, $result);
77+
$this->assertCount(3, $result->resourceTemplates);
78+
$this->assertNotNull($result->nextCursor);
79+
80+
$this->assertEquals('resource://{test}/resource_3', $result->resourceTemplates[0]->uriTemplate);
81+
$this->assertEquals('resource://{test}/resource_4', $result->resourceTemplates[1]->uriTemplate);
82+
$this->assertEquals('resource://{test}/resource_5', $result->resourceTemplates[2]->uriTemplate);
83+
}
84+
85+
public function testReturnsLastPageWithNullCursor(): void
86+
{
87+
// Arrange
88+
$this->addResourcesToRegistry(5);
89+
$firstPageRequest = $this->createListResourcesRequest();
90+
$firstPageResponse = $this->handler->handle($firstPageRequest, $this->session);
91+
92+
/** @var ListResourceTemplatesResult $firstPageResult */
93+
$firstPageResult = $firstPageResponse->result;
94+
$secondPageRequest = $this->createListResourcesRequest(cursor: $firstPageResult->nextCursor);
95+
96+
// Act
97+
$response = $this->handler->handle($secondPageRequest, $this->session);
98+
99+
// Assert
100+
/** @var ListResourceTemplatesResult $result */
101+
$result = $response->result;
102+
$this->assertInstanceOf(ListResourceTemplatesResult::class, $result);
103+
$this->assertCount(2, $result->resourceTemplates);
104+
$this->assertNull($result->nextCursor);
105+
106+
$this->assertEquals('resource://{test}/resource_3', $result->resourceTemplates[0]->uriTemplate);
107+
$this->assertEquals('resource://{test}/resource_4', $result->resourceTemplates[1]->uriTemplate);
108+
}
109+
110+
public function testHandlesEmptyRegistry(): void
111+
{
112+
// Arrange
113+
$request = $this->createListResourcesRequest();
114+
115+
// Act
116+
$response = $this->handler->handle($request, $this->session);
117+
118+
// Assert
119+
/** @var ListResourceTemplatesResult $result */
120+
$result = $response->result;
121+
$this->assertInstanceOf(ListResourceTemplatesResult::class, $result);
122+
$this->assertCount(0, $result->resourceTemplates);
123+
$this->assertNull($result->nextCursor);
124+
}
125+
126+
public function testThrowsExceptionForInvalidCursor(): void
127+
{
128+
// Arrange
129+
$this->addResourcesToRegistry(5);
130+
$request = $this->createListResourcesRequest(cursor: 'invalid-cursor');
131+
132+
// Assert
133+
$this->expectException(InvalidCursorException::class);
134+
135+
// Act
136+
$this->handler->handle($request, $this->session);
137+
}
138+
139+
public function testThrowsExceptionForCursorBeyondBounds(): void
140+
{
141+
// Arrange
142+
$this->addResourcesToRegistry(5);
143+
$outOfBoundsCursor = base64_encode('100');
144+
$request = $this->createListResourcesRequest(cursor: $outOfBoundsCursor);
145+
146+
// Assert
147+
$this->expectException(InvalidCursorException::class);
148+
149+
// Act
150+
$this->handler->handle($request, $this->session);
151+
}
152+
153+
public function testHandlesCursorAtExactBoundary(): void
154+
{
155+
// Arrange
156+
$this->addResourcesToRegistry(6);
157+
$exactBoundaryCursor = base64_encode('6');
158+
$request = $this->createListResourcesRequest(cursor: $exactBoundaryCursor);
159+
160+
// Act
161+
$response = $this->handler->handle($request, $this->session);
162+
163+
// Assert
164+
/** @var ListResourceTemplatesResult $result */
165+
$result = $response->result;
166+
$this->assertInstanceOf(ListResourceTemplatesResult::class, $result);
167+
$this->assertCount(0, $result->resourceTemplates);
168+
$this->assertNull($result->nextCursor);
169+
}
170+
171+
public function testMaintainsStableCursorsAcrossCalls(): void
172+
{
173+
// Arrange
174+
$this->addResourcesToRegistry(10);
175+
176+
// Act
177+
$request = $this->createListResourcesRequest();
178+
$response1 = $this->handler->handle($request, $this->session);
179+
$response2 = $this->handler->handle($request, $this->session);
180+
181+
// Assert
182+
/** @var ListResourceTemplatesResult $result1 */
183+
$result1 = $response1->result;
184+
/** @var ListResourceTemplatesResult $result2 */
185+
$result2 = $response2->result;
186+
$this->assertEquals($result1->nextCursor, $result2->nextCursor);
187+
$this->assertEquals($result1->resourceTemplates, $result2->resourceTemplates);
188+
}
189+
190+
private function addResourcesToRegistry(int $count): void
191+
{
192+
for ($i = 0; $i < $count; ++$i) {
193+
$resourceTemplate = new ResourceTemplate(
194+
uriTemplate: "resource://{test}/resource_$i",
195+
name: "resource_$i",
196+
description: "Test resource $i"
197+
);
198+
// Use a simple callable as handler
199+
$this->registry->registerResourceTemplate($resourceTemplate, fn () => null);
200+
}
201+
}
202+
203+
private function createListResourcesRequest(?string $cursor = null): ListResourceTemplatesRequest
204+
{
205+
$data = [
206+
'jsonrpc' => '2.0',
207+
'id' => 'test-request-id',
208+
'method' => 'resources/list',
209+
];
210+
211+
if (null !== $cursor) {
212+
$data['params'] = ['cursor' => $cursor];
213+
}
214+
215+
return ListResourceTemplatesRequest::fromArray($data);
216+
}
217+
}

0 commit comments

Comments
 (0)