From 279f9f56dc0f9bd1b6455676190c66df5cd56a21 Mon Sep 17 00:00:00 2001 From: Ousama Ben Younes Date: Tue, 18 Aug 2026 00:24:21 +0000 Subject: [PATCH 1/5] [Server] Fail when discovery dependency is missing --- src/Server/Builder.php | 9 ++---- tests/Unit/Server/BuilderTest.php | 54 +++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 9bb28c81..12c62003 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -73,7 +73,6 @@ use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Psr\SimpleCache\CacheInterface; -use Symfony\Component\Finder\Finder; /** * @phpstan-import-type Handler from ElementReference @@ -1073,12 +1072,8 @@ private function resolve(): array ]; 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, $this->discoveryNamePatterns, $logger); - } else { - $logger->warning('File-based discovery requires symfony/finder. Skipping automatic discovery. Run: composer require symfony/finder'); - } + $discoverer = $this->discoverer ?? $this->createDiscoverer($logger); + $loaders[] = new DiscoveryLoader($this->discoveryBasePath, $this->discoveryScanDirs, $this->discoveryExcludeDirs, $discoverer, $this->discoveryNamePatterns, $logger); } $chainLoader = new ChainLoader($loaders); diff --git a/tests/Unit/Server/BuilderTest.php b/tests/Unit/Server/BuilderTest.php index 53c793f0..a02e00a9 100644 --- a/tests/Unit/Server/BuilderTest.php +++ b/tests/Unit/Server/BuilderTest.php @@ -11,6 +11,9 @@ namespace Mcp\Tests\Unit\Server; +use Composer\Autoload\ClassLoader; +use Mcp\Capability\Discovery\Discoverer; +use Mcp\Capability\Discovery\DiscovererInterface; use Mcp\Capability\Registry; use Mcp\Capability\Registry\ElementReference; use Mcp\Capability\Registry\Loader\LoaderInterface; @@ -18,6 +21,7 @@ use Mcp\Capability\RegistryInterface; use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\LogicException; +use Mcp\Exception\RuntimeException; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Extension\Apps\McpApps; @@ -41,12 +45,20 @@ use Mcp\Tests\Unit\Server\Extension\ThingListRequest; use Mcp\Tests\Unit\Server\Session\Fixture\RecordingSessionLock; use Mcp\Tests\Unit\Server\Session\Fixture\RecordingSessionStore; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; use Psr\EventDispatcher\EventDispatcherInterface; +use Psr\Log\LogLevel; +use Symfony\Component\Finder\Finder; final class BuilderTest extends TestCase { + private const DISCOVERY_DEPENDENCY_ERROR = 'File-based discovery requires symfony/finder. Run: composer require symfony/finder'; + private const FINDER_LOADED_ERROR = 'Symfony Finder was loaded after Composer autoloaders were disabled.'; + private const TEST_SERVER_NAME = 'test'; + private const TEST_SERVER_VERSION = '1.0.0'; + #[TestDox('setReferenceHandler() returns the builder for fluent chaining')] public function testSetReferenceHandlerReturnsSelf(): void { @@ -81,6 +93,48 @@ public function testBuildWithoutCustomReferenceHandler(): void $this->assertInstanceOf(Server::class, $server); } + #[RunInSeparateProcess] + #[TestDox('build() fails when file-based discovery is configured without Symfony Finder')] + public function testBuildFailsWhenDiscoveryDependencyIsMissing(): void + { + Server::builder()->setServerInfo(self::TEST_SERVER_NAME, self::TEST_SERVER_VERSION)->build(); + Server::builder() + ->setServerInfo(self::TEST_SERVER_NAME, self::TEST_SERVER_VERSION) + ->setDiscovery(__DIR__) + ->setDiscoverer($this->createStub(DiscovererInterface::class)) + ->build(); + class_exists(Discoverer::class); + class_exists(RuntimeException::class); + class_exists(LogLevel::class); + + $autoloaders = array_values(array_filter( + spl_autoload_functions(), + static fn (mixed $autoloader): bool => \is_array($autoloader) && $autoloader[0] instanceof ClassLoader, + )); + $this->assertNotEmpty($autoloaders); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(self::DISCOVERY_DEPENDENCY_ERROR); + + foreach ($autoloaders as $autoloader) { + $autoloader[0]->unregister(); + } + + try { + if (class_exists(Finder::class)) { + throw new LogicException(self::FINDER_LOADED_ERROR); + } + + Server::builder() + ->setServerInfo(self::TEST_SERVER_NAME, self::TEST_SERVER_VERSION) + ->setDiscovery(__DIR__) + ->build(); + } finally { + foreach ($autoloaders as $autoloader) { + $autoloader[0]->register(true); + } + } + } + #[TestDox('Custom ReferenceHandler is used when calling a tool')] public function testCustomReferenceHandlerIsUsedForToolCalls(): void { From 5500fe482b7005f2ec64592ec23763cae9e9afa9 Mon Sep 17 00:00:00 2001 From: ousamabenyounes Date: Mon, 19 Oct 2026 03:29:27 +0000 Subject: [PATCH 2/5] [Docs] Describe the missing symfony/finder failure Co-Authored-By: Claude Opus 5.5 --- docs/index.md | 2 +- docs/run/server-builder.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/index.md b/docs/index.md index da009ef9..7b947287 100644 --- a/docs/index.md +++ b/docs/index.md @@ -74,7 +74,7 @@ That's a complete MCP server. It exposes one **tool**, `add`, and one **resource `config://calculator/settings`. Attribute discovery needs `symfony/finder` (`composer require symfony/finder`). Without -it the server still starts, but discovers nothing and only logs a warning. +it, `build()` throws a `RuntimeException` instead of starting a server with no tools. Look at what you did *not* write: no JSON Schema — `int $a, int $b` *is* the schema — no request parsing, no serialization, no protocol handling. You wrote a PHP class with diff --git a/docs/run/server-builder.md b/docs/run/server-builder.md index 11d2a361..0bd9969d 100644 --- a/docs/run/server-builder.md +++ b/docs/run/server-builder.md @@ -160,7 +160,8 @@ $server = Server::builder() ); ``` -Discovery needs the `symfony/finder` package. Without it, the server skips discovery and logs a warning. +Discovery needs the `symfony/finder` package. Without it, `build()` throws a `RuntimeException`, unless you set your own +discoverer with `setDiscoverer()`. See [First server](../get-started/first-server.md) for the install command. **Parameters:** From 0b9b845f008d1f8ed569e78db684245eba0cbca8 Mon Sep 17 00:00:00 2001 From: ousamabenyounes Date: Mon, 19 Oct 2026 03:39:05 +0000 Subject: [PATCH 3/5] [Server] Record the missing symfony/finder failure as a BC break Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + tests/Unit/Server/BuilderTest.php | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69c8beb0..651632ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * [BC Break] `DocBlockParser::parseDocBlock()` no longer accepts `null`; pass `ReflectionMethod::getDocComment()` as is * [BC Break] Fix a `DELETE` of an unknown or ended session being answered `200` instead of the `404` the spec requires: `Protocol::destroySession()` and `BaseTransport::handleSessionEnd()` return `false` for such a session, as does the listener of `TransportInterface::onSessionEnd()`. A listener that returns nothing keeps the `200`. +* [BC Break] `Builder::build()` throws a `RuntimeException` when `setDiscovery()` is used without `symfony/finder` and without a custom discoverer, instead of logging a warning and discovering nothing * Fix tools with an array shape parameter whose keys are nullable or unions (e.g. `array{name: string, nickname: string|null}`) rejecting valid arguments with `-32602`: a `null` inside the shape no longer turns the parameter into `array|null`, and such keys get their own types instead of `object`, including lists and nested shapes (`string[]|null`, `array{x: int}|null`). * Fix `array` and other generics with a union element getting `items` of type `object`. A `null` inside a generic no longer makes the parameter nullable, and neither does a DocBlock class whose name contains `null`. * Fix `InMemorySessionStore::exists()` reporting a session past its TTL as existing, while `read()` already treated it as gone. diff --git a/tests/Unit/Server/BuilderTest.php b/tests/Unit/Server/BuilderTest.php index a02e00a9..40299117 100644 --- a/tests/Unit/Server/BuilderTest.php +++ b/tests/Unit/Server/BuilderTest.php @@ -97,6 +97,7 @@ public function testBuildWithoutCustomReferenceHandler(): void #[TestDox('build() fails when file-based discovery is configured without Symfony Finder')] public function testBuildFailsWhenDiscoveryDependencyIsMissing(): void { + // Load every class the failing path needs before the Composer autoloaders are unregistered. Server::builder()->setServerInfo(self::TEST_SERVER_NAME, self::TEST_SERVER_VERSION)->build(); Server::builder() ->setServerInfo(self::TEST_SERVER_NAME, self::TEST_SERVER_VERSION) From fe3fb9f84be3c007095b73f0782cb595ebe4da72 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 4 Nov 2026 20:15:40 +0100 Subject: [PATCH 4/5] [Server] Simplify the missing symfony/finder test --- CHANGELOG.md | 2 +- tests/Unit/Server/BuilderTest.php | 52 +++++++------------------------ 2 files changed, 13 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 651632ec..8fd07df6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * [BC Break] `DocBlockParser::parseDocBlock()` no longer accepts `null`; pass `ReflectionMethod::getDocComment()` as is * [BC Break] Fix a `DELETE` of an unknown or ended session being answered `200` instead of the `404` the spec requires: `Protocol::destroySession()` and `BaseTransport::handleSessionEnd()` return `false` for such a session, as does the listener of `TransportInterface::onSessionEnd()`. A listener that returns nothing keeps the `200`. -* [BC Break] `Builder::build()` throws a `RuntimeException` when `setDiscovery()` is used without `symfony/finder` and without a custom discoverer, instead of logging a warning and discovering nothing +* [BC Break] `Builder::build()` and `Builder::buildStateless()` throw a `RuntimeException` when `setDiscovery()` is used without `symfony/finder` and without a custom discoverer, instead of logging a warning and discovering nothing * Fix tools with an array shape parameter whose keys are nullable or unions (e.g. `array{name: string, nickname: string|null}`) rejecting valid arguments with `-32602`: a `null` inside the shape no longer turns the parameter into `array|null`, and such keys get their own types instead of `object`, including lists and nested shapes (`string[]|null`, `array{x: int}|null`). * Fix `array` and other generics with a union element getting `items` of type `object`. A `null` inside a generic no longer makes the parameter nullable, and neither does a DocBlock class whose name contains `null`. * Fix `InMemorySessionStore::exists()` reporting a session past its TTL as existing, while `read()` already treated it as gone. diff --git a/tests/Unit/Server/BuilderTest.php b/tests/Unit/Server/BuilderTest.php index 40299117..55abe99e 100644 --- a/tests/Unit/Server/BuilderTest.php +++ b/tests/Unit/Server/BuilderTest.php @@ -12,7 +12,6 @@ namespace Mcp\Tests\Unit\Server; use Composer\Autoload\ClassLoader; -use Mcp\Capability\Discovery\Discoverer; use Mcp\Capability\Discovery\DiscovererInterface; use Mcp\Capability\Registry; use Mcp\Capability\Registry\ElementReference; @@ -49,16 +48,10 @@ use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; use Psr\EventDispatcher\EventDispatcherInterface; -use Psr\Log\LogLevel; use Symfony\Component\Finder\Finder; final class BuilderTest extends TestCase { - private const DISCOVERY_DEPENDENCY_ERROR = 'File-based discovery requires symfony/finder. Run: composer require symfony/finder'; - private const FINDER_LOADED_ERROR = 'Symfony Finder was loaded after Composer autoloaders were disabled.'; - private const TEST_SERVER_NAME = 'test'; - private const TEST_SERVER_VERSION = '1.0.0'; - #[TestDox('setReferenceHandler() returns the builder for fluent chaining')] public function testSetReferenceHandlerReturnsSelf(): void { @@ -97,43 +90,22 @@ public function testBuildWithoutCustomReferenceHandler(): void #[TestDox('build() fails when file-based discovery is configured without Symfony Finder')] public function testBuildFailsWhenDiscoveryDependencyIsMissing(): void { - // Load every class the failing path needs before the Composer autoloaders are unregistered. - Server::builder()->setServerInfo(self::TEST_SERVER_NAME, self::TEST_SERVER_VERSION)->build(); - Server::builder() - ->setServerInfo(self::TEST_SERVER_NAME, self::TEST_SERVER_VERSION) + foreach (ClassLoader::getRegisteredLoaders() as $loader) { + $loader->setPsr4('Symfony\\Component\\Finder\\', []); + } + $this->assertFalse(class_exists(Finder::class)); + + $this->assertInstanceOf(Server::class, Server::builder()->setServerInfo('test', '1.0.0')->build()); + $this->assertInstanceOf(Server::class, Server::builder() + ->setServerInfo('test', '1.0.0') ->setDiscovery(__DIR__) ->setDiscoverer($this->createStub(DiscovererInterface::class)) - ->build(); - class_exists(Discoverer::class); - class_exists(RuntimeException::class); - class_exists(LogLevel::class); - - $autoloaders = array_values(array_filter( - spl_autoload_functions(), - static fn (mixed $autoloader): bool => \is_array($autoloader) && $autoloader[0] instanceof ClassLoader, - )); - $this->assertNotEmpty($autoloaders); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage(self::DISCOVERY_DEPENDENCY_ERROR); + ->build()); - foreach ($autoloaders as $autoloader) { - $autoloader[0]->unregister(); - } - - try { - if (class_exists(Finder::class)) { - throw new LogicException(self::FINDER_LOADED_ERROR); - } + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('File-based discovery requires symfony/finder. Run: composer require symfony/finder'); - Server::builder() - ->setServerInfo(self::TEST_SERVER_NAME, self::TEST_SERVER_VERSION) - ->setDiscovery(__DIR__) - ->build(); - } finally { - foreach ($autoloaders as $autoloader) { - $autoloader[0]->register(true); - } - } + Server::builder()->setServerInfo('test', '1.0.0')->setDiscovery(__DIR__)->build(); } #[TestDox('Custom ReferenceHandler is used when calling a tool')] From 8c4513b539853d4e9a55c75349391886c6167b7f Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 4 Nov 2026 20:17:38 +0100 Subject: [PATCH 5/5] [Docs] Mention buildStateless() in the missing symfony/finder failure --- docs/index.md | 2 +- docs/run/server-builder.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/index.md b/docs/index.md index 7b947287..1eb28a3a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -74,7 +74,7 @@ That's a complete MCP server. It exposes one **tool**, `add`, and one **resource `config://calculator/settings`. Attribute discovery needs `symfony/finder` (`composer require symfony/finder`). Without -it, `build()` throws a `RuntimeException` instead of starting a server with no tools. +it, building the server throws a `RuntimeException`. Look at what you did *not* write: no JSON Schema — `int $a, int $b` *is* the schema — no request parsing, no serialization, no protocol handling. You wrote a PHP class with diff --git a/docs/run/server-builder.md b/docs/run/server-builder.md index 0bd9969d..16e581a7 100644 --- a/docs/run/server-builder.md +++ b/docs/run/server-builder.md @@ -160,8 +160,8 @@ $server = Server::builder() ); ``` -Discovery needs the `symfony/finder` package. Without it, `build()` throws a `RuntimeException`, unless you set your own -discoverer with `setDiscoverer()`. +Discovery needs the `symfony/finder` package. Without it, `build()` and `buildStateless()` throw a `RuntimeException`. +You don't need the package if you set your own discoverer with `setDiscoverer()`. See [First server](../get-started/first-server.md) for the install command. **Parameters:**