Skip to content

Commit f40d08e

Browse files
committed
Add missing handler for resource subscribe and unsubscribe
1 parent 42fe143 commit f40d08e

File tree

14 files changed

+888
-10
lines changed

14 files changed

+888
-10
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: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,20 @@
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\SessionFactoryInterface;
36+
use Mcp\Server\Session\SessionInterface;
37+
use Mcp\Server\Session\SessionStoreInterface;
3338
use Psr\EventDispatcher\EventDispatcherInterface;
3439
use Psr\Log\LoggerInterface;
3540
use Psr\Log\NullLogger;
41+
use Psr\SimpleCache\InvalidArgumentException;
3642

3743
/**
3844
* Registry implementation that manages MCP element registration and access.
@@ -64,6 +70,8 @@ final class Registry implements RegistryInterface
6470
public function __construct(
6571
private readonly ?EventDispatcherInterface $eventDispatcher = null,
6672
private readonly LoggerInterface $logger = new NullLogger(),
73+
private readonly ?SessionStoreInterface $sessionStore = null,
74+
private readonly ?SessionFactoryInterface $sessionFactory = null,
6775
private readonly NameValidator $nameValidator = new NameValidator(),
6876
) {
6977
}
@@ -391,6 +399,64 @@ public function setDiscoveryState(DiscoveryState $state): void
391399
}
392400
}
393401

402+
/**
403+
* @throws InvalidArgumentException
404+
*/
405+
public function subscribe(SessionInterface $session, string $uri): void
406+
{
407+
$subscriptions = $session->get('resource_subscriptions', []);
408+
$subscriptions[$uri] = true;
409+
$session->set('resource_subscriptions', $subscriptions);
410+
$session->save();
411+
}
412+
413+
/**
414+
* @throws InvalidArgumentException
415+
*/
416+
public function unsubscribe(SessionInterface $session, string $uri): void
417+
{
418+
$subscriptions = $session->get('resource_subscriptions', []);
419+
unset($subscriptions[$uri]);
420+
$session->set('resource_subscriptions', $subscriptions);
421+
$session->save();
422+
}
423+
424+
public function notifyResourceChanged(Protocol $protocol, string $uri): void
425+
{
426+
if (!$this->sessionStore || !$this->sessionFactory) {
427+
$this->logger->warning('Cannot send resource notifications: session store or factory not configured.');
428+
429+
return;
430+
}
431+
432+
foreach ($this->sessionStore->getAllSessionIds() as $sessionId) {
433+
try {
434+
$sessionData = $this->sessionStore->read($sessionId);
435+
if (!$sessionData) {
436+
continue;
437+
}
438+
439+
$sessionArray = json_decode($sessionData, true);
440+
if (!\is_array($sessionArray)) {
441+
continue;
442+
}
443+
444+
if (!isset($sessionArray['resource_subscriptions'][$uri])) {
445+
continue;
446+
}
447+
448+
$session = $this->sessionFactory->createWithId($sessionId, $this->sessionStore);
449+
$protocol->sendNotification(new ResourceUpdatedNotification($uri), $session);
450+
} catch (\Throwable $e) {
451+
$this->logger->error('Error sending resource notification to session', [
452+
'session_id' => $sessionId->toRfc4122(),
453+
'uri' => $uri,
454+
'exception' => $e,
455+
]);
456+
}
457+
}
458+
}
459+
394460
/**
395461
* Calculate next cursor for pagination.
396462
*

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: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -488,7 +488,17 @@ public function build(): Server
488488
{
489489
$logger = $this->logger ?? new NullLogger();
490490
$container = $this->container ?? new Container();
491-
$registry = $this->registry ?? new Registry($this->eventDispatcher, $logger);
491+
492+
$sessionTtl = $this->sessionTtl ?? 3600;
493+
$sessionFactory = $this->sessionFactory ?? new SessionFactory();
494+
$sessionStore = $this->sessionStore ?? new InMemorySessionStore($sessionTtl);
495+
496+
$registry = $this->registry ?? new Registry(
497+
$this->eventDispatcher,
498+
$logger,
499+
$sessionStore,
500+
$sessionFactory
501+
);
492502

493503
$loaders = [
494504
...$this->loaders,
@@ -504,16 +514,13 @@ public function build(): Server
504514
$loader->load($registry);
505515
}
506516

507-
$sessionTtl = $this->sessionTtl ?? 3600;
508-
$sessionFactory = $this->sessionFactory ?? new SessionFactory();
509-
$sessionStore = $this->sessionStore ?? new InMemorySessionStore($sessionTtl);
510517
$messageFactory = MessageFactory::make();
511518

512519
$capabilities = $this->serverCapabilities ?? new ServerCapabilities(
513520
tools: $registry->hasTools(),
514521
toolsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface,
515522
resources: $registry->hasResources() || $registry->hasResourceTemplates(),
516-
resourcesSubscribe: false,
523+
resourcesSubscribe: $registry->hasResources() || $registry->hasResourceTemplates(),
517524
resourcesListChanged: $this->eventDispatcher instanceof EventDispatcherInterface,
518525
prompts: $registry->hasPrompts(),
519526
promptsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface,
@@ -536,6 +543,8 @@ public function build(): Server
536543
new Handler\Request\ListToolsHandler($registry, $this->paginationLimit),
537544
new Handler\Request\PingHandler(),
538545
new Handler\Request\ReadResourceHandler($registry, $referenceHandler, $logger),
546+
new Handler\Request\ResourceSubscribeHandler($registry, $logger),
547+
new Handler\Request\ResourceUnsubscribeHandler($registry, $logger),
539548
new Handler\Request\SetLogLevelHandler(),
540549
]);
541550

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 <suleabimbola@gmail.com>
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 <suleabimbola@gmail.com>
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+
}

src/Server/Session/FileSessionStore.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,44 @@ public function gc(): array
150150
return $deleted;
151151
}
152152

153+
public function getAllSessionIds(): array
154+
{
155+
$sessionIds = [];
156+
$now = $this->clock->now()->getTimestamp();
157+
158+
$dir = @opendir($this->directory);
159+
if (false === $dir) {
160+
return $sessionIds;
161+
}
162+
163+
while (($entry = readdir($dir)) !== false) {
164+
// Skip dot entries
165+
if ('.' === $entry || '..' === $entry) {
166+
continue;
167+
}
168+
169+
$path = $this->directory.\DIRECTORY_SEPARATOR.$entry;
170+
if (!is_file($path)) {
171+
continue;
172+
}
173+
174+
$mtime = @filemtime($path) ?: 0;
175+
if (($now - $mtime) > $this->ttl) {
176+
continue;
177+
}
178+
179+
try {
180+
$sessionIds[] = Uuid::fromString($entry);
181+
} catch (\Throwable) {
182+
// ignore non-UUID file names
183+
}
184+
}
185+
186+
closedir($dir);
187+
188+
return $sessionIds;
189+
}
190+
153191
private function pathFor(Uuid $id): string
154192
{
155193
return $this->directory.\DIRECTORY_SEPARATOR.$id->toRfc4122();

src/Server/Session/InMemorySessionStore.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,4 +87,14 @@ public function gc(): array
8787

8888
return $deletedSessions;
8989
}
90+
91+
public function getAllSessionIds(): array
92+
{
93+
$ids = [];
94+
foreach (array_keys($this->store) as $id) {
95+
$ids[] = Uuid::fromString($id);
96+
}
97+
98+
return $ids;
99+
}
90100
}

0 commit comments

Comments
 (0)