diff --git a/CHANGELOG.md b/CHANGELOG.md index 69c8beb0..8fd07df6 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()` 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/docs/index.md b/docs/index.md index da009ef9..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 the server still starts, but discovers nothing and only logs a warning. +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 11d2a361..16e581a7 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()` 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:** 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..55abe99e 100644 --- a/tests/Unit/Server/BuilderTest.php +++ b/tests/Unit/Server/BuilderTest.php @@ -11,6 +11,8 @@ namespace Mcp\Tests\Unit\Server; +use Composer\Autoload\ClassLoader; +use Mcp\Capability\Discovery\DiscovererInterface; use Mcp\Capability\Registry; use Mcp\Capability\Registry\ElementReference; use Mcp\Capability\Registry\Loader\LoaderInterface; @@ -18,6 +20,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,9 +44,11 @@ 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 Symfony\Component\Finder\Finder; final class BuilderTest extends TestCase { @@ -81,6 +86,28 @@ 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 + { + 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()); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('File-based discovery requires symfony/finder. Run: composer require symfony/finder'); + + Server::builder()->setServerInfo('test', '1.0.0')->setDiscovery(__DIR__)->build(); + } + #[TestDox('Custom ReferenceHandler is used when calling a tool')] public function testCustomReferenceHandlerIsUsedForToolCalls(): void {