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 08348e8a..2a327ae4 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..c39c2c2f 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\SessionSubscriptionManager; +use Mcp\Server\Resource\SubscriptionManagerInterface; 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 ?SubscriptionManagerInterface $subscriptionManager = null; + private ?LoggerInterface $logger = null; private ?CacheInterface $discoveryCache = null; @@ -309,6 +313,13 @@ public function setDiscoverer(DiscovererInterface $discoverer): self return $this; } + public function setResourceSubscriptionManager(SubscriptionManagerInterface $subscriptionManager): self + { + $this->subscriptionManager = $subscriptionManager; + + return $this; + } + public function setSession( SessionStoreInterface $sessionStore, SessionFactoryInterface $sessionFactory = new SessionFactory(), @@ -489,12 +500,16 @@ public function build(): Server $logger = $this->logger ?? new NullLogger(); $container = $this->container ?? new Container(); $registry = $this->registry ?? new Registry($this->eventDispatcher, $logger); - + $subscriptionManager = $this->subscriptionManager ?? new SessionSubscriptionManager($logger); $loaders = [ ...$this->loaders, new ArrayLoader($this->tools, $this->resources, $this->resourceTemplates, $this->prompts, $logger, $this->schemaGenerator), ]; + $sessionTtl = $this->sessionTtl ?? 3600; + $sessionFactory = $this->sessionFactory ?? new SessionFactory(); + $sessionStore = $this->sessionStore ?? new InMemorySessionStore($sessionTtl); + if (null !== $this->discoveryBasePath) { $discoverer = $this->discoverer ?? $this->createDiscoverer($logger); $loaders[] = new DiscoveryLoader($this->discoveryBasePath, $this->discoveryScanDirs, $this->discoveryExcludeDirs, $discoverer); @@ -504,16 +519,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 +548,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, $subscriptionManager, $logger), + new Handler\Request\ResourceUnsubscribeHandler($registry, $subscriptionManager, $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..ba0c8793 --- /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 SubscriptionManagerInterface $subscriptionManager, + 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->subscriptionManager->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..26db50e7 --- /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 SubscriptionManagerInterface $subscriptionManager, + 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->subscriptionManager->unsubscribe($session, $uri); + + return new Response( + $request->getId(), + new EmptyResult(), + ); + } +} diff --git a/src/Server/Resource/SessionSubscriptionManager.php b/src/Server/Resource/SessionSubscriptionManager.php new file mode 100644 index 00000000..f3ea4d5b --- /dev/null +++ b/src/Server/Resource/SessionSubscriptionManager.php @@ -0,0 +1,94 @@ + + */ +final class SessionSubscriptionManager implements SubscriptionManagerInterface +{ + public function __construct( + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + /** + * @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 isSubscribed(SessionInterface $session, string $uri): bool + { + $subscriptions = $session->get('resource_subscriptions', []); + + return isset($subscriptions[$uri]); + } + + /** + * @throws InvalidArgumentException + */ + public function notifyResourceChanged(Protocol $protocol, SessionInterface $session, string $uri): void + { + $activeSession = $this->isSubscribed($session, $uri); + if (!$activeSession) { + return; + } + + try { + $protocol->sendNotification( + new ResourceUpdatedNotification($uri), + $session + ); + } catch (InvalidArgumentException $e) { + $this->logger->error('Error sending resource notification to session', [ + 'session_id' => $session->getId()->toRfc4122(), + 'uri' => $uri, + 'exception' => $e, + ]); + + throw $e; + } + } +} diff --git a/src/Server/Resource/SubscriptionManagerInterface.php b/src/Server/Resource/SubscriptionManagerInterface.php new file mode 100644 index 00000000..b31f3d73 --- /dev/null +++ b/src/Server/Resource/SubscriptionManagerInterface.php @@ -0,0 +1,53 @@ + + */ +interface SubscriptionManagerInterface +{ + /** + * 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; + + /** + * Check if a session is subscribed to a resource URI. + * + * @throws InvalidArgumentException + */ + public function isSubscribed(SessionInterface $session, string $uri): bool; + + /** + * 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, SessionInterface $session, string $uri): void; +} diff --git a/tests/Conformance/conformance-baseline.yml b/tests/Conformance/conformance-baseline.yml index e1251a60..2613c0d4 100644 --- a/tests/Conformance/conformance-baseline.yml +++ b/tests/Conformance/conformance-baseline.yml @@ -2,6 +2,4 @@ server: - tools-call-elicitation - elicitation-sep1034-defaults - elicitation-sep1330-enums - - resources-subscribe - - resources-unsubscribe - dns-rebinding-protection diff --git a/tests/Conformance/server.php b/tests/Conformance/server.php index bbcbaa10..1b69b8f0 100644 --- a/tests/Conformance/server.php +++ b/tests/Conformance/server.php @@ -9,6 +9,8 @@ * 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; @@ -51,7 +53,6 @@ ->addResource(static fn () => 'This is the content of the static text resource.', 'test://static-text', 'static-text', 'A static text resource for testing') ->addResource(static 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(static fn () => 'Watched resource content', 'test://watched-resource', 'watched-resource', 'A resource that can be watched') // Prompts ->addPrompt(static 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..00512fa4 --- /dev/null +++ b/tests/Unit/Server/Handler/Request/ResourceSubscribeTest.php @@ -0,0 +1,142 @@ +registry = $this->createMock(RegistryInterface::class); + $this->subscriptionManager = $this->createMock(SubscriptionManagerInterface::class); + $this->session = $this->createMock(SessionInterface::class); + $this->handler = new ResourceSubscribeHandler($this->registry, $this->subscriptionManager); + } + + #[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->subscriptionManager->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->subscriptionManager + ->expects($this->exactly(2)) + ->method('subscribe') + ->with($this->session, $uri); + + $response1 = $this->handler->handle($request, $this->session); + $response2 = $this->handler->handle($request, $this->session); + + $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..509c256d --- /dev/null +++ b/tests/Unit/Server/Handler/Request/ResourceUnsubscribeTest.php @@ -0,0 +1,149 @@ +registry = $this->createMock(RegistryInterface::class); + $this->subscriptionManager = $this->createMock(SubscriptionManagerInterface::class); + $this->session = $this->createMock(SessionInterface::class); + + $this->handler = new ResourceUnsubscribeHandler($this->registry, $this->subscriptionManager); + } + + #[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->subscriptionManager->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->subscriptionManager + ->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/SessionSubscriptionManagerTest.php b/tests/Unit/Server/SessionSubscriptionManagerTest.php new file mode 100644 index 00000000..e0fd2847 --- /dev/null +++ b/tests/Unit/Server/SessionSubscriptionManagerTest.php @@ -0,0 +1,176 @@ +logger = $this->createMock(LoggerInterface::class); + $this->protocol = $this->createMock(Protocol::class); + $this->subscriptionManager = new SessionSubscriptionManager($this->logger); + } + + #[TestDox('Subscribing to a resource sends update notifications')] + public function testSubscribeAndSendsNotification(): void + { + // Arrange + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + $uri = 'test://resource'; + + $session->method('get') + ->with('resource_subscriptions', []) + ->willReturnOnConsecutiveCalls( + [], + [$uri => true] + ); + + $session->expects($this->once())->method('set')->with('resource_subscriptions', [$uri => true]); + $session->expects($this->once())->method('save'); + + // Act + $this->subscriptionManager->subscribe($session, $uri); + + // Assert + $this->protocol->expects($this->once()) + ->method('sendNotification') + ->with($this->isInstanceOf(ResourceUpdatedNotification::class)); + + $this->subscriptionManager->notifyResourceChanged($this->protocol, $session, $uri); + } + + #[TestDox('Unsubscribe from a resource')] + public function testUnsubscribeFromAResource(): void + { + // Arrange + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + $uri = 'test://resource'; + + $session->method('get') + ->with('resource_subscriptions', []) + ->willReturnOnConsecutiveCalls( + [], + [$uri => true], + [$uri => true], + ); + + $session->expects($this->exactly(2))->method('set'); + $session->expects($this->exactly(2))->method('save'); + + // Act + $this->subscriptionManager->subscribe($session, $uri); + + $this->protocol->expects($this->once())->method('sendNotification'); + $this->subscriptionManager->notifyResourceChanged($this->protocol, $session, $uri); + + $this->subscriptionManager->unsubscribe($session, $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); + $session->method('getId')->willReturn(Uuid::v4()); + $uri = 'test://resource'; + + $session->method('get') + ->with('resource_subscriptions', []) + ->willReturnOnConsecutiveCalls( + [], + [$uri => true], + [] + ); + + $session->expects($this->exactly(2))->method('set'); + $session->expects($this->exactly(2))->method('save'); + + // Act + $this->subscriptionManager->subscribe($session, $uri); + $this->subscriptionManager->unsubscribe($session, $uri); + + // Assert + $protocol->expects($this->never())->method('sendNotification'); + $this->subscriptionManager->notifyResourceChanged($protocol, $session, $uri); + } + + #[TestDox('Logs error when notification fails to send')] + public function testLogsErrorWhenNotificationFails(): void + { + // Arrange + $protocol = $this->createMock(Protocol::class); + $session = $this->createMock(SessionInterface::class); + $uuid = Uuid::v4(); + $session->method('getId')->willReturn($uuid); + $uri = 'test://resource'; + + $session->method('get') + ->with('resource_subscriptions', []) + ->willReturnOnConsecutiveCalls( + [], + [$uri => true] + ); + + $session->expects($this->once())->method('set')->with('resource_subscriptions', [$uri => true]); + $session->expects($this->once())->method('save'); + + $this->subscriptionManager->subscribe($session, $uri); + + // Create a concrete exception that implements InvalidArgumentException + $exception = new class('Cache error') extends \Exception implements InvalidArgumentException {}; + + $protocol->expects($this->once()) + ->method('sendNotification') + ->willThrowException($exception); + + $this->logger->expects($this->once()) + ->method('error') + ->with( + 'Error sending resource notification to session', + $this->callback(static function ($context) use ($uuid, $uri, $exception) { + return $context['session_id'] === (string) $uuid + && $context['uri'] === $uri + && $context['exception'] === $exception; + }) + ); + + try { + // Act + $this->subscriptionManager->notifyResourceChanged($protocol, $session, $uri); + + $this->fail('Expected an exception to be thrown.'); + } catch (InvalidArgumentException $e) { + // Assert + $this->assertSame($exception, $e); + + return; + } + } +}