-
Notifications
You must be signed in to change notification settings - Fork 109
[Server] Add missing handler for resource subscribe/unsubscribe handlers #220
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,14 +25,20 @@ | |
| use Mcp\Exception\PromptNotFoundException; | ||
| use Mcp\Exception\ResourceNotFoundException; | ||
| use Mcp\Exception\ToolNotFoundException; | ||
| use Mcp\Schema\Notification\ResourceUpdatedNotification; | ||
| use Mcp\Schema\Page; | ||
| use Mcp\Schema\Prompt; | ||
| use Mcp\Schema\Resource; | ||
| use Mcp\Schema\ResourceTemplate; | ||
| use Mcp\Schema\Tool; | ||
| use Mcp\Server\Protocol; | ||
| use Mcp\Server\Session\SessionFactoryInterface; | ||
| use Mcp\Server\Session\SessionInterface; | ||
| use Mcp\Server\Session\SessionStoreInterface; | ||
| use Psr\EventDispatcher\EventDispatcherInterface; | ||
| use Psr\Log\LoggerInterface; | ||
| use Psr\Log\NullLogger; | ||
| use Psr\SimpleCache\InvalidArgumentException; | ||
|
|
||
| /** | ||
| * Registry implementation that manages MCP element registration and access. | ||
|
|
@@ -64,6 +70,8 @@ final class Registry implements RegistryInterface | |
| public function __construct( | ||
| private readonly ?EventDispatcherInterface $eventDispatcher = null, | ||
| private readonly LoggerInterface $logger = new NullLogger(), | ||
| private readonly ?SessionStoreInterface $sessionStore = null, | ||
| private readonly ?SessionFactoryInterface $sessionFactory = null, | ||
| private readonly NameValidator $nameValidator = new NameValidator(), | ||
| ) { | ||
| } | ||
|
|
@@ -391,6 +399,64 @@ public function setDiscoveryState(DiscoveryState $state): void | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * @throws InvalidArgumentException | ||
| */ | ||
| public function subscribe(SessionInterface $session, string $uri): void | ||
| { | ||
| $subscriptions = $session->get('resource_subscriptions', []); | ||
| $subscriptions[$uri] = true; | ||
| $session->set('resource_subscriptions', $subscriptions); | ||
| $session->save(); | ||
| } | ||
|
|
||
| /** | ||
| * @throws InvalidArgumentException | ||
| */ | ||
| public function unsubscribe(SessionInterface $session, string $uri): void | ||
| { | ||
| $subscriptions = $session->get('resource_subscriptions', []); | ||
| unset($subscriptions[$uri]); | ||
| $session->set('resource_subscriptions', $subscriptions); | ||
| $session->save(); | ||
| } | ||
|
|
||
| public function notifyResourceChanged(Protocol $protocol, string $uri): void | ||
| { | ||
| if (!$this->sessionStore || !$this->sessionFactory) { | ||
| $this->logger->warning('Cannot send resource notifications: session store or factory not configured.'); | ||
|
|
||
| return; | ||
| } | ||
|
|
||
| foreach ($this->sessionStore->getAllSessionIds() as $sessionId) { | ||
| try { | ||
| $sessionData = $this->sessionStore->read($sessionId); | ||
| if (!$sessionData) { | ||
| continue; | ||
| } | ||
|
|
||
| $sessionArray = json_decode($sessionData, true); | ||
| if (!\is_array($sessionArray)) { | ||
| continue; | ||
| } | ||
|
|
||
| if (!isset($sessionArray['resource_subscriptions'][$uri])) { | ||
| continue; | ||
| } | ||
|
|
||
| $session = $this->sessionFactory->createWithId($sessionId, $this->sessionStore); | ||
| $protocol->sendNotification(new ResourceUpdatedNotification($uri), $session); | ||
| } catch (\Throwable $e) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. please catch an explicit exception instead of a global one - better to be specific and let global exception handling happen in a global point |
||
| $this->logger->error('Error sending resource notification to session', [ | ||
| 'session_id' => $sessionId->toRfc4122(), | ||
| 'uri' => $uri, | ||
| 'exception' => $e, | ||
| ]); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Calculate next cursor for pagination. | ||
| * | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| <?php | ||
|
|
||
| /* | ||
| * This file is part of the official PHP MCP SDK. | ||
| * | ||
| * A collaboration between Symfony and the PHP Foundation. | ||
| * | ||
| * For the full copyright and license information, please view the LICENSE | ||
| * file that was distributed with this source code. | ||
| */ | ||
|
|
||
| namespace Mcp\Server\Handler\Request; | ||
|
|
||
| use Mcp\Capability\RegistryInterface; | ||
| use Mcp\Exception\ResourceNotFoundException; | ||
| use Mcp\Schema\JsonRpc\Error; | ||
| use Mcp\Schema\JsonRpc\Request; | ||
| use Mcp\Schema\JsonRpc\Response; | ||
| use Mcp\Schema\Request\ResourceSubscribeRequest; | ||
| use Mcp\Schema\Result\EmptyResult; | ||
| use Mcp\Server\Session\SessionInterface; | ||
| use Psr\Log\LoggerInterface; | ||
| use Psr\Log\NullLogger; | ||
|
|
||
| /** | ||
| * @implements RequestHandlerInterface<EmptyResult> | ||
| * | ||
| * @author Larry Sule-balogun <suleabimbola@gmail.com> | ||
| */ | ||
| final class ResourceSubscribeHandler implements RequestHandlerInterface | ||
| { | ||
| public function __construct( | ||
| private readonly RegistryInterface $registry, | ||
| private readonly LoggerInterface $logger = new NullLogger(), | ||
| ) { | ||
| } | ||
|
|
||
| public function supports(Request $request): bool | ||
| { | ||
| return $request instanceof ResourceSubscribeRequest; | ||
| } | ||
|
|
||
| public function handle(Request $request, SessionInterface $session): Response|Error | ||
| { | ||
| \assert($request instanceof ResourceSubscribeRequest); | ||
|
|
||
| $uri = $request->uri; | ||
|
|
||
| try { | ||
| $this->registry->getResource($uri); | ||
| } catch (ResourceNotFoundException $e) { | ||
| $this->logger->error('Resource not found', ['uri' => $uri]); | ||
|
|
||
| return Error::forResourceNotFound($e->getMessage(), $request->getId()); | ||
| } | ||
|
|
||
| $this->logger->debug('Subscribing to resource', ['uri' => $uri]); | ||
|
|
||
| $this->registry->subscribe($session, $uri); | ||
|
|
||
| return new Response( | ||
| $request->getId(), | ||
| new EmptyResult(), | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| <?php | ||
|
|
||
| /* | ||
| * This file is part of the official PHP MCP SDK. | ||
| * | ||
| * A collaboration between Symfony and the PHP Foundation. | ||
| * | ||
| * For the full copyright and license information, please view the LICENSE | ||
| * file that was distributed with this source code. | ||
| */ | ||
|
|
||
| namespace Mcp\Server\Handler\Request; | ||
|
|
||
| use Mcp\Capability\RegistryInterface; | ||
| use Mcp\Exception\ResourceNotFoundException; | ||
| use Mcp\Schema\JsonRpc\Error; | ||
| use Mcp\Schema\JsonRpc\Request; | ||
| use Mcp\Schema\JsonRpc\Response; | ||
| use Mcp\Schema\Request\ResourceUnsubscribeRequest; | ||
| use Mcp\Schema\Result\EmptyResult; | ||
| use Mcp\Server\Session\SessionInterface; | ||
| use Psr\Log\LoggerInterface; | ||
| use Psr\Log\NullLogger; | ||
|
|
||
| /** | ||
| * @implements RequestHandlerInterface<EmptyResult> | ||
| * | ||
| * @author Larry Sule-balogun <suleabimbola@gmail.com> | ||
| */ | ||
| final class ResourceUnsubscribeHandler implements RequestHandlerInterface | ||
| { | ||
| public function __construct( | ||
| private readonly RegistryInterface $registry, | ||
| private readonly LoggerInterface $logger = new NullLogger(), | ||
| ) { | ||
| } | ||
|
|
||
| public function supports(Request $request): bool | ||
| { | ||
| return $request instanceof ResourceUnsubscribeRequest; | ||
| } | ||
|
|
||
| public function handle(Request $request, SessionInterface $session): Response|Error | ||
| { | ||
| \assert($request instanceof ResourceUnsubscribeRequest); | ||
|
|
||
| $uri = $request->uri; | ||
|
|
||
| try { | ||
| $this->registry->getResource($uri); | ||
| } catch (ResourceNotFoundException $e) { | ||
| $this->logger->error('Resource not found', ['uri' => $uri]); | ||
|
|
||
| return Error::forResourceNotFound($e->getMessage(), $request->getId()); | ||
| } | ||
|
|
||
| $this->logger->debug('Unsubscribing from resource', ['uri' => $uri]); | ||
|
|
||
| $this->registry->unsubscribe($session, $uri); | ||
|
|
||
| return new Response( | ||
| $request->getId(), | ||
| new EmptyResult(), | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -150,6 +150,44 @@ public function gc(): array | |||||
| return $deleted; | ||||||
| } | ||||||
|
|
||||||
| public function getAllSessionIds(): array | ||||||
| { | ||||||
| $sessionIds = []; | ||||||
| $now = $this->clock->now()->getTimestamp(); | ||||||
|
Comment on lines
+155
to
+156
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. let's move this closer to where it's needed => before the while loop |
||||||
|
|
||||||
| $dir = @opendir($this->directory); | ||||||
| if (false === $dir) { | ||||||
| return $sessionIds; | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| } | ||||||
|
|
||||||
| while (($entry = readdir($dir)) !== false) { | ||||||
| // Skip dot entries | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. don't think that comment is needed - code is quite clear here |
||||||
| if ('.' === $entry || '..' === $entry) { | ||||||
| continue; | ||||||
| } | ||||||
|
|
||||||
| $path = $this->directory.\DIRECTORY_SEPARATOR.$entry; | ||||||
| if (!is_file($path)) { | ||||||
| continue; | ||||||
| } | ||||||
|
|
||||||
| $mtime = @filemtime($path) ?: 0; | ||||||
| if (($now - $mtime) > $this->ttl) { | ||||||
| continue; | ||||||
| } | ||||||
|
|
||||||
| try { | ||||||
| $sessionIds[] = Uuid::fromString($entry); | ||||||
| } catch (\Throwable) { | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah ! I've seen the usage this way hence my reasoning. I've given it a closer look now and I've seen the Uuid extends AbstractUid which throws the |
||||||
| // ignore non-UUID file names | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| closedir($dir); | ||||||
|
|
||||||
| return $sessionIds; | ||||||
| } | ||||||
|
|
||||||
| private function pathFor(Uuid $id): string | ||||||
| { | ||||||
| return $this->directory.\DIRECTORY_SEPARATOR.$id->toRfc4122(); | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a specific reason to bring this into the registry?
from a cohesion point of view those methods there is no direct connection or i fail to see it.
i mean, i think this could be a standalone service instead of extending the
Registry, which is large alreadyThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hi @chr-hertel this make sense, and i agree on making it a standalone. I’ll create a
src/Server/Resourcedirectory with aResourceSubscriptionclass and interface, then follow up on the remaining comments :)