Skip to content

Commit 95be722

Browse files
committed
Add missing handler for resource subscribe and unsubscribe
1 parent 4c3e5e6 commit 95be722

File tree

10 files changed

+568
-3
lines changed

10 files changed

+568
-3
lines changed

.github/workflows/pipeline.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ jobs:
100100
passedTests=$(echo "$OUTPUT" | sed -nE 's/.*Total: ([0-9]+) passed.*/\1/p')
101101
passedTests=${passedTests:-0}
102102
103-
REQUIRED_TESTS_TO_PASS=22
103+
REQUIRED_TESTS_TO_PASS=25
104104
echo "Required tests to pass: $REQUIRED_TESTS_TO_PASS"
105105
[ "$passedTests" -ge "$REQUIRED_TESTS_TO_PASS" ] || exit $exit_code
106106

src/Capability/Registry.php

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,14 @@
2525
use Mcp\Exception\PromptNotFoundException;
2626
use Mcp\Exception\ResourceNotFoundException;
2727
use Mcp\Exception\ToolNotFoundException;
28+
use Mcp\Schema\Notification\ResourceUpdatedNotification;
2829
use Mcp\Schema\Page;
2930
use Mcp\Schema\Prompt;
3031
use Mcp\Schema\Resource;
3132
use Mcp\Schema\ResourceTemplate;
3233
use Mcp\Schema\Tool;
34+
use Mcp\Server\Protocol;
35+
use Mcp\Server\Session\SessionInterface;
3336
use Psr\EventDispatcher\EventDispatcherInterface;
3437
use Psr\Log\LoggerInterface;
3538
use Psr\Log\NullLogger;
@@ -61,6 +64,11 @@ final class Registry implements RegistryInterface
6164
*/
6265
private array $resourceTemplates = [];
6366

67+
/**
68+
* @var array<string, array<string, SessionInterface>>
69+
*/
70+
private array $resourceSubscriptions = [];
71+
6472
public function __construct(
6573
private readonly ?EventDispatcherInterface $eventDispatcher = null,
6674
private readonly LoggerInterface $logger = new NullLogger(),
@@ -449,4 +457,40 @@ private function paginateResults(array $items, int $limit, ?string $cursor = nul
449457

450458
return array_values(\array_slice($items, $offset, $limit));
451459
}
460+
461+
public function subscribe(SessionInterface $session, string $uri): void
462+
{
463+
if (!isset($this->resourceSubscriptions[$uri])) {
464+
$this->resourceSubscriptions[$uri] = [];
465+
}
466+
467+
$sessionId = $session->getId()->toRfc4122();
468+
$this->resourceSubscriptions[$uri][$sessionId] = $session;
469+
}
470+
471+
public function unsubscribe(SessionInterface $session, string $uri): void
472+
{
473+
if (!isset($this->resourceSubscriptions[$uri])) {
474+
return;
475+
}
476+
477+
$sessionId = $session->getId()->toRfc4122();
478+
479+
unset($this->resourceSubscriptions[$uri][$sessionId]);
480+
481+
if ([] === $this->resourceSubscriptions[$uri]) {
482+
unset($this->resourceSubscriptions[$uri]);
483+
}
484+
}
485+
486+
public function notifyResourceChanged(Protocol $protocol, string $uri): void
487+
{
488+
if (!isset($this->resourceSubscriptions[$uri])) {
489+
return;
490+
}
491+
492+
foreach ($this->resourceSubscriptions[$uri] as $session) {
493+
$protocol->sendNotification(new ResourceUpdatedNotification($uri), $session);
494+
}
495+
}
452496
}

src/Capability/RegistryInterface.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
use Mcp\Schema\Resource;
2626
use Mcp\Schema\ResourceTemplate;
2727
use Mcp\Schema\Tool;
28+
use Mcp\Server\Protocol;
29+
use Mcp\Server\Session\SessionInterface;
2830

2931
/**
3032
* @phpstan-import-type Handler from ElementReference
@@ -157,4 +159,20 @@ public function getPrompts(?int $limit = null, ?string $cursor = null): Page;
157159
* @throws PromptNotFoundException
158160
*/
159161
public function getPrompt(string $name): PromptReference;
162+
163+
/**
164+
* Subscribes a session to a specific resource URI.
165+
*/
166+
public function subscribe(SessionInterface $session, string $uri): void;
167+
168+
/**
169+
* Unsubscribes a session from a specific resource URI.
170+
*/
171+
public function unsubscribe(SessionInterface $session, string $uri): void;
172+
173+
/**
174+
* Notifies all sessions subscribed to the given resource URI that the
175+
* resource has changed. Sends a ResourceUpdatedNotification for each subscriber.
176+
*/
177+
public function notifyResourceChanged(Protocol $protocol, string $uri): void;
160178
}

src/Server/Builder.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -486,7 +486,7 @@ public function build(): Server
486486
tools: $registry->hasTools(),
487487
toolsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface,
488488
resources: $registry->hasResources() || $registry->hasResourceTemplates(),
489-
resourcesSubscribe: false,
489+
resourcesSubscribe: $registry->hasResources() || $registry->hasResourceTemplates(),
490490
resourcesListChanged: $this->eventDispatcher instanceof EventDispatcherInterface,
491491
prompts: $registry->hasPrompts(),
492492
promptsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface,
@@ -509,6 +509,8 @@ public function build(): Server
509509
new Handler\Request\ListToolsHandler($registry, $this->paginationLimit),
510510
new Handler\Request\PingHandler(),
511511
new Handler\Request\ReadResourceHandler($registry, $referenceHandler, $logger),
512+
new Handler\Request\ResourceSubscribeHandler($registry, $logger),
513+
new Handler\Request\ResourceUnsubscribeHandler($registry, $logger),
512514
new Handler\Request\SetLogLevelHandler(),
513515
]);
514516

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
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\RegistryInterface;
15+
use Mcp\Exception\ResourceNotFoundException;
16+
use Mcp\Schema\JsonRpc\Error;
17+
use Mcp\Schema\JsonRpc\Request;
18+
use Mcp\Schema\JsonRpc\Response;
19+
use Mcp\Schema\Request\ResourceSubscribeRequest;
20+
use Mcp\Schema\Result\EmptyResult;
21+
use Mcp\Server\Session\SessionInterface;
22+
use Psr\Log\LoggerInterface;
23+
use Psr\Log\NullLogger;
24+
25+
/**
26+
* @implements RequestHandlerInterface<EmptyResult>
27+
*
28+
* @author Larry Sule-balogun <[email protected]>
29+
*/
30+
final class ResourceSubscribeHandler implements RequestHandlerInterface
31+
{
32+
public function __construct(
33+
private readonly RegistryInterface $registry,
34+
private readonly LoggerInterface $logger = new NullLogger(),
35+
) {
36+
}
37+
38+
public function supports(Request $request): bool
39+
{
40+
return $request instanceof ResourceSubscribeRequest;
41+
}
42+
43+
public function handle(Request $request, SessionInterface $session): Response|Error
44+
{
45+
\assert($request instanceof ResourceSubscribeRequest);
46+
47+
$uri = $request->uri;
48+
49+
try {
50+
$this->registry->getResource($uri);
51+
} catch (ResourceNotFoundException $e) {
52+
$this->logger->error('Resource not found', ['uri' => $uri]);
53+
54+
return Error::forResourceNotFound($e->getMessage(), $request->getId());
55+
}
56+
57+
$this->logger->debug('Subscribing to resource', ['uri' => $uri]);
58+
59+
$this->registry->subscribe($session, $uri);
60+
61+
return new Response(
62+
$request->getId(),
63+
new EmptyResult(),
64+
);
65+
}
66+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
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\RegistryInterface;
15+
use Mcp\Exception\ResourceNotFoundException;
16+
use Mcp\Schema\JsonRpc\Error;
17+
use Mcp\Schema\JsonRpc\Request;
18+
use Mcp\Schema\JsonRpc\Response;
19+
use Mcp\Schema\Request\ResourceUnsubscribeRequest;
20+
use Mcp\Schema\Result\EmptyResult;
21+
use Mcp\Server\Session\SessionInterface;
22+
use Psr\Log\LoggerInterface;
23+
use Psr\Log\NullLogger;
24+
25+
/**
26+
* @implements RequestHandlerInterface<EmptyResult>
27+
*
28+
* @author Larry Sule-balogun <[email protected]>
29+
*/
30+
final class ResourceUnsubscribeHandler implements RequestHandlerInterface
31+
{
32+
public function __construct(
33+
private readonly RegistryInterface $registry,
34+
private readonly LoggerInterface $logger = new NullLogger(),
35+
) {
36+
}
37+
38+
public function supports(Request $request): bool
39+
{
40+
return $request instanceof ResourceUnsubscribeRequest;
41+
}
42+
43+
public function handle(Request $request, SessionInterface $session): Response|Error
44+
{
45+
\assert($request instanceof ResourceUnsubscribeRequest);
46+
47+
$uri = $request->uri;
48+
49+
try {
50+
$this->registry->getResource($uri);
51+
} catch (ResourceNotFoundException $e) {
52+
$this->logger->error('Resource not found', ['uri' => $uri]);
53+
54+
return Error::forResourceNotFound($e->getMessage(), $request->getId());
55+
}
56+
57+
$this->logger->debug('Unsubscribing from resource', ['uri' => $uri]);
58+
59+
$this->registry->unsubscribe($session, $uri);
60+
61+
return new Response(
62+
$request->getId(),
63+
new EmptyResult(),
64+
);
65+
}
66+
}

tests/Conformance/server.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,13 @@
99
* file that was distributed with this source code.
1010
*/
1111

12+
ini_set('display_errors', '0');
13+
1214
require_once dirname(__DIR__, 2).'/vendor/autoload.php';
1315

1416
use Http\Discovery\Psr17Factory;
1517
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
18+
use Mcp\Capability\Registry;
1619
use Mcp\Schema\Content\AudioContent;
1720
use Mcp\Schema\Content\EmbeddedResource;
1821
use Mcp\Schema\Content\ImageContent;
@@ -32,6 +35,7 @@
3235
$request = $psr17Factory->createServerRequestFromGlobals();
3336

3437
$transport = new StreamableHttpTransport($request, logger: $logger);
38+
$registry = new Registry(null, $logger);
3539

3640
$server = Server::builder()
3741
->setServerInfo('mcp-conformance-test-server', '1.0.0')
@@ -51,7 +55,6 @@
5155
->addResource(fn () => 'This is the content of the static text resource.', 'test://static-text', 'static-text', 'A static text resource for testing')
5256
->addResource(fn () => fopen('data://image/png;base64,'.Elements::TEST_IMAGE_BASE64, 'r'), 'test://static-binary', 'static-binary', 'A static binary resource (image) for testing')
5357
->addResourceTemplate([Elements::class, 'resourceTemplate'], 'test://template/{id}/data', 'template', 'A resource template with parameter substitution', 'application/json')
54-
// TODO: Handler for resources/subscribe and resources/unsubscribe
5558
->addResource(fn () => 'Watched resource content', 'test://watched-resource', 'watched-resource', 'A resource that can be watched')
5659
// Prompts
5760
->addPrompt(fn () => [['role' => 'user', 'content' => 'This is a simple prompt for testing.']], 'test_simple_prompt', 'A simple prompt without arguments')

0 commit comments

Comments
 (0)