createStub(ReferenceHandlerInterface::class); $builder = Server::builder(); $result = $builder->setReferenceHandler($referenceHandler); $this->assertSame($builder, $result); } #[TestDox('build() succeeds with a custom ReferenceHandler')] public function testBuildWithCustomReferenceHandler(): void { $referenceHandler = $this->createStub(ReferenceHandlerInterface::class); $server = Server::builder() ->setServerInfo('test', '1.0.0') ->setReferenceHandler($referenceHandler) ->build(); $this->assertInstanceOf(Server::class, $server); } #[TestDox('build() succeeds without a custom ReferenceHandler (uses default)')] public function testBuildWithoutCustomReferenceHandler(): void { $server = Server::builder() ->setServerInfo('test', '1.0.0') ->build(); $this->assertInstanceOf(Server::class, $server); } #[TestDox('Custom ReferenceHandler is used when calling a tool')] public function testCustomReferenceHandlerIsUsedForToolCalls(): void { $referenceHandler = $this->createMock(ReferenceHandlerInterface::class); $referenceHandler->expects($this->once()) ->method('handle') ->willReturnCallback(static function (ElementReference $reference, array $arguments): string { return 'intercepted'; }); $server = Server::builder() ->setServerInfo('test', '1.0.0') ->setReferenceHandler($referenceHandler) ->addTool(static fn (): string => 'original', name: 'test_tool', description: 'A test tool') ->build(); $result = $this->callTool($server, 'test_tool'); $this->assertSame('intercepted', $result); } #[TestDox('A pre-built instance handler with constructor dependencies is registered and invoked on that instance')] public function testPreBuiltInstanceHandlerIsInvokedOnTheGivenInstance(): void { // The handler's constructor requires an argument the container-less // `new $className()` fallback can never satisfy. If the tool call // succeeds, it can only be because the pre-built instance — carrying // its injected dependency — was the one invoked. $handler = new GreetingService('World'); $server = Server::builder() ->setServerInfo('test', '1.0.0') ->addTool(handler: [$handler, 'greet'], name: 'greet', description: 'Greets using the injected name') ->build(); $result = $this->callTool($server, 'greet'); $this->assertSame('Hello, World', $result); } #[TestDox('enableExtension() registers an extension and announces its capability payload')] public function testEnableExtensionRegistersExtension(): void { $server = Server::builder() ->setServerInfo('test', '1.0.0') ->enableExtension(new McpApps()) ->build(); $capabilities = $this->extractServerCapabilities($server); $this->assertNotNull($capabilities->extensions); $this->assertArrayHasKey(McpApps::EXTENSION_ID, $capabilities->extensions); $this->assertSame(['mimeTypes' => [McpApps::MIME_TYPE]], $capabilities->extensions[McpApps::EXTENSION_ID]); } #[TestDox('enableExtension() throws when the same extension is enabled twice')] public function testEnableExtensionRejectsDuplicate(): void { $this->expectException(LogicException::class); $this->expectExceptionMessage(McpApps::EXTENSION_ID); Server::builder()->enableExtension(new McpApps(), new McpApps()); } #[TestDox('enableExtension() extensions are merged into capabilities set via setCapabilities()')] public function testEnableExtensionMergesIntoCustomCapabilities(): void { $server = Server::builder() ->setServerInfo('test', '1.0.0') ->setCapabilities(new ServerCapabilities(tools: true)) ->enableExtension(new McpApps()) ->build(); $capabilities = $this->extractServerCapabilities($server); $this->assertNotNull($capabilities->extensions); $this->assertArrayHasKey(McpApps::EXTENSION_ID, $capabilities->extensions); } #[TestDox('build() advertises tools capability for a pre-populated registry set via setRegistry()')] public function testBuildAdvertisesToolsForPreloadedCustomRegistry(): void { $registry = new Registry(); $registry->registerTool( new Tool(name: 'test_tool', title: null, inputSchema: ['type' => 'object', 'properties' => [], 'required' => null], description: 'A test tool', annotations: null), static fn (): string => 'result', ); $server = Server::builder() ->setServerInfo('test', '1.0.0') ->setRegistry($registry) ->build(); $capabilities = $this->extractServerCapabilities($server); $this->assertTrue($capabilities->tools); } #[TestDox('setLazyLoading() returns the builder for fluent chaining')] public function testSetLazyLoadingReturnsSelf(): void { $builder = Server::builder(); $this->assertSame($builder, $builder->setLazyLoading(false)); } #[TestDox('Lazy loading (default) advertises tools from configured sources without running loaders')] public function testLazyLoadingAdvertisesFromConfiguredSourcesWithoutLoading(): void { $loader = $this->createMock(LoaderInterface::class); $loader->expects($this->never())->method('load'); $server = Server::builder() ->setServerInfo('test', '1.0.0') ->addLoader($loader) ->build(); $capabilities = $this->extractServerCapabilities($server); // A custom loader is opaque, so its presence advertises tools even though it never ran. $this->assertTrue($capabilities->tools); } #[TestDox('Eager loading runs the loaders at build time and advertises from the loaded registry')] public function testEagerLoadingAdvertisesFromLoadedRegistry(): void { $loader = $this->createMock(LoaderInterface::class); $loader->expects($this->once())->method('load'); $server = Server::builder() ->setServerInfo('test', '1.0.0') ->setLazyLoading(false) ->addLoader($loader) ->build(); $capabilities = $this->extractServerCapabilities($server); // The loader ran but registered nothing, so the loaded registry advertises no tools. $this->assertFalse($capabilities->tools); } private function extractServerCapabilities(Server $server): ServerCapabilities { $protocol = (new \ReflectionClass($server))->getProperty('protocol')->getValue($server); $requestHandlers = (new \ReflectionClass($protocol))->getProperty('requestHandlers')->getValue($protocol); foreach ($requestHandlers as $handler) { if ($handler instanceof InitializeHandler) { return $handler->configuration->capabilities; } } $this->fail('InitializeHandler not found in request handlers'); } private function callTool(Server $server, string $toolName): mixed { $protocol = (new \ReflectionClass($server))->getProperty('protocol')->getValue($server); $requestHandlers = (new \ReflectionClass($protocol))->getProperty('requestHandlers')->getValue($protocol); foreach ($requestHandlers as $handler) { if ($handler instanceof CallToolHandler) { $request = CallToolRequest::fromArray([ 'jsonrpc' => '2.0', 'method' => 'tools/call', 'id' => 'test-1', 'params' => ['name' => $toolName, 'arguments' => []], ]); $session = $this->createStub(SessionInterface::class); $response = $handler->handle($request, $session); if ($response instanceof Response) { $content = $response->result->content[0] ?? null; return $content instanceof TextContent ? $content->text : null; } $this->fail('Expected Response, got '.$response::class); } } $this->fail('CallToolHandler not found in request handlers'); } } /** * A handler whose constructor dependency cannot be satisfied by the * container-less `new $className()` fallback, so it must be registered as a * pre-built instance. */ final class GreetingService { public function __construct(private string $name) { } public function greet(): string { return 'Hello, '.$this->name; } }