From f40d08e473557ed9fd44d1bb797dc3ac229cb54d Mon Sep 17 00:00:00 2001 From: bigdevlarry Date: Tue, 20 Jan 2026 20:18:56 +0000 Subject: [PATCH] Add missing handler for resource subscribe and unsubscribe --- .github/workflows/pipeline.yaml | 2 +- src/Capability/Registry.php | 66 +++++ src/Capability/RegistryInterface.php | 18 ++ src/Server/Builder.php | 19 +- .../Request/ResourceSubscribeHandler.php | 66 +++++ .../Request/ResourceUnsubscribeHandler.php | 66 +++++ src/Server/Session/FileSessionStore.php | 38 +++ src/Server/Session/InMemorySessionStore.php | 10 + src/Server/Session/Psr16StoreSession.php | 94 ++++++- src/Server/Session/SessionStoreInterface.php | 7 + tests/Conformance/server.php | 5 +- tests/Unit/Capability/RegistryTest.php | 230 +++++++++++++++++- .../Handler/Request/ResourceSubscribeTest.php | 136 +++++++++++ .../Request/ResourceUnsubscribeTest.php | 141 +++++++++++ 14 files changed, 888 insertions(+), 10 deletions(-) create mode 100644 src/Server/Handler/Request/ResourceSubscribeHandler.php create mode 100644 src/Server/Handler/Request/ResourceUnsubscribeHandler.php create mode 100644 tests/Unit/Server/Handler/Request/ResourceSubscribeTest.php create mode 100644 tests/Unit/Server/Handler/Request/ResourceUnsubscribeTest.php diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index 812217c2..b1a2d528 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -100,7 +100,7 @@ jobs: passedTests=$(echo "$OUTPUT" | sed -nE 's/.*Total: ([0-9]+) passed.*/\1/p') passedTests=${passedTests:-0} - REQUIRED_TESTS_TO_PASS=22 + REQUIRED_TESTS_TO_PASS=25 echo "Required tests to pass: $REQUIRED_TESTS_TO_PASS" [ "$passedTests" -ge "$REQUIRED_TESTS_TO_PASS" ] || exit $exit_code diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index 7a6f0d45..9f19cb93 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -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) { + $this->logger->error('Error sending resource notification to session', [ + 'session_id' => $sessionId->toRfc4122(), + 'uri' => $uri, + 'exception' => $e, + ]); + } + } + } + /** * Calculate next cursor for pagination. * diff --git a/src/Capability/RegistryInterface.php b/src/Capability/RegistryInterface.php index 67295681..f62db4f5 100644 --- a/src/Capability/RegistryInterface.php +++ b/src/Capability/RegistryInterface.php @@ -25,6 +25,8 @@ use Mcp\Schema\Resource; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\Tool; +use Mcp\Server\Protocol; +use Mcp\Server\Session\SessionInterface; /** * @phpstan-import-type Handler from ElementReference @@ -157,4 +159,20 @@ public function getPrompts(?int $limit = null, ?string $cursor = null): Page; * @throws PromptNotFoundException */ public function getPrompt(string $name): PromptReference; + + /** + * Subscribes a session to a specific resource URI. + */ + public function subscribe(SessionInterface $session, string $uri): void; + + /** + * Unsubscribes a session from a specific resource URI. + */ + public function unsubscribe(SessionInterface $session, string $uri): void; + + /** + * Notifies all sessions subscribed to the given resource URI that the + * resource has changed. Sends a ResourceUpdatedNotification for each subscriber. + */ + public function notifyResourceChanged(Protocol $protocol, string $uri): void; } diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 9e9b6b2f..3443aaf7 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -488,7 +488,17 @@ public function build(): Server { $logger = $this->logger ?? new NullLogger(); $container = $this->container ?? new Container(); - $registry = $this->registry ?? new Registry($this->eventDispatcher, $logger); + + $sessionTtl = $this->sessionTtl ?? 3600; + $sessionFactory = $this->sessionFactory ?? new SessionFactory(); + $sessionStore = $this->sessionStore ?? new InMemorySessionStore($sessionTtl); + + $registry = $this->registry ?? new Registry( + $this->eventDispatcher, + $logger, + $sessionStore, + $sessionFactory + ); $loaders = [ ...$this->loaders, @@ -504,16 +514,13 @@ public function build(): Server $loader->load($registry); } - $sessionTtl = $this->sessionTtl ?? 3600; - $sessionFactory = $this->sessionFactory ?? new SessionFactory(); - $sessionStore = $this->sessionStore ?? new InMemorySessionStore($sessionTtl); $messageFactory = MessageFactory::make(); $capabilities = $this->serverCapabilities ?? new ServerCapabilities( tools: $registry->hasTools(), toolsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, resources: $registry->hasResources() || $registry->hasResourceTemplates(), - resourcesSubscribe: false, + resourcesSubscribe: $registry->hasResources() || $registry->hasResourceTemplates(), resourcesListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, prompts: $registry->hasPrompts(), promptsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, @@ -536,6 +543,8 @@ public function build(): Server new Handler\Request\ListToolsHandler($registry, $this->paginationLimit), new Handler\Request\PingHandler(), new Handler\Request\ReadResourceHandler($registry, $referenceHandler, $logger), + new Handler\Request\ResourceSubscribeHandler($registry, $logger), + new Handler\Request\ResourceUnsubscribeHandler($registry, $logger), new Handler\Request\SetLogLevelHandler(), ]); diff --git a/src/Server/Handler/Request/ResourceSubscribeHandler.php b/src/Server/Handler/Request/ResourceSubscribeHandler.php new file mode 100644 index 00000000..189d751f --- /dev/null +++ b/src/Server/Handler/Request/ResourceSubscribeHandler.php @@ -0,0 +1,66 @@ + + * + * @author Larry Sule-balogun + */ +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(), + ); + } +} diff --git a/src/Server/Handler/Request/ResourceUnsubscribeHandler.php b/src/Server/Handler/Request/ResourceUnsubscribeHandler.php new file mode 100644 index 00000000..91144662 --- /dev/null +++ b/src/Server/Handler/Request/ResourceUnsubscribeHandler.php @@ -0,0 +1,66 @@ + + * + * @author Larry Sule-balogun + */ +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(), + ); + } +} diff --git a/src/Server/Session/FileSessionStore.php b/src/Server/Session/FileSessionStore.php index 0a7b7cd4..8cc223c9 100644 --- a/src/Server/Session/FileSessionStore.php +++ b/src/Server/Session/FileSessionStore.php @@ -150,6 +150,44 @@ public function gc(): array return $deleted; } + public function getAllSessionIds(): array + { + $sessionIds = []; + $now = $this->clock->now()->getTimestamp(); + + $dir = @opendir($this->directory); + if (false === $dir) { + return $sessionIds; + } + + while (($entry = readdir($dir)) !== false) { + // Skip dot entries + 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) { + // ignore non-UUID file names + } + } + + closedir($dir); + + return $sessionIds; + } + private function pathFor(Uuid $id): string { return $this->directory.\DIRECTORY_SEPARATOR.$id->toRfc4122(); diff --git a/src/Server/Session/InMemorySessionStore.php b/src/Server/Session/InMemorySessionStore.php index 9f8077c6..5a6324af 100644 --- a/src/Server/Session/InMemorySessionStore.php +++ b/src/Server/Session/InMemorySessionStore.php @@ -87,4 +87,14 @@ public function gc(): array return $deletedSessions; } + + public function getAllSessionIds(): array + { + $ids = []; + foreach (array_keys($this->store) as $id) { + $ids[] = Uuid::fromString($id); + } + + return $ids; + } } diff --git a/src/Server/Session/Psr16StoreSession.php b/src/Server/Session/Psr16StoreSession.php index aacf0ffc..b4a05139 100644 --- a/src/Server/Session/Psr16StoreSession.php +++ b/src/Server/Session/Psr16StoreSession.php @@ -26,6 +26,8 @@ */ class Psr16StoreSession implements SessionStoreInterface { + private const SESSION_IDS_KEY = 'mcp-session-ids'; + public function __construct( private readonly CacheInterface $cache, private readonly string $prefix = 'mcp-', @@ -54,7 +56,13 @@ public function read(Uuid $id): string|false public function write(Uuid $id, string $data): bool { try { - return $this->cache->set($this->getKey($id), $data, $this->ttl); + $result = $this->cache->set($this->getKey($id), $data, $this->ttl); + + if ($result) { + $this->addSessionId($id); + } + + return $result; } catch (\Throwable) { return false; } @@ -63,7 +71,13 @@ public function write(Uuid $id, string $data): bool public function destroy(Uuid $id): bool { try { - return $this->cache->delete($this->getKey($id)); + $result = $this->cache->delete($this->getKey($id)); + + if ($result) { + $this->removeSessionId($id); + } + + return $result; } catch (\Throwable) { return false; } @@ -74,6 +88,82 @@ public function gc(): array return []; } + public function getAllSessionIds(): array + { + try { + $sessionIdsData = $this->cache->get(self::SESSION_IDS_KEY, []); + + if (!\is_array($sessionIdsData)) { + return []; + } + + $validSessionIds = []; + + foreach ($sessionIdsData as $sessionIdString) { + try { + $uuid = Uuid::fromString($sessionIdString); + if ($this->exists($uuid)) { + $validSessionIds[] = $uuid; + } + } catch (\Throwable) { + // Skip invalid UUIDs + } + } + + if (\count($validSessionIds) !== \count($sessionIdsData)) { + $this->cache->set( + self::SESSION_IDS_KEY, + array_map(fn (Uuid $id) => $id->toRfc4122(), $validSessionIds), + null + ); + } + + return $validSessionIds; + } catch (\Throwable) { + return []; + } + } + + private function addSessionId(Uuid $id): void + { + try { + $sessionIds = $this->cache->get(self::SESSION_IDS_KEY, []); + + if (!\is_array($sessionIds)) { + $sessionIds = []; + } + + $idString = $id->toRfc4122(); + if (!\in_array($idString, $sessionIds, true)) { + $sessionIds[] = $idString; + $this->cache->set(self::SESSION_IDS_KEY, $sessionIds, null); + } + } catch (\Throwable) { + return; + } + } + + private function removeSessionId(Uuid $id): void + { + try { + $sessionIds = $this->cache->get(self::SESSION_IDS_KEY, []); + + if (!\is_array($sessionIds)) { + return; + } + + $idString = $id->toRfc4122(); + $sessionIds = array_values(array_filter( + $sessionIds, + fn ($sid) => $sid !== $idString + )); + + $this->cache->set(self::SESSION_IDS_KEY, $sessionIds, null); + } catch (\Throwable) { + return; + } + } + private function getKey(Uuid $id): string { return $this->prefix.$id; diff --git a/src/Server/Session/SessionStoreInterface.php b/src/Server/Session/SessionStoreInterface.php index 13f5f161..85d09469 100644 --- a/src/Server/Session/SessionStoreInterface.php +++ b/src/Server/Session/SessionStoreInterface.php @@ -61,4 +61,11 @@ public function destroy(Uuid $id): bool; * @return Uuid[] */ public function gc(): array; + + /** + * Get all active session IDs. + * + * @return Uuid[] + */ + public function getAllSessionIds(): array; } diff --git a/tests/Conformance/server.php b/tests/Conformance/server.php index 802fa05a..af250067 100644 --- a/tests/Conformance/server.php +++ b/tests/Conformance/server.php @@ -9,10 +9,13 @@ * file that was distributed with this source code. */ +ini_set('display_errors', '0'); + require_once dirname(__DIR__, 2).'/vendor/autoload.php'; use Http\Discovery\Psr17Factory; use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; +use Mcp\Capability\Registry; use Mcp\Schema\Content\AudioContent; use Mcp\Schema\Content\EmbeddedResource; use Mcp\Schema\Content\ImageContent; @@ -32,6 +35,7 @@ $request = $psr17Factory->createServerRequestFromGlobals(); $transport = new StreamableHttpTransport($request, logger: $logger); +$registry = new Registry(null, $logger); $server = Server::builder() ->setServerInfo('mcp-conformance-test-server', '1.0.0') @@ -51,7 +55,6 @@ ->addResource(fn () => 'This is the content of the static text resource.', 'test://static-text', 'static-text', 'A static text resource for testing') ->addResource(fn () => fopen('data://image/png;base64,'.Elements::TEST_IMAGE_BASE64, 'r'), 'test://static-binary', 'static-binary', 'A static binary resource (image) for testing') ->addResourceTemplate([Elements::class, 'resourceTemplate'], 'test://template/{id}/data', 'template', 'A resource template with parameter substitution', 'application/json') - // TODO: Handler for resources/subscribe and resources/unsubscribe ->addResource(fn () => 'Watched resource content', 'test://watched-resource', 'watched-resource', 'A resource that can be watched') // Prompts ->addPrompt(fn () => [['role' => 'user', 'content' => 'This is a simple prompt for testing.']], 'test_simple_prompt', 'A simple prompt without arguments') diff --git a/tests/Unit/Capability/RegistryTest.php b/tests/Unit/Capability/RegistryTest.php index a5e1647c..878b7b47 100644 --- a/tests/Unit/Capability/RegistryTest.php +++ b/tests/Unit/Capability/RegistryTest.php @@ -20,23 +20,35 @@ use Mcp\Exception\PromptNotFoundException; use Mcp\Exception\ResourceNotFoundException; use Mcp\Exception\ToolNotFoundException; +use Mcp\Schema\Notification\ResourceUpdatedNotification; 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 PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; +use Symfony\Component\Uid\Uuid; class RegistryTest extends TestCase { private Registry $registry; + private Protocol|MockObject $protocol; private LoggerInterface|MockObject $logger; + private SessionStoreInterface|MockObject $sessionStore; + private SessionFactoryInterface|MockObject $sessionFactory; protected function setUp(): void { $this->logger = $this->createMock(LoggerInterface::class); - $this->registry = new Registry(null, $this->logger); + $this->sessionStore = $this->createMock(SessionStoreInterface::class); + $this->sessionFactory = $this->createMock(SessionFactoryInterface::class); + $this->registry = new Registry(null, $this->logger, $this->sessionStore, $this->sessionFactory); + $this->protocol = $this->createMock(Protocol::class); } public function testHasserReturnFalseForEmptyRegistry(): void @@ -527,6 +539,222 @@ public function testMultipleRegistrationsOfSameElementWithSameType(): void $this->assertEquals('second', ($toolRef->handler)()); } + public function testSubscribeAndSendsNotification(): void + { + $session1 = $this->createMock(SessionInterface::class); + $session2 = $this->createMock(SessionInterface::class); + + $uuid1 = Uuid::v4(); + $uuid2 = Uuid::v4(); + + $session1->method('getId')->willReturn($uuid1); + $session2->method('getId')->willReturn($uuid2); + + $uri = 'test://resource1'; + + $session1->expects($this->once())->method('get')->with('resource_subscriptions', [])->willReturn([]); + $session1->expects($this->once())->method('set')->with('resource_subscriptions', [$uri => true]); + $session1->expects($this->once())->method('save'); + + $session2->expects($this->once())->method('get')->with('resource_subscriptions', [])->willReturn([]); + $session2->expects($this->once())->method('set')->with('resource_subscriptions', [$uri => true]); + $session2->expects($this->once())->method('save'); + + // Subscribe both sessions + $this->registry->subscribe($session1, $uri); + $this->registry->subscribe($session2, $uri); + + // Mock session store to return both session IDs + $this->sessionStore->expects($this->once()) + ->method('getAllSessionIds') + ->willReturn([$uuid1, $uuid2]); + + // Mock session store reads + $sessionData1 = json_encode(['resource_subscriptions' => [$uri => true]]); + $sessionData2 = json_encode(['resource_subscriptions' => [$uri => true]]); + + $this->sessionStore->expects($this->exactly(2)) + ->method('read') + ->willReturnCallback(function ($id) use ($uuid1, $uuid2, $sessionData1, $sessionData2) { + if ($id === $uuid1) { + return $sessionData1; + } + if ($id === $uuid2) { + return $sessionData2; + } + + return false; + }); + + $this->sessionFactory->expects($this->exactly(2)) + ->method('createWithId') + ->willReturnCallback(function ($id) use ($uuid1, $uuid2, $session1, $session2) { + if ($id === $uuid1) { + return $session1; + } + if ($id === $uuid2) { + return $session2; + } + + return null; + }); + + $this->protocol->expects($this->exactly(2)) + ->method('sendNotification') + ->with($this->callback(function ($notification) use ($uri) { + return $notification instanceof ResourceUpdatedNotification + && $notification->uri === $uri; + })); + + $this->registry->notifyResourceChanged($this->protocol, $uri); + } + + public function testUnsubscribeRemovesOnlyTargetSession(): void + { + $session1 = $this->createMock(SessionInterface::class); + $uuid1 = Uuid::v4(); + $session1->method('getId')->willReturn($uuid1); + + $uri = 'test://resource'; + + $callCount = 0; + $session1->expects($this->exactly(2))->method('get')->with('resource_subscriptions', []) + ->willReturnCallback(function () use (&$callCount, $uri) { + return 0 === $callCount++ ? [] : [$uri => true]; + }); + $session1->expects($this->exactly(2))->method('set') + ->willReturnCallback(function ($key, $value) use ($uri) { + // First call sets subscription, second call removes it + static $callNum = 0; + if (0 === $callNum++) { + $this->assertEquals('resource_subscriptions', $key); + $this->assertEquals([$uri => true], $value); + } else { + $this->assertEquals('resource_subscriptions', $key); + $this->assertEquals([], $value); + } + }); + $session1->expects($this->exactly(2))->method('save'); + + // Subscribe session + $this->registry->subscribe($session1, $uri); + + // Mock session store to return the session ID + $this->sessionStore->expects($this->once()) + ->method('getAllSessionIds') + ->willReturn([$uuid1]); + + $sessionData = json_encode(['resource_subscriptions' => [$uri => true]]); + $this->sessionStore->expects($this->once()) + ->method('read') + ->with($uuid1) + ->willReturn($sessionData); + + $this->sessionFactory->expects($this->once()) + ->method('createWithId') + ->with($uuid1) + ->willReturn($session1); + + $this->protocol->expects($this->exactly(1)) + ->method('sendNotification') + ->with($this->callback(fn ($notification) => $notification instanceof ResourceUpdatedNotification && $notification->uri === $uri + )); + + $this->registry->notifyResourceChanged($this->protocol, $uri); + + // Unsubscribe only session1 + $this->registry->unsubscribe($session1, $uri); + } + + public function testUnsubscribeStopsNotifications(): void + { + $protocol = $this->createMock(Protocol::class); + $session = $this->createMock(SessionInterface::class); + $uuid = Uuid::v4(); + $session->method('getId')->willReturn($uuid); + $uri = 'test://resource'; + + $callCount = 0; + $session->expects($this->exactly(2))->method('get')->with('resource_subscriptions', []) + ->willReturnCallback(function () use (&$callCount, $uri) { + return 0 === $callCount++ ? [] : [$uri => true]; + }); + $session->expects($this->exactly(2))->method('set') + ->willReturnCallback(function ($key, $value) use ($uri) { + static $callNum = 0; + $this->assertEquals('resource_subscriptions', $key); + if (0 === $callNum++) { + $this->assertEquals([$uri => true], $value); + } else { + $this->assertEquals([], $value); + } + }); + $session->expects($this->exactly(2))->method('save'); + + $this->registry->subscribe($session, $uri); + $this->registry->unsubscribe($session, $uri); + + $this->sessionStore->expects($this->once()) + ->method('getAllSessionIds') + ->willReturn([$uuid]); + + // Mock session store read - session has no subscriptions + $sessionData = json_encode(['resource_subscriptions' => []]); + $this->sessionStore->expects($this->once()) + ->method('read') + ->with($uuid) + ->willReturn($sessionData); + + $protocol->expects($this->never())->method('sendNotification'); + + $this->registry->notifyResourceChanged($protocol, $uri); + } + + public function testDuplicateSubscribeDoesNotTriggerDuplicateNotifications(): void + { + $session = $this->createMock(SessionInterface::class); + $uuid = Uuid::v4(); + $session->method('getId')->willReturn($uuid); + + $uri = 'test://resource'; + + // First subscribe + $callCount = 0; + $session->expects($this->exactly(2))->method('get')->with('resource_subscriptions', []) + ->willReturnCallback(function () use (&$callCount, $uri) { + return 0 === $callCount++ ? [] : [$uri => true]; + }); + $session->expects($this->exactly(2))->method('set') + ->with('resource_subscriptions', [$uri => true]); + $session->expects($this->exactly(2))->method('save'); + + $this->registry->subscribe($session, $uri); + $this->registry->subscribe($session, $uri); + + $this->sessionStore->expects($this->once()) + ->method('getAllSessionIds') + ->willReturn([$uuid]); + + // Mock session store read + $sessionData = json_encode(['resource_subscriptions' => [$uri => true]]); + $this->sessionStore->expects($this->once()) + ->method('read') + ->with($uuid) + ->willReturn($sessionData); + + $this->sessionFactory->expects($this->once()) + ->method('createWithId') + ->with($uuid) + ->willReturn($session); + + $this->protocol->expects($this->once()) + ->method('sendNotification') + ->with($this->callback(fn ($notification) => $notification instanceof ResourceUpdatedNotification && $notification->uri === $uri + )); + + $this->registry->notifyResourceChanged($this->protocol, $uri); + } + public function testExtractStructuredContentReturnsNullWhenOutputSchemaIsNull(): void { $tool = $this->createValidTool('test_tool', null); diff --git a/tests/Unit/Server/Handler/Request/ResourceSubscribeTest.php b/tests/Unit/Server/Handler/Request/ResourceSubscribeTest.php new file mode 100644 index 00000000..45865430 --- /dev/null +++ b/tests/Unit/Server/Handler/Request/ResourceSubscribeTest.php @@ -0,0 +1,136 @@ +registry = $this->createMock(RegistryInterface::class); + $this->session = $this->createMock(SessionInterface::class); + + $this->handler = new ResourceSubscribeHandler($this->registry); + } + + public function testHandleSuccessfulSubscribe(): void + { + $uri = 'file://documents/readme.txt'; + $request = $this->createResourceSubscribeRequest($uri); + $resourceReference = $this->getMockBuilder(ResourceReference::class) + ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'text/plain'), []]) + ->getMock(); + + $this->registry + ->expects($this->once()) + ->method('getResource') + ->with($uri) + ->willReturn($resourceReference); + + $this->registry->expects($this->once()) + ->method('subscribe') + ->with($this->session, $uri); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals($request->getId(), $response->id); + $this->assertInstanceOf(EmptyResult::class, $response->result); + } + + public function testDuplicateSubscribeDoesNotError(): void + { + $uri = 'file://documents/readme.txt'; + $request = $this->createResourceSubscribeRequest($uri); + $resourceReference = $this->getMockBuilder(ResourceReference::class) + ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'text/plain'), []]) + ->getMock(); + + $this->registry + ->expects($this->exactly(2)) + ->method('getResource') + ->with($uri) + ->willReturn($resourceReference); + + $this->registry + ->expects($this->exactly(2)) + ->method('subscribe') + ->with($this->session, $uri); + + $response1 = $this->handler->handle($request, $this->session); + $response2 = $this->handler->handle($request, $this->session); + + // No exception thrown, response is still EmptyResult + $this->assertInstanceOf(Response::class, $response1); + $this->assertInstanceOf(Response::class, $response2); + $this->assertEquals($request->getId(), $response1->id); + $this->assertEquals($request->getId(), $response2->id); + $this->assertInstanceOf(EmptyResult::class, $response1->result); + $this->assertInstanceOf(EmptyResult::class, $response2->result); + } + + public function testSubscribeWithEmptyUriThrowsError(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Missing or invalid "uri" parameter for resources/subscribe.'); + + $this->createResourceSubscribeRequest(''); + } + + public function testHandleSubscribeResourceNotFoundException(): void + { + $uri = 'file://missing/file.txt'; + $request = $this->createResourceSubscribeRequest($uri); + $exception = new ResourceNotFoundException($uri); + + $this->registry + ->expects($this->once()) + ->method('getResource') + ->with($uri) + ->willThrowException($exception); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Error::class, $response); + $this->assertEquals(Error::RESOURCE_NOT_FOUND, $response->code); + $this->assertEquals(\sprintf('Resource not found for uri: "%s".', $uri), $response->message); + } + + private function createResourceSubscribeRequest(string $uri): ResourceSubscribeRequest + { + return ResourceSubscribeRequest::fromArray([ + 'jsonrpc' => '2.0', + 'method' => ResourceSubscribeRequest::getMethod(), + 'id' => 'test-request-'.uniqid(), + 'params' => [ + 'uri' => $uri, + ], + ]); + } +} diff --git a/tests/Unit/Server/Handler/Request/ResourceUnsubscribeTest.php b/tests/Unit/Server/Handler/Request/ResourceUnsubscribeTest.php new file mode 100644 index 00000000..f4b21b46 --- /dev/null +++ b/tests/Unit/Server/Handler/Request/ResourceUnsubscribeTest.php @@ -0,0 +1,141 @@ +registry = $this->createMock(RegistryInterface::class); + $this->session = $this->createMock(SessionInterface::class); + + $this->handler = new ResourceUnsubscribeHandler($this->registry); + } + + public function testHandleSuccessfulUnsubscribe(): void + { + // Arrange + $uri = 'file://documents/readme.txt'; + $request = $this->createResourceUnsubscribeRequest($uri); + $resourceReference = $this->getMockBuilder(ResourceReference::class) + ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'text/plain'), []]) + ->getMock(); + + $this->registry + ->expects($this->once()) + ->method('getResource') + ->with($uri) + ->willReturn($resourceReference); + + $this->registry->expects($this->once()) + ->method('unsubscribe') + ->with($this->session, $uri); + + // Act + $response = $this->handler->handle($request, $this->session); + + // Assert + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals($request->getId(), $response->id); + $this->assertInstanceOf(EmptyResult::class, $response->result); + } + + public function testDuplicateUnsubscribeDoesNotError(): void + { + // Arrange + $uri = 'file://documents/readme.txt'; + $request = $this->createResourceUnsubscribeRequest($uri); + $resourceReference = $this->getMockBuilder(ResourceReference::class) + ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'text/plain'), []]) + ->getMock(); + + $this->registry + ->expects($this->exactly(2)) + ->method('getResource') + ->with($uri) + ->willReturn($resourceReference); + + $this->registry + ->expects($this->exactly(2)) + ->method('unsubscribe') + ->with($this->session, $uri); + + // Act + $response1 = $this->handler->handle($request, $this->session); + $response2 = $this->handler->handle($request, $this->session); + + // Assert + $this->assertInstanceOf(Response::class, $response1); + $this->assertInstanceOf(Response::class, $response2); + $this->assertEquals($request->getId(), $response1->id); + $this->assertEquals($request->getId(), $response2->id); + $this->assertInstanceOf(EmptyResult::class, $response1->result); + $this->assertInstanceOf(EmptyResult::class, $response2->result); + } + + public function testHandleUnsubscribeResourceNotFoundException(): void + { + $uri = 'file://missing/file.txt'; + $request = $this->createResourceUnsubscribeRequest($uri); + $exception = new ResourceNotFoundException($uri); + + $this->registry + ->expects($this->once()) + ->method('getResource') + ->with($uri) + ->willThrowException($exception); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Error::class, $response); + $this->assertEquals(Error::RESOURCE_NOT_FOUND, $response->code); + $this->assertEquals(\sprintf('Resource not found for uri: "%s".', $uri), $response->message); + } + + public function testUnsubscribeWithEmptyUriThrowsError(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Missing or invalid "uri" parameter for resources/unsubscribe.'); + + $this->createResourceUnsubscribeRequest(''); + } + + private function createResourceUnsubscribeRequest(string $uri): ResourceUnsubscribeRequest + { + return ResourceUnsubscribeRequest::fromArray([ + 'jsonrpc' => '2.0', + 'method' => ResourceUnsubscribeRequest::getMethod(), + 'id' => 'test-request-'.uniqid(), + 'params' => [ + 'uri' => $uri, + ], + ]); + } +}