[Server] Allow overriding the default file name pattern for Discovery (#292)

* Add optional namePatterns capability to discovery so that the default scan for `*.php` files can be overridden.

* add default to namePatterns docs

* add an error check if we manage to pass in an empty list of patterns

* add unit tests (and a static entry to classmap for composer.json so it autoloads from a non .php file for the test)

* run php-cs-fixer

* change how default namePatterns are set and make sure all the annotations are correct

* Fix bad whitespace

Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>

* switch to a const default instead of nullable with a check in the function to convert null to a default

* make sure we handle empty arrays

* formatting fixes

* Apply suggestion from @chr-hertel

Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>

* Add changelog entry

---------

Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>
This commit is contained in:
johnhunt-lc 2026-05-08 03:48:29 -04:00 • committed by GitHub
commit 86bd845924
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 109 additions and 37 deletions

View file

@ -2,6 +2,11 @@
All notable changes to `mcp/sdk` will be documented in this file. All notable changes to `mcp/sdk` will be documented in this file.
0.6.0
-----
* Allow overriding the default name pattern for Discovery
0.5.0 0.5.0
----- -----

View file

@ -80,7 +80,10 @@
"Mcp\\Example\\Server\\OAuthMicrosoft\\": "examples/server/oauth-microsoft/", "Mcp\\Example\\Server\\OAuthMicrosoft\\": "examples/server/oauth-microsoft/",
"Mcp\\Example\\Server\\SchemaShowcase\\": "examples/server/schema-showcase/", "Mcp\\Example\\Server\\SchemaShowcase\\": "examples/server/schema-showcase/",
"Mcp\\Tests\\": "tests/" "Mcp\\Tests\\": "tests/"
} },
"classmap": [
"tests/Unit/Capability/Discovery/Fixtures/AlternativeFileNameToolHandler.class.inc"
]
}, },
"config": { "config": {
"allow-plugins": { "allow-plugins": {

View file

@ -100,7 +100,8 @@ $server = Server::builder()
basePath: __DIR__, basePath: __DIR__,
scanDirs: ['.', 'src', 'lib'], // Where to look for MCP attributes scanDirs: ['.', 'src', 'lib'], // Where to look for MCP attributes
excludeDirs: ['vendor', 'tests'], // Where NOT to look excludeDirs: ['vendor', 'tests'], // Where NOT to look
cache: $cacheInstance // Optional: cache discovered elements 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']`) - `$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 - `$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 - `$cache` (CacheInterface|null): Optional PSR-16 cache to store discovered elements for performance
- `$namePatterns` (array): Optional list of patterns (regexp, glob, or string) for file names (default: `['*.php']`)
**Basic Discovery (scans current directory and `src/`):** **Basic Discovery (scans current directory and `src/`):**
```php ```php

View file

@ -38,11 +38,12 @@ final class CachedDiscoverer implements DiscovererInterface
/** /**
* Discover MCP elements in the specified directories with caching. * Discover MCP elements in the specified directories with caching.
* *
* @param string $basePath the base path for resolving directories * @param string $basePath the base path for resolving directories
* @param array<string> $directories list of directories (relative to base path) to scan * @param array<string> $directories list of directories (relative to base path) to scan
* @param array<string> $excludeDirs list of directories (relative to base path) to exclude from the scan * @param array<string> $excludeDirs list of directories (relative to base path) to exclude from the scan
* @param array<string> $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 = self::DEFAULT_NAME_PATERNS): DiscoveryState
{ {
$cacheKey = $this->generateCacheKey($basePath, $directories, $excludeDirs); $cacheKey = $this->generateCacheKey($basePath, $directories, $excludeDirs);
@ -63,7 +64,7 @@ final class CachedDiscoverer implements DiscovererInterface
'directories' => $directories, 'directories' => $directories,
]); ]);
$discoveryState = $this->discoverer->discover($basePath, $directories, $excludeDirs); $discoveryState = $this->discoverer->discover($basePath, $directories, $excludeDirs, $namePatterns);
$this->cache->set($cacheKey, $discoveryState); $this->cache->set($cacheKey, $discoveryState);

View file

@ -65,11 +65,12 @@ final class Discoverer implements DiscovererInterface
/** /**
* Discover MCP elements in the specified directories and return the discovery state. * Discover MCP elements in the specified directories and return the discovery state.
* *
* @param string $basePath the base path for resolving directories * @param string $basePath the base path for resolving directories
* @param array<string> $directories list of directories (relative to base path) to scan * @param array<string> $directories list of directories (relative to base path) to scan
* @param array<string> $excludeDirs list of directories (relative to base path) to exclude from the scan * @param array<string> $excludeDirs list of directories (relative to base path) to exclude from the scan
* @param array<string> $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 = self::DEFAULT_NAME_PATERNS): DiscoveryState
{ {
$startTime = microtime(true); $startTime = microtime(true);
$discoveredCount = [ $discoveredCount = [
@ -79,6 +80,8 @@ final class Discoverer implements DiscovererInterface
'resourceTemplates' => 0, 'resourceTemplates' => 0,
]; ];
$namePatterns = !empty($namePatterns) ? $namePatterns : self::DEFAULT_NAME_PATERNS;
$tools = []; $tools = [];
$resources = []; $resources = [];
$prompts = []; $prompts = [];
@ -106,7 +109,7 @@ final class Discoverer implements DiscovererInterface
$finder->files() $finder->files()
->in($absolutePaths) ->in($absolutePaths)
->exclude($excludeDirs) ->exclude($excludeDirs)
->name('*.php'); ->name($namePatterns);
foreach ($finder as $file) { foreach ($finder as $file) {
$this->processFile($file, $discoveredCount, $tools, $resources, $prompts, $resourceTemplates); $this->processFile($file, $discoveredCount, $tools, $resources, $prompts, $resourceTemplates);

View file

@ -20,12 +20,15 @@ namespace Mcp\Capability\Discovery;
*/ */
interface DiscovererInterface interface DiscovererInterface
{ {
public const DEFAULT_NAME_PATERNS = ['*.php'];
/** /**
* Discover MCP elements in the specified directories and return the discovery state. * Discover MCP elements in the specified directories and return the discovery state.
* *
* @param string $basePath the base path for resolving directories * @param string $basePath the base path for resolving directories
* @param array<string> $directories list of directories (relative to base path) to scan * @param array<string> $directories list of directories (relative to base path) to scan
* @param array<string> $excludeDirs list of directories (relative to base path) to exclude from the scan * @param array<string> $excludeDirs list of directories (relative to base path) to exclude from the scan
* @param array<string> $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 = self::DEFAULT_NAME_PATERNS): DiscoveryState;
} }

View file

@ -24,18 +24,20 @@ final class DiscoveryLoader implements LoaderInterface
/** /**
* @param string[] $scanDirs * @param string[] $scanDirs
* @param array|string[] $excludeDirs * @param array|string[] $excludeDirs
* @param string[] $namePatterns
*/ */
public function __construct( public function __construct(
private string $basePath, private string $basePath,
private array $scanDirs, private array $scanDirs,
private array $excludeDirs, private array $excludeDirs,
private DiscovererInterface $discoverer, private DiscovererInterface $discoverer,
private array $namePatterns = DiscovererInterface::DEFAULT_NAME_PATERNS,
) { ) {
} }
public function load(RegistryInterface $registry): void 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); $registry->setDiscoveryState($discoveryState);
} }

View file

@ -160,6 +160,11 @@ final class Builder
*/ */
private array $discoveryExcludeDirs = []; private array $discoveryExcludeDirs = [];
/**
* @var string[]|null
*/
private ?array $discoveryNamePatterns = null;
private ?ServerCapabilities $serverCapabilities = null; private ?ServerCapabilities $serverCapabilities = null;
/** /**
@ -348,17 +353,20 @@ final class Builder
/** /**
* @param string[] $scanDirs * @param string[] $scanDirs
* @param string[] $excludeDirs * @param string[] $excludeDirs
* @param string[] $namePatterns
*/ */
public function setDiscovery( public function setDiscovery(
string $basePath, string $basePath,
array $scanDirs = ['.', 'src'], array $scanDirs = ['.', 'src'],
array $excludeDirs = [], array $excludeDirs = [],
?CacheInterface $cache = null, ?CacheInterface $cache = null,
array $namePatterns = DiscovererInterface::DEFAULT_NAME_PATERNS,
): self { ): self {
$this->discoveryBasePath = $basePath; $this->discoveryBasePath = $basePath;
$this->discoveryScanDirs = $scanDirs; $this->discoveryScanDirs = $scanDirs;
$this->discoveryExcludeDirs = $excludeDirs; $this->discoveryExcludeDirs = $excludeDirs;
$this->discoveryCache = $cache; $this->discoveryCache = $cache;
$this->discoveryNamePatterns = $namePatterns;
return $this; return $this;
} }
@ -531,7 +539,7 @@ final class Builder
if (null !== $this->discoveryBasePath) { if (null !== $this->discoveryBasePath) {
if (null !== $this->discoverer || class_exists(Finder::class)) { if (null !== $this->discoverer || class_exists(Finder::class)) {
$discoverer = $this->discoverer ?? $this->createDiscoverer($logger); $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 { } else {
$logger->warning('File-based discovery requires symfony/finder. Skipping automatic discovery. Run: composer require symfony/finder'); $logger->warning('File-based discovery requires symfony/finder. Skipping automatic discovery. Run: composer require symfony/finder');
} }

View file

@ -16,6 +16,7 @@ use Mcp\Capability\Completion\ListCompletionProvider;
use Mcp\Capability\Discovery\Discoverer; use Mcp\Capability\Discovery\Discoverer;
use Mcp\Capability\Registry\ToolReference; use Mcp\Capability\Registry\ToolReference;
use Mcp\Tests\Unit\Capability\Attribute\CompletionProviderFixture; 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\DiscoverableToolHandler;
use Mcp\Tests\Unit\Capability\Discovery\Fixtures\InvocablePromptFixture; use Mcp\Tests\Unit\Capability\Discovery\Fixtures\InvocablePromptFixture;
use Mcp\Tests\Unit\Capability\Discovery\Fixtures\InvocableResourceFixture; use Mcp\Tests\Unit\Capability\Discovery\Fixtures\InvocableResourceFixture;
@ -34,10 +35,10 @@ class DiscoveryTest extends TestCase
public function testDiscoversAllElementTypesCorrectlyFromFixtureFiles(): void public function testDiscoversAllElementTypesCorrectlyFromFixtureFiles(): void
{ {
$discovery = $this->discoverer->discover(__DIR__, ['Fixtures']); $discovery = $this->discoverer->discover(__DIR__, ['Fixtures'], [], ['*.php', '*.inc']);
$tools = $discovery->getTools(); $tools = $discovery->getTools();
$this->assertCount(4, $tools); $this->assertCount(5, $tools);
$this->assertArrayHasKey('greet_user', $tools); $this->assertArrayHasKey('greet_user', $tools);
$this->assertFalse($tools['greet_user']->isManual); $this->assertFalse($tools['greet_user']->isManual);
@ -56,6 +57,9 @@ class DiscoveryTest extends TestCase
$this->assertFalse($tools['InvokableCalculator']->isManual); $this->assertFalse($tools['InvokableCalculator']->isManual);
$this->assertEquals([InvocableToolFixture::class, '__invoke'], $tools['InvokableCalculator']->handler); $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('private_tool_should_be_ignored', $tools);
$this->assertArrayNotHasKey('protected_tool_should_be_ignored', $tools); $this->assertArrayNotHasKey('protected_tool_should_be_ignored', $tools);
$this->assertArrayNotHasKey('static_tool_should_be_ignored', $tools); $this->assertArrayNotHasKey('static_tool_should_be_ignored', $tools);
@ -121,6 +125,25 @@ class DiscoveryTest extends TestCase
$this->assertTrue($discovery->isEmpty()); $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 public function testCorrectlyInfersNamesAndDescriptionsFromMethodsOrClassesIfNotSetInAttribute(): void
{ {
$discovery = $this->discoverer->discover(__DIR__, ['Fixtures']); $discovery = $this->discoverer->discover(__DIR__, ['Fixtures']);

View file

@ -0,0 +1,22 @@
<?php
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Tests\Unit\Capability\Discovery\Fixtures;
use Mcp\Capability\Attribute\McpTool;
class AlternativeFileNameToolHandler
{
#[McpTool(name: 'inc_file_name_tool')]
public function run(): void
{
}
}