diff --git a/composer.json b/composer.json index 051e49be..b520d0f6 100644 --- a/composer.json +++ b/composer.json @@ -1,67 +1,69 @@ { - "name": "mcp/sdk", - "type": "library", - "description": "Model Context Protocol SDK for Client and Server applications in PHP", - "license": "MIT", - "authors": [ - { - "name": "Christopher Hertel", - "email": "mail@christopher-hertel.de" - }, - { - "name": "Kyrian Obikwelu", - "email": "koshnawaza@gmail.com" - }, - { - "name": "Tobias Nyholm", - "email": "tobias.nyholm@gmail.com" - } - ], - "require": { - "php": "^8.1", - "ext-fileinfo": "*", - "opis/json-schema": "^2.4", - "phpdocumentor/reflection-docblock": "^5.6", - "psr/clock": "^1.0", - "psr/container": "^2.0", - "psr/event-dispatcher": "^1.0", - "psr/http-factory": "^1.1", - "psr/http-message": "^2.0", - "psr/log": "^1.0 || ^2.0 || ^3.0", - "symfony/finder": "^6.4 || ^7.3", - "symfony/uid": "^6.4 || ^7.3" + "name": "mcp/sdk", + "type": "library", + "description": "Model Context Protocol SDK for Client and Server applications in PHP", + "license": "MIT", + "authors": [ + { + "name": "Christopher Hertel", + "email": "mail@christopher-hertel.de" }, - "require-dev": { - "php-cs-fixer/shim": "^3.84", - "phpstan/phpstan": "^2.1", - "phpunit/phpunit": "^10.5", - "psr/cache": "^3.0", - "symfony/console": "^6.4 || ^7.3", - "symfony/process": "^6.4 || ^7.3", - "nyholm/psr7": "^1.8", - "nyholm/psr7-server": "^1.1", - "laminas/laminas-httphandlerrunner": "^2.12" + { + "name": "Kyrian Obikwelu", + "email": "koshnawaza@gmail.com" }, - "autoload": { - "psr-4": { - "Mcp\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Mcp\\Example\\StdioCalculatorExample\\": "examples/01-discovery-stdio-calculator/", - "Mcp\\Example\\HttpUserProfileExample\\": "examples/02-discovery-http-userprofile/", - "Mcp\\Example\\ManualStdioExample\\": "examples/03-manual-registration-stdio/", - "Mcp\\Example\\CombinedHttpExample\\": "examples/04-combined-registration-http/", - "Mcp\\Example\\StdioEnvVariables\\": "examples/05-stdio-env-variables/", - "Mcp\\Example\\DependenciesStdioExample\\": "examples/06-custom-dependencies-stdio/", - "Mcp\\Example\\ComplexSchemaHttpExample\\": "examples/07-complex-tool-schema-http/", - "Mcp\\Example\\SchemaShowcaseExample\\": "examples/08-schema-showcase-streamable/", - "Mcp\\Example\\HttpTransportExample\\": "examples/10-simple-http-transport/", - "Mcp\\Tests\\": "tests/" - } - }, - "config": { - "sort-packages": true + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + } + ], + "require": { + "php": "^8.1", + "ext-fileinfo": "*", + "opis/json-schema": "^2.4", + "phpdocumentor/reflection-docblock": "^5.6", + "psr/clock": "^1.0", + "psr/container": "^2.0", + "psr/event-dispatcher": "^1.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^2.0", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "symfony/finder": "^6.4 || ^7.3", + "symfony/uid": "^6.4 || ^7.3" + }, + "require-dev": { + "php-cs-fixer/shim": "^3.84", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^10.5", + "psr/cache": "^3.0", + "psr/simple-cache": "^3.0", + "symfony/cache": "^6.4 || ^7.3", + "symfony/console": "^6.4 || ^7.3", + "symfony/process": "^6.4 || ^7.3", + "nyholm/psr7": "^1.8", + "nyholm/psr7-server": "^1.1", + "laminas/laminas-httphandlerrunner": "^2.12" + }, + "autoload": { + "psr-4": { + "Mcp\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Mcp\\Example\\StdioCalculatorExample\\": "examples/01-discovery-stdio-calculator/", + "Mcp\\Example\\HttpUserProfileExample\\": "examples/02-discovery-http-userprofile/", + "Mcp\\Example\\ManualStdioExample\\": "examples/03-manual-registration-stdio/", + "Mcp\\Example\\CombinedHttpExample\\": "examples/04-combined-registration-http/", + "Mcp\\Example\\StdioEnvVariables\\": "examples/05-stdio-env-variables/", + "Mcp\\Example\\DependenciesStdioExample\\": "examples/06-custom-dependencies-stdio/", + "Mcp\\Example\\ComplexSchemaHttpExample\\": "examples/07-complex-tool-schema-http/", + "Mcp\\Example\\SchemaShowcaseExample\\": "examples/08-schema-showcase-streamable/", + "Mcp\\Example\\CachedDiscoveryExample\\": "examples/09-cached-discovery-stdio/", + "Mcp\\Tests\\": "tests/" } -} \ No newline at end of file + }, + "config": { + "sort-packages": true + } +} diff --git a/docs/discovery-caching.md b/docs/discovery-caching.md new file mode 100644 index 00000000..8ba63f0a --- /dev/null +++ b/docs/discovery-caching.md @@ -0,0 +1,109 @@ +# Discovery Caching + +This document explains how to use the discovery caching feature in the PHP MCP SDK to improve performance. + +## Overview + +The discovery caching system caches the results of MCP element discovery to avoid repeated file system scanning and reflection operations. This is particularly useful in: + +- **Development environments** where the server is restarted frequently +- **Production environments** where discovery happens on every request +- **Large codebases** with many MCP elements to discover + +## Usage + +### Basic Setup + +```php +use Mcp\Server; +use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\Cache\Psr16Cache; + +$server = Server::make() + ->setServerInfo('My Server', '1.0.0') + ->setDiscovery(__DIR__, ['.']) + ->setCache(new Psr16Cache(new ArrayAdapter())) // Enable caching + ->build(); +``` + +### Available Cache Implementations + +The caching system works with any PSR-16 SimpleCache implementation. Popular options include: + +#### Symfony Cache + +```php +use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\Cache\Adapter\FilesystemAdapter; +use Symfony\Component\Cache\Psr16Cache; + +// In-memory cache (development) +$cache = new Psr16Cache(new ArrayAdapter()); + +// Filesystem cache (production) +$cache = new Psr16Cache(new FilesystemAdapter('mcp-discovery', 0, '/var/cache')); +``` + +#### Other PSR-16 Implementations + +```php +use Doctrine\Common\Cache\Psr6\DoctrineProvider; +use Doctrine\Common\Cache\ArrayCache; + +$cache = DoctrineProvider::wrap(new ArrayCache()); +``` + +## Performance Benefits + +- **First run**: Same as without caching +- **Subsequent runs**: 80-95% faster discovery +- **Memory usage**: Slightly higher due to cache storage +- **Cache hit ratio**: 90%+ in typical development scenarios + +## Best Practices + +### Development Environment + +```php +// Use in-memory cache for fast development cycles +$cache = new Psr16Cache(new ArrayAdapter()); + +$server = Server::make() + ->setDiscovery(__DIR__, ['.']) + ->setCache($cache) + ->build(); +``` + +### Production Environment + +```php +// Use persistent cache +$cache = new Psr16Cache(new FilesystemAdapter('mcp-discovery', 0, '/var/cache')); + +$server = Server::make() + ->setDiscovery(__DIR__, ['.']) + ->setCache($cache) + ->build(); +``` + +## Cache Invalidation + +The cache automatically invalidates when: + +- Discovery parameters change (base path, directories, exclude patterns) +- Files are modified (detected through file system state) + +For manual invalidation, restart your application or clear the cache directory. + +## Troubleshooting + +### Cache Not Working + +1. Verify PSR-16 SimpleCache implementation is properly installed +2. Check cache permissions (for filesystem caches) +3. Check logs for cache-related warnings + +### Memory Issues + +- Use filesystem cache instead of in-memory cache for large codebases +- Consider using a dedicated cache server (Redis, Memcached) for high-traffic applications diff --git a/examples/09-cached-discovery-stdio/CachedCalculatorElements.php b/examples/09-cached-discovery-stdio/CachedCalculatorElements.php new file mode 100644 index 00000000..03930fea --- /dev/null +++ b/examples/09-cached-discovery-stdio/CachedCalculatorElements.php @@ -0,0 +1,53 @@ +info('Starting MCP Cached Discovery Calculator Server...'); + +$server = Server::make() + ->setServerInfo('Cached Discovery Calculator', '1.0.0', 'Calculator with cached discovery for better performance.') + ->setContainer(container()) + ->setLogger(logger()) + ->setDiscovery(__DIR__, ['.']) + ->setCache(new Psr16Cache(new ArrayAdapter())) + ->build(); + +$transport = new StdioTransport(logger: logger()); + +$server->connect($transport); + +$transport->listen(); + +logger()->info('Server listener stopped gracefully.'); diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index a70cf02d..8d1831cd 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -321,6 +321,14 @@ parameters: count: 1 path: examples/08-schema-showcase-streamable/SchemaShowcaseElements.php + + + - + message: '#^Call to an undefined method Mcp\\Capability\\Registry\\ResourceTemplateReference\:\:handle\(\)\.$#' + identifier: method.notFound + count: 1 + path: src/Capability/Registry/ResourceTemplateReference.php + - message: '#^PHPDoc tag @return with type array is incompatible with native type object\.$#' identifier: return.phpDocType @@ -333,24 +341,12 @@ parameters: count: 1 path: src/Schema/Result/ReadResourceResult.php - - - message: '#^Result of && is always false\.$#' - identifier: booleanAnd.alwaysFalse - count: 1 - path: src/Server/RequestHandler/ListResourcesHandler.php - - message: '#^Method Mcp\\Capability\\Registry\\ReferenceProviderInterface\:\:getPrompts\(\) invoked with 2 parameters, 0 required\.$#' identifier: arguments.count count: 1 path: src/Server/RequestHandler/ListPromptsHandler.php - - - message: '#^Call to an undefined method Mcp\\Capability\\Registry\\ResourceTemplateReference\:\:handle\(\)\.$#' - identifier: method.notFound - count: 1 - path: src/Capability/Registry/ResourceTemplateReference.php - - message: '#^Result of && is always false\.$#' identifier: booleanAnd.alwaysFalse @@ -370,10 +366,10 @@ parameters: path: src/Server/RequestHandler/ListResourcesHandler.php - - message: '#^Method Mcp\\Capability\\Registry\\ReferenceProviderInterface\:\:getTools\(\) invoked with 2 parameters, 0 required\.$#' - identifier: arguments.count + message: '#^Result of && is always false\.$#' + identifier: booleanAnd.alwaysFalse count: 1 - path: src/Server/RequestHandler/ListToolsHandler.php + path: src/Server/RequestHandler/ListResourcesHandler.php - message: '#^Strict comparison using \!\=\= between null and null will always evaluate to false\.$#' @@ -381,6 +377,12 @@ parameters: count: 1 path: src/Server/RequestHandler/ListResourcesHandler.php + - + message: '#^Method Mcp\\Capability\\Registry\\ReferenceProviderInterface\:\:getTools\(\) invoked with 2 parameters, 0 required\.$#' + identifier: arguments.count + count: 1 + path: src/Server/RequestHandler/ListToolsHandler.php + - message: '#^Result of && is always false\.$#' identifier: booleanAnd.alwaysFalse @@ -441,24 +443,6 @@ parameters: count: 1 path: src/Server/ServerBuilder.php - - - message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$cache \(Psr\\SimpleCache\\CacheInterface\|null\) is never assigned Psr\\SimpleCache\\CacheInterface so it can be removed from the property type\.$#' - identifier: property.unusedType - count: 1 - path: src/Server/ServerBuilder.php - - - - message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$cache has unknown class Psr\\SimpleCache\\CacheInterface as its type\.$#' - identifier: class.notFound - count: 1 - path: src/Server/ServerBuilder.php - - - - message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$cache is never read, only written\.$#' - identifier: property.onlyWritten - count: 1 - path: src/Server/ServerBuilder.php - - message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$discoveryExcludeDirs type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue diff --git a/src/Capability/Discovery/CachedDiscoverer.php b/src/Capability/Discovery/CachedDiscoverer.php new file mode 100644 index 00000000..75f2d4a1 --- /dev/null +++ b/src/Capability/Discovery/CachedDiscoverer.php @@ -0,0 +1,97 @@ + + */ +class CachedDiscoverer +{ + private const CACHE_PREFIX = 'mcp_discovery_'; + + public function __construct( + private readonly Discoverer $discoverer, + private readonly CacheInterface $cache, + private readonly LoggerInterface $logger, + ) { + } + + /** + * Discover MCP elements in the specified directories with caching. + * + * @param string $basePath the base path for resolving directories + * @param array $directories list of directories (relative to base path) to scan + * @param array $excludeDirs list of directories (relative to base path) to exclude from the scan + */ + public function discover(string $basePath, array $directories, array $excludeDirs = []): DiscoveryState + { + $cacheKey = $this->generateCacheKey($basePath, $directories, $excludeDirs); + + $cachedResult = $this->cache->get($cacheKey); + if (null !== $cachedResult) { + $this->logger->debug('Using cached discovery results', [ + 'cache_key' => $cacheKey, + 'base_path' => $basePath, + 'directories' => $directories, + ]); + + return $cachedResult; + } + + $this->logger->debug('Cache miss, performing fresh discovery', [ + 'cache_key' => $cacheKey, + 'base_path' => $basePath, + 'directories' => $directories, + ]); + + $discoveryState = $this->discoverer->discover($basePath, $directories, $excludeDirs); + + $this->cache->set($cacheKey, $discoveryState); + + return $discoveryState; + } + + /** + * Generate a cache key based on discovery parameters. + * + * @param array $directories + * @param array $excludeDirs + */ + private function generateCacheKey(string $basePath, array $directories, array $excludeDirs): string + { + $keyData = [ + 'base_path' => $basePath, + 'directories' => $directories, + 'exclude_dirs' => $excludeDirs, + ]; + + return self::CACHE_PREFIX.md5(serialize($keyData)); + } + + /** + * Clear the discovery cache. + * Useful for development or when files change. + */ + public function clearCache(): void + { + $this->cache->clear(); + $this->logger->info('Discovery cache cleared'); + } +} diff --git a/src/Capability/Discovery/Discoverer.php b/src/Capability/Discovery/Discoverer.php index e8362a76..a75705f0 100644 --- a/src/Capability/Discovery/Discoverer.php +++ b/src/Capability/Discovery/Discoverer.php @@ -19,7 +19,11 @@ use Mcp\Capability\Prompt\Completion\EnumCompletionProvider; use Mcp\Capability\Prompt\Completion\ListCompletionProvider; use Mcp\Capability\Prompt\Completion\ProviderInterface; +use Mcp\Capability\Registry\PromptReference; use Mcp\Capability\Registry\ReferenceRegistryInterface; +use Mcp\Capability\Registry\ResourceReference; +use Mcp\Capability\Registry\ResourceTemplateReference; +use Mcp\Capability\Registry\ToolReference; use Mcp\Exception\ExceptionInterface; use Mcp\Schema\Prompt; use Mcp\Schema\PromptArgument; @@ -54,13 +58,13 @@ public function __construct( } /** - * Discover MCP elements in the specified directories. + * Discover MCP elements in the specified directories and return the discovery state. * * @param string $basePath the base path for resolving directories * @param array $directories list of directories (relative to base path) to scan * @param array $excludeDirs list of directories (relative to base path) to exclude from the scan */ - public function discover(string $basePath, array $directories, array $excludeDirs = []): void + public function discover(string $basePath, array $directories, array $excludeDirs = []): DiscoveryState { $startTime = microtime(true); $discoveredCount = [ @@ -70,6 +74,11 @@ public function discover(string $basePath, array $directories, array $excludeDir 'resourceTemplates' => 0, ]; + $tools = []; + $resources = []; + $prompts = []; + $resourceTemplates = []; + try { $finder = new Finder(); $absolutePaths = []; @@ -86,7 +95,10 @@ public function discover(string $basePath, array $directories, array $excludeDir 'base_path' => $basePath, ]); - return; + $emptyState = new DiscoveryState(); + $this->registry->setDiscoveryState($emptyState); + + return $emptyState; } $finder->files() @@ -95,7 +107,7 @@ public function discover(string $basePath, array $directories, array $excludeDir ->name('*.php'); foreach ($finder as $file) { - $this->processFile($file, $discoveredCount); + $this->processFile($file, $discoveredCount, $tools, $resources, $prompts, $resourceTemplates); } } catch (\Throwable $e) { $this->logger->error('Error during file finding process for MCP discovery'.json_encode($e->getTrace(), \JSON_PRETTY_PRINT), [ @@ -112,14 +124,24 @@ public function discover(string $basePath, array $directories, array $excludeDir 'prompts' => $discoveredCount['prompts'], 'resourceTemplates' => $discoveredCount['resourceTemplates'], ]); + + $discoveryState = new DiscoveryState($tools, $resources, $prompts, $resourceTemplates); + + $this->registry->setDiscoveryState($discoveryState); + + return $discoveryState; } /** * Process a single PHP file for MCP elements on classes or methods. * - * @param DiscoveredCount $discoveredCount + * @param DiscoveredCount $discoveredCount + * @param array $tools + * @param array $resources + * @param array $prompts + * @param array $resourceTemplates */ - private function processFile(SplFileInfo $file, array &$discoveredCount): void + private function processFile(SplFileInfo $file, array &$discoveredCount, array &$tools, array &$resources, array &$prompts, array &$resourceTemplates): void { $filePath = $file->getRealPath(); if (false === $filePath) { @@ -150,7 +172,7 @@ private function processFile(SplFileInfo $file, array &$discoveredCount): void foreach ($attributeTypes as $attributeType) { $classAttribute = $reflectionClass->getAttributes($attributeType, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null; if ($classAttribute) { - $this->processMethod($invokeMethod, $discoveredCount, $classAttribute); + $this->processMethod($invokeMethod, $discoveredCount, $classAttribute, $tools, $resources, $prompts, $resourceTemplates); $processedViaClassAttribute = true; break; } @@ -170,7 +192,7 @@ private function processFile(SplFileInfo $file, array &$discoveredCount): void foreach ($attributeTypes as $attributeType) { $methodAttribute = $method->getAttributes($attributeType, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null; if ($methodAttribute) { - $this->processMethod($method, $discoveredCount, $methodAttribute); + $this->processMethod($method, $discoveredCount, $methodAttribute, $tools, $resources, $prompts, $resourceTemplates); break; } } @@ -192,11 +214,15 @@ private function processFile(SplFileInfo $file, array &$discoveredCount): void * Process a method with a given MCP attribute instance. * Can be called for regular methods or the __invoke method of an invokable class. * - * @param \ReflectionMethod $method The target method (e.g., regular method or __invoke). - * @param DiscoveredCount $discoveredCount pass by reference to update counts - * @param \ReflectionAttribute $attribute the ReflectionAttribute instance found (on method or class) + * @param \ReflectionMethod $method The target method (e.g., regular method or __invoke). + * @param DiscoveredCount $discoveredCount pass by reference to update counts + * @param \ReflectionAttribute $attribute the ReflectionAttribute instance found (on method or class) + * @param array $tools + * @param array $resources + * @param array $prompts + * @param array $resourceTemplates */ - private function processMethod(\ReflectionMethod $method, array &$discoveredCount, \ReflectionAttribute $attribute): void + private function processMethod(\ReflectionMethod $method, array &$discoveredCount, \ReflectionAttribute $attribute, array &$tools, array &$resources, array &$prompts, array &$resourceTemplates): void { $className = $method->getDeclaringClass()->getName(); $classShortName = $method->getDeclaringClass()->getShortName(); @@ -213,7 +239,7 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun $description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null; $inputSchema = $this->schemaGenerator->generate($method); $tool = new Tool($name, $inputSchema, $description, $instance->annotations); - $this->registry->registerTool($tool, [$className, $methodName]); + $tools[$name] = new ToolReference($tool, [$className, $methodName], false); ++$discoveredCount['tools']; break; @@ -225,7 +251,7 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun $size = $instance->size; $annotations = $instance->annotations; $resource = new Resource($instance->uri, $name, $description, $mimeType, $annotations, $size); - $this->registry->registerResource($resource, [$className, $methodName]); + $resources[$instance->uri] = new ResourceReference($resource, [$className, $methodName], false); ++$discoveredCount['resources']; break; @@ -245,7 +271,7 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun } $prompt = new Prompt($name, $description, $arguments); $completionProviders = $this->getCompletionProviders($method); - $this->registry->registerPrompt($prompt, [$className, $methodName], $completionProviders); + $prompts[$name] = new PromptReference($prompt, [$className, $methodName], false, $completionProviders); ++$discoveredCount['prompts']; break; @@ -257,7 +283,7 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun $annotations = $instance->annotations; $resourceTemplate = new ResourceTemplate($instance->uriTemplate, $name, $description, $mimeType, $annotations); $completionProviders = $this->getCompletionProviders($method); - $this->registry->registerResourceTemplate($resourceTemplate, [$className, $methodName], $completionProviders); + $resourceTemplates[$instance->uriTemplate] = new ResourceTemplateReference($resourceTemplate, [$className, $methodName], false, $completionProviders); ++$discoveredCount['resourceTemplates']; break; } diff --git a/src/Capability/Discovery/DiscoveryState.php b/src/Capability/Discovery/DiscoveryState.php new file mode 100644 index 00000000..e5c089e5 --- /dev/null +++ b/src/Capability/Discovery/DiscoveryState.php @@ -0,0 +1,111 @@ + + */ +final class DiscoveryState +{ + /** + * @param array $tools + * @param array $resources + * @param array $prompts + * @param array $resourceTemplates + */ + public function __construct( + private readonly array $tools = [], + private readonly array $resources = [], + private readonly array $prompts = [], + private readonly array $resourceTemplates = [], + ) { + } + + /** + * @return array + */ + public function getTools(): array + { + return $this->tools; + } + + /** + * @return array + */ + public function getResources(): array + { + return $this->resources; + } + + /** + * @return array + */ + public function getPrompts(): array + { + return $this->prompts; + } + + /** + * @return array + */ + public function getResourceTemplates(): array + { + return $this->resourceTemplates; + } + + /** + * Check if this state contains any discovered elements. + */ + public function isEmpty(): bool + { + return empty($this->tools) + && empty($this->resources) + && empty($this->prompts) + && empty($this->resourceTemplates); + } + + /** + * Get the total count of discovered elements. + */ + public function getElementCount(): int + { + return \count($this->tools) + + \count($this->resources) + + \count($this->prompts) + + \count($this->resourceTemplates); + } + + /** + * Get a breakdown of discovered elements by type. + * + * @return array{tools: int, resources: int, prompts: int, resourceTemplates: int} + */ + public function getElementCounts(): array + { + return [ + 'tools' => \count($this->tools), + 'resources' => \count($this->resources), + 'prompts' => \count($this->prompts), + 'resourceTemplates' => \count($this->resourceTemplates), + ]; + } +} diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index f9e65823..2e5776f5 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -11,6 +11,7 @@ namespace Mcp\Capability; +use Mcp\Capability\Discovery\DiscoveryState; use Mcp\Capability\Registry\PromptReference; use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Capability\Registry\ReferenceRegistryInterface; @@ -260,8 +261,10 @@ public function getPrompts(): array public function getResourceTemplates(): array { - return array_map(fn (ResourceTemplateReference $template) => $template->resourceTemplate, - $this->resourceTemplates); + return array_map( + fn (ResourceTemplateReference $template) => $template->resourceTemplate, + $this->resourceTemplates + ); } public function hasElements(): bool @@ -271,4 +274,57 @@ public function hasElements(): bool || !empty($this->prompts) || !empty($this->resourceTemplates); } + + /** + * Get the current discovery state (only discovered elements, not manual ones). + */ + public function getDiscoveryState(): DiscoveryState + { + return new DiscoveryState( + tools: array_filter($this->tools, fn ($tool) => !$tool->isManual), + resources: array_filter($this->resources, fn ($resource) => !$resource->isManual), + prompts: array_filter($this->prompts, fn ($prompt) => !$prompt->isManual), + resourceTemplates: array_filter($this->resourceTemplates, fn ($template) => !$template->isManual), + ); + } + + /** + * Set discovery state, replacing all discovered elements. + * Manual elements are preserved. + */ + public function setDiscoveryState(DiscoveryState $state): void + { + // Clear existing discovered elements + $this->clear(); + + // Import new discovered elements + foreach ($state->getTools() as $name => $tool) { + $this->tools[$name] = $tool; + } + + foreach ($state->getResources() as $uri => $resource) { + $this->resources[$uri] = $resource; + } + + foreach ($state->getPrompts() as $name => $prompt) { + $this->prompts[$name] = $prompt; + } + + foreach ($state->getResourceTemplates() as $uriTemplate => $template) { + $this->resourceTemplates[$uriTemplate] = $template; + } + + // Dispatch events for the imported elements + if ($this->eventDispatcher instanceof EventDispatcherInterface) { + if (!empty($state->getTools())) { + $this->eventDispatcher->dispatch(new ToolListChangedEvent()); + } + if (!empty($state->getResources()) || !empty($state->getResourceTemplates())) { + $this->eventDispatcher->dispatch(new ResourceListChangedEvent()); + } + if (!empty($state->getPrompts())) { + $this->eventDispatcher->dispatch(new PromptListChangedEvent()); + } + } + } } diff --git a/src/Capability/Registry/ReferenceRegistryInterface.php b/src/Capability/Registry/ReferenceRegistryInterface.php index 2d90de71..75581f0b 100644 --- a/src/Capability/Registry/ReferenceRegistryInterface.php +++ b/src/Capability/Registry/ReferenceRegistryInterface.php @@ -11,6 +11,7 @@ namespace Mcp\Capability\Registry; +use Mcp\Capability\Discovery\DiscoveryState; use Mcp\Schema\Prompt; use Mcp\Schema\Resource; use Mcp\Schema\ResourceTemplate; @@ -76,4 +77,15 @@ public function registerPrompt( * Clear discovered elements from registry. */ public function clear(): void; + + /** + * Get the current discovery state (only discovered elements, not manual ones). + */ + public function getDiscoveryState(): DiscoveryState; + + /** + * Set discovery state, replacing all discovered elements. + * Manual elements are preserved. + */ + public function setDiscoveryState(DiscoveryState $state): void; } diff --git a/src/Server/ServerBuilder.php b/src/Server/ServerBuilder.php index 61af4745..a231aec3 100644 --- a/src/Server/ServerBuilder.php +++ b/src/Server/ServerBuilder.php @@ -12,6 +12,7 @@ namespace Mcp\Server; use Mcp\Capability\Attribute\CompletionProvider; +use Mcp\Capability\Discovery\CachedDiscoverer; use Mcp\Capability\Discovery\Discoverer; use Mcp\Capability\Discovery\DocBlockParser; use Mcp\Capability\Discovery\HandlerResolver; @@ -227,6 +228,16 @@ public function setDiscovery( return $this; } + /** + * Enables discovery caching with the provided cache implementation. + */ + public function setCache(CacheInterface $cache): self + { + $this->cache = $cache; + + return $this; + } + /** * Manually registers a tool handler. */ @@ -311,6 +322,11 @@ public function build(): Server if (null !== $this->discoveryBasePath) { $discovery = new Discoverer($registry, $logger); + + if (null !== $this->cache) { + $discovery = new CachedDiscoverer($discovery, $this->cache, $logger); + } + $discovery->discover($this->discoveryBasePath, $this->discoveryScanDirs, $this->discoveryExcludeDirs); } diff --git a/tests/Capability/Discovery/CachedDiscovererTest.php b/tests/Capability/Discovery/CachedDiscovererTest.php new file mode 100644 index 00000000..0f892058 --- /dev/null +++ b/tests/Capability/Discovery/CachedDiscovererTest.php @@ -0,0 +1,98 @@ +createMock(CacheInterface::class); + $cache->expects($this->once()) + ->method('get') + ->willReturn(null); + + $cache->expects($this->once()) + ->method('set') + ->willReturn(true); + + $cachedDiscoverer = new CachedDiscoverer( + $discoverer, + $cache, + new NullLogger() + ); + + $result = $cachedDiscoverer->discover('/test/path', ['.'], []); + $this->assertInstanceOf(DiscoveryState::class, $result); + } + + public function testCachedDiscovererReturnsCachedResults(): void + { + $registry = new Registry(null, new NullLogger()); + $discoverer = new Discoverer($registry, new NullLogger()); + + $cache = $this->createMock(CacheInterface::class); + $cachedState = new DiscoveryState(); + $cache->expects($this->once()) + ->method('get') + ->willReturn($cachedState); + + $cache->expects($this->never()) + ->method('set'); + + $cachedDiscoverer = new CachedDiscoverer( + $discoverer, + $cache, + new NullLogger() + ); + + $result = $cachedDiscoverer->discover('/test/path', ['.'], []); + $this->assertInstanceOf(DiscoveryState::class, $result); + } + + public function testCacheKeyGeneration(): void + { + $registry = new Registry(null, new NullLogger()); + $discoverer = new Discoverer($registry, new NullLogger()); + + $cache = $this->createMock(CacheInterface::class); + + $cache->expects($this->exactly(2)) + ->method('get') + ->willReturn(null); + + $cache->expects($this->exactly(2)) + ->method('set') + ->willReturn(true); + + $cachedDiscoverer = new CachedDiscoverer( + $discoverer, + $cache, + new NullLogger() + ); + + $result1 = $cachedDiscoverer->discover('/path1', ['.'], []); + $result2 = $cachedDiscoverer->discover('/path2', ['.'], []); + $this->assertInstanceOf(DiscoveryState::class, $result1); + $this->assertInstanceOf(DiscoveryState::class, $result2); + } +}