diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index 812217c2..5486d002 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=27 echo "Required tests to pass: $REQUIRED_TESTS_TO_PASS" [ "$passedTests" -ge "$REQUIRED_TESTS_TO_PASS" ] || exit $exit_code diff --git a/CHANGELOG.md b/CHANGELOG.md index b6667b03..8f2a09cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to `mcp/sdk` will be documented in this file. +0.4.0 +----- +* Add missing handlers for resource subscribe/unsubscribe and persist subscriptions via session + 0.3.0 ----- diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index 7a6f0d45..c07bf0c6 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -352,7 +352,7 @@ public function getDiscoveryState(): DiscoveryState } /** - * Set discovery state, replacing all discovered elements. + * Set the discovery state, replacing all discovered elements. * Manual elements are preserved. */ public function setDiscoveryState(DiscoveryState $state): void diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 9e9b6b2f..d35160bc 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -33,6 +33,8 @@ use Mcp\Server; use Mcp\Server\Handler\Notification\NotificationHandlerInterface; use Mcp\Server\Handler\Request\RequestHandlerInterface; +use Mcp\Server\Resource\ResourceSubscription; +use Mcp\Server\Resource\ResourceSubscriptionInterface; use Mcp\Server\Session\InMemorySessionStore; use Mcp\Server\Session\SessionFactory; use Mcp\Server\Session\SessionFactoryInterface; @@ -54,6 +56,8 @@ final class Builder private RegistryInterface $registry; + private ?ResourceSubscriptionInterface $resourceSubscription = null; + private ?LoggerInterface $logger = null; private ?CacheInterface $discoveryCache = null; @@ -309,6 +313,13 @@ public function setDiscoverer(DiscovererInterface $discoverer): self return $this; } + public function setResourceSubscription(ResourceSubscriptionInterface $resourceSubscription): self + { + $this->resourceSubscription = $resourceSubscription; + + return $this; + } + public function setSession( SessionStoreInterface $sessionStore, SessionFactoryInterface $sessionFactory = new SessionFactory(), @@ -490,6 +501,16 @@ public function build(): Server $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); + + $resourceSubscription = $this->resourceSubscription ?? new ResourceSubscription( + $logger, + $sessionStore, + $sessionFactory + ); + $loaders = [ ...$this->loaders, new ArrayLoader($this->tools, $this->resources, $this->resourceTemplates, $this->prompts, $logger, $this->schemaGenerator), @@ -504,16 +525,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 +554,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, $resourceSubscription, $logger), + new Handler\Request\ResourceUnsubscribeHandler($registry, $resourceSubscription, $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..f66ff8ce --- /dev/null +++ b/src/Server/Handler/Request/ResourceSubscribeHandler.php @@ -0,0 +1,72 @@ + + * + * @author Larry Sule-balogun + */ +final class ResourceSubscribeHandler implements RequestHandlerInterface +{ + public function __construct( + private readonly RegistryInterface $registry, + private readonly ResourceSubscriptionInterface $resourceSubscription, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + public function supports(Request $request): bool + { + return $request instanceof ResourceSubscribeRequest; + } + + /** + * @throws InvalidArgumentException + */ + 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->resourceSubscription->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..61d447cb --- /dev/null +++ b/src/Server/Handler/Request/ResourceUnsubscribeHandler.php @@ -0,0 +1,72 @@ + + * + * @author Larry Sule-balogun + */ +final class ResourceUnsubscribeHandler implements RequestHandlerInterface +{ + public function __construct( + private readonly RegistryInterface $registry, + private readonly ResourceSubscriptionInterface $resourceSubscription, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + public function supports(Request $request): bool + { + return $request instanceof ResourceUnsubscribeRequest; + } + + /** + * @throws InvalidArgumentException + */ + 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->resourceSubscription->unsubscribe($session, $uri); + + return new Response( + $request->getId(), + new EmptyResult(), + ); + } +} diff --git a/src/Server/Resource/ResourceSubscription.php b/src/Server/Resource/ResourceSubscription.php new file mode 100644 index 00000000..ba795778 --- /dev/null +++ b/src/Server/Resource/ResourceSubscription.php @@ -0,0 +1,97 @@ + + */ +final class ResourceSubscription implements ResourceSubscriptionInterface +{ + public function __construct( + private readonly LoggerInterface $logger = new NullLogger(), + private readonly ?SessionStoreInterface $sessionStore = null, + private readonly ?SessionFactoryInterface $sessionFactory = null, + ) { + } + + /** + * @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(); + } + + /** + * @throws InvalidArgumentException + */ + 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 (InvalidArgumentException $e) { + $this->logger->error('Error sending resource notification to session', [ + 'session_id' => $sessionId->toRfc4122(), + 'uri' => $uri, + 'exception' => $e, + ]); + } + } + } +} diff --git a/src/Server/Resource/ResourceSubscriptionInterface.php b/src/Server/Resource/ResourceSubscriptionInterface.php new file mode 100644 index 00000000..969174a5 --- /dev/null +++ b/src/Server/Resource/ResourceSubscriptionInterface.php @@ -0,0 +1,46 @@ + + */ +interface ResourceSubscriptionInterface +{ + /** + * Subscribes a session to a specific resource URI. + * + * @throws InvalidArgumentException + */ + public function subscribe(SessionInterface $session, string $uri): void; + + /** + * Unsubscribes a session from a specific resource URI. + * + * @throws InvalidArgumentException + */ + 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. + * + * @throws InvalidArgumentException + */ + public function notifyResourceChanged(Protocol $protocol, string $uri): void; +} diff --git a/src/Server/Session/FileSessionStore.php b/src/Server/Session/FileSessionStore.php index 0a7b7cd4..fd4b0888 100644 --- a/src/Server/Session/FileSessionStore.php +++ b/src/Server/Session/FileSessionStore.php @@ -15,6 +15,7 @@ use Mcp\Server\NativeClock; use Psr\Clock\ClockInterface; +use Symfony\Component\Uid\Exception\InvalidArgumentException; use Symfony\Component\Uid\Uuid; /** @@ -139,7 +140,7 @@ public function gc(): array @unlink($path); try { $deleted[] = Uuid::fromString($entry); - } catch (\Throwable) { + } catch (InvalidArgumentException $e) { // ignore non-UUID file names } } @@ -150,6 +151,42 @@ public function gc(): array return $deleted; } + public function getAllSessionIds(): array + { + $dir = @opendir($this->directory); + if (false === $dir) { + return []; + } + + $sessionIds = []; + $now = $this->clock->now()->getTimestamp(); + while (($entry = readdir($dir)) !== false) { + 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 (InvalidArgumentException $e) { + // ignore non-UUID sessions + } + } + + 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..ddb3940a 100644 --- a/src/Server/Session/Psr16StoreSession.php +++ b/src/Server/Session/Psr16StoreSession.php @@ -14,6 +14,7 @@ namespace Mcp\Server\Session; use Psr\SimpleCache\CacheInterface; +use Symfony\Component\Uid\Exception\InvalidArgumentException; use Symfony\Component\Uid\Uuid; /** @@ -26,6 +27,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 +57,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 +72,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 +89,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 (InvalidArgumentException $e) { + // 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/Server/Handler/Request/ResourceSubscribeTest.php b/tests/Unit/Server/Handler/Request/ResourceSubscribeTest.php new file mode 100644 index 00000000..576feecc --- /dev/null +++ b/tests/Unit/Server/Handler/Request/ResourceSubscribeTest.php @@ -0,0 +1,143 @@ +registry = $this->createMock(RegistryInterface::class); + $this->resourceSubscription = $this->createMock(ResourceSubscriptionInterface::class); + $this->session = $this->createMock(SessionInterface::class); + $this->handler = new ResourceSubscribeHandler($this->registry, $this->resourceSubscription); + } + + #[TestDox('Client can successfully subscribe to a resource')] + public function testClientCanSuccessfulSubscribeToAResource(): 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->resourceSubscription->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); + } + + #[TestDox('Gracefully handle duplicate subscription to a resource')] + public function testDuplicateSubscriptionIsGracefullyHandled(): 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->resourceSubscription + ->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); + } + + #[TestDox('Subscription to a resource with an empty uri throws InvalidArgumentException')] + public function testSubscribeWithEmptyUriThrowsError(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Missing or invalid "uri" parameter for resources/subscribe.'); + + $this->createResourceSubscribeRequest(''); + } + + #[TestDox('Subscription to a resource with an invalid uri throws ResourceNotException')] + 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..fb8f2eb0 --- /dev/null +++ b/tests/Unit/Server/Handler/Request/ResourceUnsubscribeTest.php @@ -0,0 +1,149 @@ +registry = $this->createMock(RegistryInterface::class); + $this->resourceSubscription = $this->createMock(ResourceSubscriptionInterface::class); + $this->session = $this->createMock(SessionInterface::class); + + $this->handler = new ResourceUnsubscribeHandler($this->registry, $this->resourceSubscription); + } + + #[TestDox('Client can unsubscribe from a resource')] + public function testClientCanUnsubscribeFromAResource(): 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->resourceSubscription->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); + } + + #[TestDox('Gracefully handle duplicate unsubscription from a resource')] + public function testDuplicateUnSubscriptionIsGracefullyHandled(): 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->resourceSubscription + ->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); + } + + #[TestDox('Unsubscription from a resource with an invalid uri throws ResourceNotException')] + 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); + } + + #[TestDox('Unsubscription from a resource with an empty uri throws InvalidArgumentException')] + 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, + ], + ]); + } +} diff --git a/tests/Unit/Server/ResourceSubscriptionTest.php b/tests/Unit/Server/ResourceSubscriptionTest.php new file mode 100644 index 00000000..30e72223 --- /dev/null +++ b/tests/Unit/Server/ResourceSubscriptionTest.php @@ -0,0 +1,216 @@ +logger = $this->createMock(LoggerInterface::class); + $this->sessionStore = $this->createMock(SessionStoreInterface::class); + $this->sessionFactory = $this->createMock(SessionFactoryInterface::class); + $this->protocol = $this->createMock(Protocol::class); + $this->resourceSubscription = new ResourceSubscription($this->logger, $this->sessionStore, $this->sessionFactory); + } + + #[TestDox('Subscribing to a resource sends update notifications')] + public function testSubscribeAndSendsNotification(): void + { + // Arrange + $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->resourceSubscription->subscribe($session1, $uri); + $this->resourceSubscription->subscribe($session2, $uri); + + $this->sessionStore->expects($this->once()) + ->method('getAllSessionIds') + ->willReturn([$uuid1, $uuid2]); + + $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; + })); + + // Act & Assert + $this->resourceSubscription->notifyResourceChanged($this->protocol, $uri); + } + + #[TestDox('Unsubscribing from a resource removes only the target session')] + public function testUnsubscribeRemovesOnlyTargetSession(): void + { + // Arrange + $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'); + + $this->resourceSubscription->subscribe($session1, $uri); + + $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->resourceSubscription->notifyResourceChanged($this->protocol, $uri); + + // Act & Assert + $this->resourceSubscription->unsubscribe($session1, $uri); + } + + #[TestDox('Unsubscribing from a resource verifies that no notification is sent')] + public function testUnsubscribeDoesNotSendNotifications(): void + { + // Arrange + $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'); + + // Act & Assert + $this->resourceSubscription->subscribe($session, $uri); + $this->resourceSubscription->unsubscribe($session, $uri); + + $this->sessionStore->expects($this->once()) + ->method('getAllSessionIds') + ->willReturn([$uuid]); + + $sessionData = json_encode(['resource_subscriptions' => []]); + $this->sessionStore->expects($this->once()) + ->method('read') + ->with($uuid) + ->willReturn($sessionData); + + $protocol->expects($this->never())->method('sendNotification'); + + $this->resourceSubscription->notifyResourceChanged($protocol, $uri); + } +}