diff --git a/composer.json b/composer.json index 46ea2d77..d9f1507f 100644 --- a/composer.json +++ b/composer.json @@ -80,7 +80,10 @@ "Mcp\\Example\\Server\\OAuthMicrosoft\\": "examples/server/oauth-microsoft/", "Mcp\\Example\\Server\\SchemaShowcase\\": "examples/server/schema-showcase/", "Mcp\\Tests\\": "tests/" - } + }, + "classmap": [ + "tests/Unit/Capability/Discovery/Fixtures/AlternativeFileNameToolHandler.class.inc" + ] }, "config": { "allow-plugins": { @@ -89,4 +92,4 @@ }, "sort-packages": true } -} \ No newline at end of file +} diff --git a/docs/server-builder.md b/docs/server-builder.md index 1a8e4663..9e02f72f 100644 --- a/docs/server-builder.md +++ b/docs/server-builder.md @@ -99,8 +99,9 @@ $server = Server::builder() ->setDiscovery( basePath: __DIR__, scanDirs: ['.', 'src', 'lib'], // Where to look for MCP attributes - excludeDirs: ['vendor', 'tests'], // Where NOT to look - cache: $cacheInstance // Optional: cache discovered elements + excludeDirs: ['vendor', 'tests'], // Where NOT to look + cache: $cacheInstance, // Optional: cache discovered elements + namePatterns: ['*.php', '*.inc'], // Optional: list of filename patterns to match ); ``` @@ -109,6 +110,7 @@ $server = Server::builder() - `$scanDirs` (array): Directories to recursively scan for `#[McpTool]`, `#[McpResource]`, etc. All subdirectories are included. (default: `['.', 'src']`) - `$excludeDirs` (array): Directory names to exclude **within** the scanned directories during recursive scanning - `$cache` (CacheInterface|null): Optional PSR-16 cache to store discovered elements for performance +- `$namePatterns` (array): Optional list of Finder->name() compatible patterns to match against file names (default: `['*.php']`) **Basic Discovery (scans current directory and `src/`):** ```php @@ -137,7 +139,7 @@ $server = Server::builder() **How `excludeDirs` works:** - If scanning `src/` and there's `src/vendor/`, it will be excluded -- If scanning `lib/` and there's `lib/tests/`, it will be excluded +- If scanning `lib/` and there's `lib/tests/`, it will be excluded - But if `vendor/` and `tests/` are at the same level as `src/`, they're not scanned anyway (not in `scanDirs`) > **Performance**: Always use a cache in production. The first run scans and caches all discovered MCP elements, making @@ -255,19 +257,19 @@ $server = Server::builder() name: 'add_numbers', description: 'Adds two numbers together' ) - + // Using class method pair ->addTool( handler: [Calculator::class, 'multiply'], name: 'multiply_numbers' // name and description are optional - derived from method name and docblock ) - + // Using instance method ->addTool( handler: [$calculatorInstance, 'divide'] ) - + // Using invokable class ->addTool( handler: InvokableCalculator::class @@ -421,17 +423,17 @@ $server = Server::builder() individual JSON-RPC messages. They do not receive the builder's registry, container, or discovery output unless you pass those dependencies in yourself. -> **Warning**: Custom message handlers bypass discovery, manual capability registration, and container lookups (unless +> **Warning**: Custom message handlers bypass discovery, manual capability registration, and container lookups (unless > you explicitly pass them). Tools, resources, and prompts you register elsewhere will not show up unless your handler -> loads and executes them manually. Reach for this API only when you need that level of control and are comfortable +> loads and executes them manually. Reach for this API only when you need that level of control and are comfortable > taking on the additional plumbing. ### Request Handlers -Handle JSON-RPC requests (messages with an `id` that expect a response). Request handlers **must** return either a +Handle JSON-RPC requests (messages with an `id` that expect a response). Request handlers **must** return either a `Response` or an `Error` object. -Attach request handlers with `addRequestHandler()` (single) or `addRequestHandlers()` (multiple). You can call these +Attach request handlers with `addRequestHandler()` (single) or `addRequestHandlers()` (multiple). You can call these methods as many times as needed; each call prepends the handlers so they execute before the defaults: ```php @@ -508,7 +510,7 @@ interface NotificationHandlerInterface ### Example -Check out `examples/custom-method-handlers/server.php` for a complete example showing how to implement +Check out `examples/custom-method-handlers/server.php` for a complete example showing how to implement custom `tools/list` and `tools/call` request handlers independently of the registry. ## Complete Example @@ -540,25 +542,25 @@ $container->set(DatabaseService::class, new DatabaseService($container->get(\PDO $server = Server::builder() // Server identity ->setServerInfo('Advanced Calculator', '2.1.0') - + // Performance and behavior ->setPaginationLimit(100) ->setInstructions('Use calculate tool for math operations. Check config resource for current settings.') - + // Discovery with caching ->setDiscovery(__DIR__, ['src'], ['vendor', 'tests'], $cache) - + // Session management ->setSession($sessionStore) - + // Services ->setLogger($logger) ->setContainer($container) - + // Manual capability registration ->addTool([Calculator::class, 'advancedCalculation'], 'advanced_calc') ->addResource([Config::class, 'getSettings'], 'config://app/settings', 'app_settings') - + // Build the server ->build(); ``` diff --git a/src/Capability/Discovery/CachedDiscoverer.php b/src/Capability/Discovery/CachedDiscoverer.php index bf039736..e1450ac1 100644 --- a/src/Capability/Discovery/CachedDiscoverer.php +++ b/src/Capability/Discovery/CachedDiscoverer.php @@ -38,11 +38,12 @@ public function __construct( /** * 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 + * @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 + * @param array|null $namePatterns list of file name patterns for the scan. Compatible with Finder->name() */ - public function discover(string $basePath, array $directories, array $excludeDirs = []): DiscoveryState + public function discover(string $basePath, array $directories, array $excludeDirs = [], ?array $namePatterns = ['*.php']): DiscoveryState { $cacheKey = $this->generateCacheKey($basePath, $directories, $excludeDirs); @@ -63,7 +64,7 @@ public function discover(string $basePath, array $directories, array $excludeDir 'directories' => $directories, ]); - $discoveryState = $this->discoverer->discover($basePath, $directories, $excludeDirs); + $discoveryState = $this->discoverer->discover($basePath, $directories, $excludeDirs, $namePatterns); $this->cache->set($cacheKey, $discoveryState); diff --git a/src/Capability/Discovery/Discoverer.php b/src/Capability/Discovery/Discoverer.php index 2554a30d..2772b9ee 100644 --- a/src/Capability/Discovery/Discoverer.php +++ b/src/Capability/Discovery/Discoverer.php @@ -65,11 +65,12 @@ public function __construct( /** * 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 + * @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 + * @param array|null $namePatterns list of file name patterns for the scan. Compatible with Finder->name() */ - public function discover(string $basePath, array $directories, array $excludeDirs = []): DiscoveryState + public function discover(string $basePath, array $directories, array $excludeDirs = [], ?array $namePatterns = null): DiscoveryState { $startTime = microtime(true); $discoveredCount = [ @@ -84,6 +85,8 @@ public function discover(string $basePath, array $directories, array $excludeDir $prompts = []; $resourceTemplates = []; + $namePatterns = !empty($namePatterns) ? $namePatterns : ['*.php']; + try { $finder = new Finder(); $absolutePaths = []; @@ -106,7 +109,7 @@ public function discover(string $basePath, array $directories, array $excludeDir $finder->files() ->in($absolutePaths) ->exclude($excludeDirs) - ->name('*.php'); + ->name($namePatterns); foreach ($finder as $file) { $this->processFile($file, $discoveredCount, $tools, $resources, $prompts, $resourceTemplates); diff --git a/src/Capability/Discovery/DiscovererInterface.php b/src/Capability/Discovery/DiscovererInterface.php index 0de02de4..a86a0516 100644 --- a/src/Capability/Discovery/DiscovererInterface.php +++ b/src/Capability/Discovery/DiscovererInterface.php @@ -23,9 +23,10 @@ interface DiscovererInterface /** * 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 + * @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 + * @param array|null $namePatterns list of file name patterns for the scan. Compatible with Finder->name() */ - public function discover(string $basePath, array $directories, array $excludeDirs = []): DiscoveryState; + public function discover(string $basePath, array $directories, array $excludeDirs = [], ?array $namePatterns = null): DiscoveryState; } diff --git a/src/Capability/Registry/Loader/DiscoveryLoader.php b/src/Capability/Registry/Loader/DiscoveryLoader.php index 25261ff5..3f169326 100644 --- a/src/Capability/Registry/Loader/DiscoveryLoader.php +++ b/src/Capability/Registry/Loader/DiscoveryLoader.php @@ -24,18 +24,20 @@ final class DiscoveryLoader implements LoaderInterface /** * @param string[] $scanDirs * @param array|string[] $excludeDirs + * @param string[]|null $namePatterns */ public function __construct( private string $basePath, private array $scanDirs, private array $excludeDirs, private DiscovererInterface $discoverer, + private ?array $namePatterns = null, ) { } public function load(RegistryInterface $registry): void { - $discoveryState = $this->discoverer->discover($this->basePath, $this->scanDirs, $this->excludeDirs); + $discoveryState = $this->discoverer->discover($this->basePath, $this->scanDirs, $this->excludeDirs, $this->namePatterns); $registry->setDiscoveryState($discoveryState); } diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 953c4c2c..eede1d6e 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -160,6 +160,11 @@ final class Builder */ private array $discoveryExcludeDirs = []; + /** + * @var string[]|null + */ + private ?array $discoveryNamePatterns = null; + private ?ServerCapabilities $serverCapabilities = null; /** @@ -346,19 +351,22 @@ public function setSession( } /** - * @param string[] $scanDirs - * @param string[] $excludeDirs + * @param string[] $scanDirs + * @param string[] $excludeDirs + * @param string[]|null $namePatterns */ public function setDiscovery( string $basePath, array $scanDirs = ['.', 'src'], array $excludeDirs = [], ?CacheInterface $cache = null, + ?array $namePatterns = null, ): self { $this->discoveryBasePath = $basePath; $this->discoveryScanDirs = $scanDirs; $this->discoveryExcludeDirs = $excludeDirs; $this->discoveryCache = $cache; + $this->discoveryNamePatterns = $namePatterns; return $this; } @@ -531,7 +539,7 @@ public function build(): Server if (null !== $this->discoveryBasePath) { if (null !== $this->discoverer || class_exists(Finder::class)) { $discoverer = $this->discoverer ?? $this->createDiscoverer($logger); - $loaders[] = new DiscoveryLoader($this->discoveryBasePath, $this->discoveryScanDirs, $this->discoveryExcludeDirs, $discoverer); + $loaders[] = new DiscoveryLoader($this->discoveryBasePath, $this->discoveryScanDirs, $this->discoveryExcludeDirs, $discoverer, $this->discoveryNamePatterns); } else { $logger->warning('File-based discovery requires symfony/finder. Skipping automatic discovery. Run: composer require symfony/finder'); } diff --git a/tests/Unit/Capability/Discovery/DiscoveryTest.php b/tests/Unit/Capability/Discovery/DiscoveryTest.php index 81081063..1451361d 100644 --- a/tests/Unit/Capability/Discovery/DiscoveryTest.php +++ b/tests/Unit/Capability/Discovery/DiscoveryTest.php @@ -16,6 +16,7 @@ use Mcp\Capability\Discovery\Discoverer; use Mcp\Capability\Registry\ToolReference; use Mcp\Tests\Unit\Capability\Attribute\CompletionProviderFixture; +use Mcp\Tests\Unit\Capability\Discovery\Fixtures\AlternativeFileNameToolHandler; use Mcp\Tests\Unit\Capability\Discovery\Fixtures\DiscoverableToolHandler; use Mcp\Tests\Unit\Capability\Discovery\Fixtures\InvocablePromptFixture; use Mcp\Tests\Unit\Capability\Discovery\Fixtures\InvocableResourceFixture; @@ -34,10 +35,10 @@ protected function setUp(): void public function testDiscoversAllElementTypesCorrectlyFromFixtureFiles(): void { - $discovery = $this->discoverer->discover(__DIR__, ['Fixtures']); + $discovery = $this->discoverer->discover(__DIR__, ['Fixtures'], [], ['*.php', '*.inc']); $tools = $discovery->getTools(); - $this->assertCount(4, $tools); + $this->assertCount(5, $tools); $this->assertArrayHasKey('greet_user', $tools); $this->assertFalse($tools['greet_user']->isManual); @@ -56,6 +57,9 @@ public function testDiscoversAllElementTypesCorrectlyFromFixtureFiles(): void $this->assertFalse($tools['InvokableCalculator']->isManual); $this->assertEquals([InvocableToolFixture::class, '__invoke'], $tools['InvokableCalculator']->handler); + $this->assertArrayHasKey('inc_file_name_tool', $tools); + $this->assertEquals([AlternativeFileNameToolHandler::class, 'run'], $tools['inc_file_name_tool']->handler); + $this->assertArrayNotHasKey('private_tool_should_be_ignored', $tools); $this->assertArrayNotHasKey('protected_tool_should_be_ignored', $tools); $this->assertArrayNotHasKey('static_tool_should_be_ignored', $tools); @@ -121,6 +125,25 @@ public function testHandlesEmptyDirectoriesOrDirectoriesWithNoPhpFiles(): void $this->assertTrue($discovery->isEmpty()); } + public function testHandlesDefaultAndOverriddenFileNamePatterns(): void + { + $discovery = $this->discoverer->discover(__DIR__, ['Fixtures']); + $this->assertArrayHasKey('greet_user', $discovery->getTools()); + $this->assertArrayNotHasKey('inc_file_name_tool', $discovery->getTools()); + + $discovery = $this->discoverer->discover(__DIR__, ['Fixtures'], [], []); + $this->assertArrayHasKey('greet_user', $discovery->getTools()); + $this->assertArrayNotHasKey('inc_file_name_tool', $discovery->getTools()); + + $discovery = $this->discoverer->discover(__DIR__, ['Fixtures'], [], ['*.php', '*.inc']); + $this->assertArrayHasKey('greet_user', $discovery->getTools()); + $this->assertArrayHasKey('inc_file_name_tool', $discovery->getTools()); + + $discovery = $this->discoverer->discover(__DIR__, ['Fixtures'], [], ['*.inc']); + $this->assertArrayNotHasKey('greet_user', $discovery->getTools()); + $this->assertArrayHasKey('inc_file_name_tool', $discovery->getTools()); + } + public function testCorrectlyInfersNamesAndDescriptionsFromMethodsOrClassesIfNotSetInAttribute(): void { $discovery = $this->discoverer->discover(__DIR__, ['Fixtures']); diff --git a/tests/Unit/Capability/Discovery/Fixtures/AlternativeFileNameToolHandler.class.inc b/tests/Unit/Capability/Discovery/Fixtures/AlternativeFileNameToolHandler.class.inc new file mode 100644 index 00000000..67c78eb0 --- /dev/null +++ b/tests/Unit/Capability/Discovery/Fixtures/AlternativeFileNameToolHandler.class.inc @@ -0,0 +1,22 @@ +