Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -89,4 +92,4 @@
},
"sort-packages": true
}
}
}
36 changes: 19 additions & 17 deletions docs/server-builder.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
johnhunt-lc marked this conversation as resolved.
Outdated
);
```

Expand All @@ -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']`)
Comment thread
johnhunt-lc marked this conversation as resolved.
Outdated

**Basic Discovery (scans current directory and `src/`):**
```php
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -420,17 +422,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
Expand Down Expand Up @@ -507,7 +509,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
Expand Down Expand Up @@ -539,25 +541,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();
```
Expand Down
11 changes: 6 additions & 5 deletions src/Capability/Discovery/CachedDiscoverer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<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 string $basePath the base path for resolving directories
* @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>|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);

Expand All @@ -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);

Expand Down
13 changes: 8 additions & 5 deletions src/Capability/Discovery/Discoverer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<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 string $basePath the base path for resolving directories
* @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>|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
Comment thread
chr-hertel marked this conversation as resolved.
Outdated
{
$startTime = microtime(true);
$discoveredCount = [
Expand All @@ -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 = [];
Expand All @@ -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);
Expand Down
9 changes: 5 additions & 4 deletions src/Capability/Discovery/DiscovererInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<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 string $basePath the base path for resolving directories
* @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>|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;
}
4 changes: 3 additions & 1 deletion src/Capability/Registry/Loader/DiscoveryLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
14 changes: 11 additions & 3 deletions src/Server/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,11 @@ final class Builder
*/
private array $discoveryExcludeDirs = [];

/**
* @var string[]|null
*/
private ?array $discoveryNamePatterns = null;

private ?ServerCapabilities $serverCapabilities = null;

/**
Expand Down Expand Up @@ -345,19 +350,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;
}
Expand Down Expand Up @@ -527,7 +535,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');
}
Expand Down
27 changes: 25 additions & 2 deletions tests/Unit/Capability/Discovery/DiscoveryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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']);
Expand Down
Original file line number Diff line number Diff line change
@@ -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
{
}
}