Skip to content

Commit 8396bc3

Browse files
committed
refactor: eliminate reflection in discovery caching system
- Create DiscoveryState class to represent discovered capabilities - Add exportDiscoveryState() and importDiscoveryState() methods to Registry - Refactor Discoverer to return DiscoveryState instead of modifying registry directly - Rewrite CachedDiscoverer to use state-based caching instead of reflection - Update ServerBuilder to work with new discovery flow - Update all tests to use the new approach - Add comprehensive documentation explaining the refactoring This addresses reviewer feedback about avoiding reflection-based implementation while maintaining the same performance benefits and backward compatibility. Resolves the OOP design concerns by: - Eliminating reflection usage completely - Creating proper state objects for discovered capabilities - Adding clean interfaces for state import/export - Separating discovery logic from registry state management - Maintaining encapsulation and following proper OOP principles
1 parent e84287a commit 8396bc3

File tree

9 files changed

+301
-152
lines changed

9 files changed

+301
-152
lines changed

docs/discovery-caching.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,9 @@ See the `examples/10-cached-discovery-stdio/` directory for a complete working e
116116

117117
## Implementation Details
118118

119-
The caching system uses the decorator pattern:
119+
The caching system uses the decorator pattern with a clean state-based approach:
120120
- `CachedDiscoverer` wraps the existing `Discoverer` class
121-
- Uses reflection to extract and restore registry state
121+
- Uses `DiscoveryState` objects to represent discovered capabilities
122122
- Only caches discovered elements (not manually registered ones)
123-
- Maintains backward compatibility - works with or without cache
123+
- Maintains backward compatibility - works with or without cache
124+
- Clean separation of concerns between discovery and registry state management

src/Capability/Discovery/CachedDiscoverer.php

Lines changed: 17 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111

1212
namespace Mcp\Capability\Discovery;
1313

14-
use Mcp\Capability\Registry;
1514
use Psr\Log\LoggerInterface;
1615
use Psr\SimpleCache\CacheInterface;
1716

@@ -43,7 +42,7 @@ public function __construct(
4342
* @param array<string> $directories list of directories (relative to base path) to scan
4443
* @param array<string> $excludeDirs list of directories (relative to base path) to exclude from the scan
4544
*/
46-
public function discover(string $basePath, array $directories, array $excludeDirs = []): void
45+
public function discover(string $basePath, array $directories, array $excludeDirs = []): DiscoveryState
4746
{
4847
$cacheKey = $this->generateCacheKey($basePath, $directories, $excludeDirs);
4948

@@ -56,9 +55,8 @@ public function discover(string $basePath, array $directories, array $excludeDir
5655
'directories' => $directories,
5756
]);
5857

59-
// Restore the registry state from cache
60-
$this->restoreRegistryFromCache($cachedResult);
61-
return;
58+
// Restore the discovery state from cache
59+
return $this->restoreDiscoveryStateFromCache($cachedResult);
6260
}
6361

6462
$this->logger->debug('Cache miss, performing fresh discovery', [
@@ -68,10 +66,12 @@ public function discover(string $basePath, array $directories, array $excludeDir
6866
]);
6967

7068
// Perform fresh discovery
71-
$this->discoverer->discover($basePath, $directories, $excludeDirs);
69+
$discoveryState = $this->discoverer->discover($basePath, $directories, $excludeDirs);
7270

7371
// Cache the results
74-
$this->cacheDiscoveryResults($cacheKey);
72+
$this->cacheDiscoveryResults($cacheKey, $discoveryState);
73+
74+
return $discoveryState;
7575
}
7676

7777
/**
@@ -92,23 +92,21 @@ private function generateCacheKey(string $basePath, array $directories, array $e
9292
}
9393

9494
/**
95-
* Cache the current registry state.
95+
* Cache the discovery state.
9696
*/
97-
private function cacheDiscoveryResults(string $cacheKey): void
97+
private function cacheDiscoveryResults(string $cacheKey, DiscoveryState $state): void
9898
{
9999
try {
100-
// Get the registry from the discoverer
101-
$registry = $this->getRegistryFromDiscoverer();
102-
103-
// Extract registry state for caching
104-
$registryState = $this->extractRegistryState($registry);
100+
// Convert state to array for caching
101+
$stateData = $state->toArray();
105102

106103
// Store in cache
107-
$this->cache->set($cacheKey, $registryState, $this->cacheTtl);
104+
$this->cache->set($cacheKey, $stateData, $this->cacheTtl);
108105

109106
$this->logger->debug('Cached discovery results', [
110107
'cache_key' => $cacheKey,
111108
'ttl' => $this->cacheTtl,
109+
'element_count' => $state->getElementCount(),
112110
]);
113111
} catch (\Throwable $e) {
114112
$this->logger->warning('Failed to cache discovery results', [
@@ -119,126 +117,22 @@ private function cacheDiscoveryResults(string $cacheKey): void
119117
}
120118

121119
/**
122-
* Restore registry state from cached data.
120+
* Restore discovery state from cached data.
123121
*
124122
* @param array<string, mixed> $cachedResult
125123
*/
126-
private function restoreRegistryFromCache(array $cachedResult): void
124+
private function restoreDiscoveryStateFromCache(array $cachedResult): DiscoveryState
127125
{
128126
try {
129-
$registry = $this->getRegistryFromDiscoverer();
130-
$this->restoreRegistryState($registry, $cachedResult);
127+
return DiscoveryState::fromArray($cachedResult);
131128
} catch (\Throwable $e) {
132-
$this->logger->error('Failed to restore registry from cache', [
129+
$this->logger->error('Failed to restore discovery state from cache', [
133130
'exception' => $e->getMessage(),
134131
]);
135132
throw $e;
136133
}
137134
}
138135

139-
/**
140-
* Get the registry instance from the discoverer.
141-
* This uses reflection to access the private registry property.
142-
*/
143-
private function getRegistryFromDiscoverer(): Registry
144-
{
145-
$reflection = new \ReflectionClass($this->discoverer);
146-
147-
if (!$reflection->hasProperty('registry')) {
148-
throw new \RuntimeException('Discoverer does not have a registry property');
149-
}
150-
151-
$registryProperty = $reflection->getProperty('registry');
152-
$registryProperty->setAccessible(true);
153-
154-
return $registryProperty->getValue($this->discoverer);
155-
}
156-
157-
/**
158-
* Extract the current state of the registry for caching.
159-
*
160-
* @return array<string, mixed>
161-
*/
162-
private function extractRegistryState(Registry $registry): array
163-
{
164-
// Use reflection to access registry's internal state
165-
$reflection = new \ReflectionClass($registry);
166-
167-
$state = [];
168-
169-
// Extract tools (only discovered ones, not manual)
170-
if ($reflection->hasProperty('tools')) {
171-
$toolsProperty = $reflection->getProperty('tools');
172-
$toolsProperty->setAccessible(true);
173-
$tools = $toolsProperty->getValue($registry);
174-
$state['tools'] = array_filter($tools, fn($tool) => !$tool->isManual);
175-
}
176-
177-
// Extract resources (only discovered ones, not manual)
178-
if ($reflection->hasProperty('resources')) {
179-
$resourcesProperty = $reflection->getProperty('resources');
180-
$resourcesProperty->setAccessible(true);
181-
$resources = $resourcesProperty->getValue($registry);
182-
$state['resources'] = array_filter($resources, fn($resource) => !$resource->isManual);
183-
}
184-
185-
// Extract prompts (only discovered ones, not manual)
186-
if ($reflection->hasProperty('prompts')) {
187-
$promptsProperty = $reflection->getProperty('prompts');
188-
$promptsProperty->setAccessible(true);
189-
$prompts = $promptsProperty->getValue($registry);
190-
$state['prompts'] = array_filter($prompts, fn($prompt) => !$prompt->isManual);
191-
}
192-
193-
// Extract resource templates (only discovered ones, not manual)
194-
if ($reflection->hasProperty('resourceTemplates')) {
195-
$resourceTemplatesProperty = $reflection->getProperty('resourceTemplates');
196-
$resourceTemplatesProperty->setAccessible(true);
197-
$resourceTemplates = $resourceTemplatesProperty->getValue($registry);
198-
$state['resourceTemplates'] = array_filter($resourceTemplates, fn($template) => !$template->isManual);
199-
}
200-
201-
return $state;
202-
}
203-
204-
/**
205-
* Restore registry state from cached data.
206-
*
207-
* @param array<string, mixed> $cachedState
208-
*/
209-
private function restoreRegistryState(Registry $registry, array $cachedState): void
210-
{
211-
$reflection = new \ReflectionClass($registry);
212-
213-
// Restore tools
214-
if (isset($cachedState['tools']) && $reflection->hasProperty('tools')) {
215-
$toolsProperty = $reflection->getProperty('tools');
216-
$toolsProperty->setAccessible(true);
217-
$toolsProperty->setValue($registry, $cachedState['tools']);
218-
}
219-
220-
// Restore resources
221-
if (isset($cachedState['resources']) && $reflection->hasProperty('resources')) {
222-
$resourcesProperty = $reflection->getProperty('resources');
223-
$resourcesProperty->setAccessible(true);
224-
$resourcesProperty->setValue($registry, $cachedState['resources']);
225-
}
226-
227-
// Restore prompts
228-
if (isset($cachedState['prompts']) && $reflection->hasProperty('prompts')) {
229-
$promptsProperty = $reflection->getProperty('prompts');
230-
$promptsProperty->setAccessible(true);
231-
$promptsProperty->setValue($registry, $cachedState['prompts']);
232-
}
233-
234-
// Restore resource templates
235-
if (isset($cachedState['resourceTemplates']) && $reflection->hasProperty('resourceTemplates')) {
236-
$resourceTemplatesProperty = $reflection->getProperty('resourceTemplates');
237-
$resourceTemplatesProperty->setAccessible(true);
238-
$resourceTemplatesProperty->setValue($registry, $cachedState['resourceTemplates']);
239-
}
240-
}
241-
242136
/**
243137
* Clear the discovery cache.
244138
* Useful for development or when files change.

src/Capability/Discovery/Discoverer.php

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@
2020
use Mcp\Capability\Prompt\Completion\ListCompletionProvider;
2121
use Mcp\Capability\Prompt\Completion\ProviderInterface;
2222
use Mcp\Capability\Registry;
23+
use Mcp\Capability\Registry\PromptReference;
24+
use Mcp\Capability\Registry\ResourceReference;
25+
use Mcp\Capability\Registry\ResourceTemplateReference;
26+
use Mcp\Capability\Registry\ToolReference;
2327
use Mcp\Exception\ExceptionInterface;
2428
use Mcp\Schema\Prompt;
2529
use Mcp\Schema\PromptArgument;
@@ -54,13 +58,13 @@ public function __construct(
5458
}
5559

5660
/**
57-
* Discover MCP elements in the specified directories.
61+
* Discover MCP elements in the specified directories and return the discovery state.
5862
*
5963
* @param string $basePath the base path for resolving directories
6064
* @param array<string> $directories list of directories (relative to base path) to scan
6165
* @param array<string> $excludeDirs list of directories (relative to base path) to exclude from the scan
6266
*/
63-
public function discover(string $basePath, array $directories, array $excludeDirs = []): void
67+
public function discover(string $basePath, array $directories, array $excludeDirs = []): DiscoveryState
6468
{
6569
$startTime = microtime(true);
6670
$discoveredCount = [
@@ -70,6 +74,12 @@ public function discover(string $basePath, array $directories, array $excludeDir
7074
'resourceTemplates' => 0,
7175
];
7276

77+
// Collections to store discovered elements
78+
$tools = [];
79+
$resources = [];
80+
$prompts = [];
81+
$resourceTemplates = [];
82+
7383
try {
7484
$finder = new Finder();
7585
$absolutePaths = [];
@@ -86,7 +96,7 @@ public function discover(string $basePath, array $directories, array $excludeDir
8696
'base_path' => $basePath,
8797
]);
8898

89-
return;
99+
return new DiscoveryState();
90100
}
91101

92102
$finder->files()
@@ -95,7 +105,7 @@ public function discover(string $basePath, array $directories, array $excludeDir
95105
->name('*.php');
96106

97107
foreach ($finder as $file) {
98-
$this->processFile($file, $discoveredCount);
108+
$this->processFile($file, $discoveredCount, $tools, $resources, $prompts, $resourceTemplates);
99109
}
100110
} catch (\Throwable $e) {
101111
$this->logger->error('Error during file finding process for MCP discovery', [
@@ -112,14 +122,29 @@ public function discover(string $basePath, array $directories, array $excludeDir
112122
'prompts' => $discoveredCount['prompts'],
113123
'resourceTemplates' => $discoveredCount['resourceTemplates'],
114124
]);
125+
126+
return new DiscoveryState($tools, $resources, $prompts, $resourceTemplates);
127+
}
128+
129+
/**
130+
* Apply a discovery state to the registry.
131+
* This method imports the discovered elements into the registry.
132+
*/
133+
public function applyDiscoveryState(DiscoveryState $state): void
134+
{
135+
$this->registry->importDiscoveryState($state);
115136
}
116137

117138
/**
118139
* Process a single PHP file for MCP elements on classes or methods.
119140
*
120141
* @param DiscoveredCount $discoveredCount
142+
* @param array<string, ToolReference> $tools
143+
* @param array<string, ResourceReference> $resources
144+
* @param array<string, PromptReference> $prompts
145+
* @param array<string, ResourceTemplateReference> $resourceTemplates
121146
*/
122-
private function processFile(SplFileInfo $file, array &$discoveredCount): void
147+
private function processFile(SplFileInfo $file, array &$discoveredCount, array &$tools, array &$resources, array &$prompts, array &$resourceTemplates): void
123148
{
124149
$filePath = $file->getRealPath();
125150
if (false === $filePath) {
@@ -150,7 +175,7 @@ private function processFile(SplFileInfo $file, array &$discoveredCount): void
150175
foreach ($attributeTypes as $attributeType) {
151176
$classAttribute = $reflectionClass->getAttributes($attributeType, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null;
152177
if ($classAttribute) {
153-
$this->processMethod($invokeMethod, $discoveredCount, $classAttribute);
178+
$this->processMethod($invokeMethod, $discoveredCount, $classAttribute, $tools, $resources, $prompts, $resourceTemplates);
154179
$processedViaClassAttribute = true;
155180
break;
156181
}
@@ -170,7 +195,7 @@ private function processFile(SplFileInfo $file, array &$discoveredCount): void
170195
foreach ($attributeTypes as $attributeType) {
171196
$methodAttribute = $method->getAttributes($attributeType, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null;
172197
if ($methodAttribute) {
173-
$this->processMethod($method, $discoveredCount, $methodAttribute);
198+
$this->processMethod($method, $discoveredCount, $methodAttribute, $tools, $resources, $prompts, $resourceTemplates);
174199
break;
175200
}
176201
}
@@ -195,8 +220,12 @@ private function processFile(SplFileInfo $file, array &$discoveredCount): void
195220
* @param \ReflectionMethod $method The target method (e.g., regular method or __invoke).
196221
* @param DiscoveredCount $discoveredCount pass by reference to update counts
197222
* @param \ReflectionAttribute<McpTool|McpResource|McpPrompt|McpResourceTemplate> $attribute the ReflectionAttribute instance found (on method or class)
223+
* @param array<string, ToolReference> $tools
224+
* @param array<string, ResourceReference> $resources
225+
* @param array<string, PromptReference> $prompts
226+
* @param array<string, ResourceTemplateReference> $resourceTemplates
198227
*/
199-
private function processMethod(\ReflectionMethod $method, array &$discoveredCount, \ReflectionAttribute $attribute): void
228+
private function processMethod(\ReflectionMethod $method, array &$discoveredCount, \ReflectionAttribute $attribute, array &$tools, array &$resources, array &$prompts, array &$resourceTemplates): void
200229
{
201230
$className = $method->getDeclaringClass()->getName();
202231
$classShortName = $method->getDeclaringClass()->getShortName();
@@ -213,7 +242,7 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun
213242
$description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null;
214243
$inputSchema = $this->schemaGenerator->generate($method);
215244
$tool = new Tool($name, $inputSchema, $description, $instance->annotations);
216-
$this->registry->registerTool($tool, [$className, $methodName]);
245+
$tools[$name] = new ToolReference($tool, [$className, $methodName], false);
217246
++$discoveredCount['tools'];
218247
break;
219248

@@ -225,7 +254,7 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun
225254
$size = $instance->size;
226255
$annotations = $instance->annotations;
227256
$resource = new Resource($instance->uri, $name, $description, $mimeType, $annotations, $size);
228-
$this->registry->registerResource($resource, [$className, $methodName]);
257+
$resources[$instance->uri] = new ResourceReference($resource, [$className, $methodName], false);
229258
++$discoveredCount['resources'];
230259
break;
231260

@@ -245,7 +274,7 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun
245274
}
246275
$prompt = new Prompt($name, $description, $arguments);
247276
$completionProviders = $this->getCompletionProviders($method);
248-
$this->registry->registerPrompt($prompt, [$className, $methodName], $completionProviders);
277+
$prompts[$name] = new PromptReference($prompt, [$className, $methodName], false, $completionProviders);
249278
++$discoveredCount['prompts'];
250279
break;
251280

@@ -257,7 +286,7 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun
257286
$annotations = $instance->annotations;
258287
$resourceTemplate = new ResourceTemplate($instance->uriTemplate, $name, $description, $mimeType, $annotations);
259288
$completionProviders = $this->getCompletionProviders($method);
260-
$this->registry->registerResourceTemplate($resourceTemplate, [$className, $methodName], $completionProviders);
289+
$resourceTemplates[$instance->uriTemplate] = new ResourceTemplateReference($resourceTemplate, [$className, $methodName], false, $completionProviders);
261290
++$discoveredCount['resourceTemplates'];
262291
break;
263292
}

0 commit comments

Comments
 (0)