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); } 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; } }