Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
);
```

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']`)

**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 @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
```
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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using a default here would ease the change:

Suggested change
public function discover(string $basePath, array $directories, array $excludeDirs = [], ?array $namePatterns = null): DiscoveryState
public function discover(string $basePath, array $directories, array $excludeDirs = [], array $namePatterns = ['*.php']): DiscoveryState

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was batting around different ideas for this. $namePatterns gets passed through quite a few different functions, and I originally specified the ['*.php'] default for each one, but I realized that was scattering hardcoded defaults everywhere so I switched to default null in all the functions that pass it along, and just set the default in the ternary below.

I agree that I prefer having the default in the params instead. Would you rather I:

  • set a class const and use it for the default in all functions that use this argument?
  • hardcode this in all of them?
  • set it here, make the rest default to null, make this nullable, and handle nulls in the function logic?
  • some other 4th thing I haven't thought of?

{
$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 @@ -160,6 +160,11 @@ final class Builder
*/
private array $discoveryExcludeDirs = [];

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

private ?ServerCapabilities $serverCapabilities = null;

/**
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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');
}
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
{
}
}