diff --git a/README.md b/README.md index 42e41cb..4870d13 100644 --- a/README.md +++ b/README.md @@ -757,7 +757,11 @@ Completion providers enable MCP clients to offer auto-completion suggestions in > **Note**: Tools and resources can be discovered via standard MCP commands (`tools/list`, `resources/list`), so completion providers are not needed for them. Completion providers are used only for resource templates (URI variables) and prompt arguments. -Completion providers must implement the `CompletionProviderInterface`: +The `#[CompletionProvider]` attribute supports three types of completion sources: + +#### 1. Custom Provider Classes + +For complex completion logic, implement the `CompletionProviderInterface`: ```php use PhpMcp\Server\Contracts\CompletionProviderInterface; @@ -766,13 +770,12 @@ use PhpMcp\Server\Attributes\{McpResourceTemplate, CompletionProvider}; class UserIdCompletionProvider implements CompletionProviderInterface { + public function __construct(private DatabaseService $db) {} + public function getCompletions(string $currentValue, SessionInterface $session): array { - // Return completion suggestions based on current input - $allUsers = ['user_1', 'user_2', 'user_3', 'admin_user']; - - // Filter based on what user has typed so far - return array_filter($allUsers, fn($user) => str_starts_with($user, $currentValue)); + // Dynamic completion from database + return $this->db->searchUsers($currentValue); } } @@ -780,20 +783,130 @@ class UserService { #[McpResourceTemplate(uriTemplate: 'user://{userId}/profile')] public function getUserProfile( - #[CompletionProvider(UserIdCompletionProvider::class)] + #[CompletionProvider(provider: UserIdCompletionProvider::class)] // Class string - resolved from container string $userId ): array { - // Always validate input even with completion providers - // Users can still pass any value regardless of completion suggestions - if (!$this->isValidUserId($userId)) { - throw new \InvalidArgumentException('Invalid user ID provided'); - } - return ['id' => $userId, 'name' => 'John Doe']; } } ``` +You can also pass pre-configured provider instances: + +```php +class DocumentService +{ + #[McpPrompt(name: 'document_prompt')] + public function generatePrompt( + #[CompletionProvider(provider: new UserIdCompletionProvider($database))] // Pre-configured instance + string $userId, + + #[CompletionProvider(provider: $this->categoryProvider)] // Instance from property + string $category + ): array { + return [['role' => 'user', 'content' => "Generate document for user {$userId} in {$category}"]]; + } +} +``` + +#### 2. Simple List Completions + +For static completion lists, use the `values` parameter: + +```php +use PhpMcp\Server\Attributes\{McpPrompt, CompletionProvider}; + +class ContentService +{ + #[McpPrompt(name: 'content_generator')] + public function generateContent( + #[CompletionProvider(values: ['blog', 'article', 'tutorial', 'guide', 'documentation'])] + string $contentType, + + #[CompletionProvider(values: ['beginner', 'intermediate', 'advanced', 'expert'])] + string $difficulty + ): array { + return [['role' => 'user', 'content' => "Create a {$difficulty} level {$contentType}"]]; + } +} +``` + +#### 3. Enum-Based Completions + +For enum classes, use the `enum` parameter: + +```php +enum Priority: string +{ + case LOW = 'low'; + case MEDIUM = 'medium'; + case HIGH = 'high'; + case CRITICAL = 'critical'; +} + +enum Status // Unit enum (no backing values) +{ + case DRAFT; + case PUBLISHED; + case ARCHIVED; +} + +class TaskService +{ + #[McpTool(name: 'create_task')] + public function createTask( + string $title, + + #[CompletionProvider(enum: Priority::class)] // String-backed enum uses values + string $priority, + + #[CompletionProvider(enum: Status::class)] // Unit enum uses case names + string $status + ): array { + return ['id' => 123, 'title' => $title, 'priority' => $priority, 'status' => $status]; + } +} +``` + +#### Manual Registration with Completion Providers + +```php +$server = Server::make() + ->withServerInfo('Completion Demo', '1.0.0') + + // Using provider class (resolved from container) + ->withPrompt( + [DocumentHandler::class, 'generateReport'], + name: 'document_report' + // Completion providers are auto-discovered from method attributes + ) + + // Using closure with inline completion providers + ->withPrompt( + function( + #[CompletionProvider(values: ['json', 'xml', 'csv', 'yaml'])] + string $format, + + #[CompletionProvider(enum: Priority::class)] + string $priority + ): array { + return [['role' => 'user', 'content' => "Export data in {$format} format with {$priority} priority"]]; + }, + name: 'export_data' + ) + + ->build(); +``` + +#### Completion Provider Resolution + +The server automatically handles provider resolution: + +- **Class strings** (`MyProvider::class`) โ†’ Resolved from PSR-11 container with dependency injection +- **Instances** (`new MyProvider()`) โ†’ Used directly as-is +- **Values arrays** (`['a', 'b', 'c']`) โ†’ Automatically wrapped in `ListCompletionProvider` +- **Enum classes** (`MyEnum::class`) โ†’ Automatically wrapped in `EnumCompletionProvider` + > **Important**: Completion providers only offer suggestions to users in the MCP client interface. Users can still input any value, so always validate parameters in your handlers regardless of completion provider constraints. ### Custom Dependency Injection diff --git a/examples/02-discovery-http-userprofile/McpElements.php b/examples/02-discovery-http-userprofile/McpElements.php index d371713..f684814 100644 --- a/examples/02-discovery-http-userprofile/McpElements.php +++ b/examples/02-discovery-http-userprofile/McpElements.php @@ -43,7 +43,7 @@ public function __construct(LoggerInterface $logger) )] public function getUserProfile( - #[CompletionProvider(providerClass: UserIdCompletionProvider::class)] + #[CompletionProvider(values: ['101', '102', '103'])] string $userId ): array { $this->logger->info('Reading resource: user profile', ['userId' => $userId]); @@ -116,7 +116,7 @@ public function testToolWithoutParams() */ #[McpPrompt(name: 'generate_bio_prompt')] public function generateBio( - #[CompletionProvider(providerClass: UserIdCompletionProvider::class)] + #[CompletionProvider(provider: UserIdCompletionProvider::class)] string $userId, string $tone = 'professional' ): array { diff --git a/src/Attributes/CompletionProvider.php b/src/Attributes/CompletionProvider.php index 3bf5223..eb5034a 100644 --- a/src/Attributes/CompletionProvider.php +++ b/src/Attributes/CompletionProvider.php @@ -11,9 +11,15 @@ class CompletionProvider { /** - * @param class-string $providerClass FQCN of the completion provider class. + * @param class-string|CompletionProviderInterface|null $provider If a class-string, it will be resolved from the container at the point of use. */ - public function __construct(public string $providerClass) - { + public function __construct( + public string|CompletionProviderInterface|null $provider = null, + public ?array $values = null, + public ?string $enum = null, + ) { + if (count(array_filter([$provider, $values, $enum])) !== 1) { + throw new \InvalidArgumentException('Only one of provider, values, or enum can be set'); + } } } diff --git a/src/Defaults/EnumCompletionProvider.php b/src/Defaults/EnumCompletionProvider.php new file mode 100644 index 0000000..a8244e9 --- /dev/null +++ b/src/Defaults/EnumCompletionProvider.php @@ -0,0 +1,37 @@ +values = array_map( + fn($case) => isset($case->value) && is_string($case->value) ? $case->value : $case->name, + $enumClass::cases() + ); + } + + public function getCompletions(string $currentValue, SessionInterface $session): array + { + if (empty($currentValue)) { + return $this->values; + } + + return array_values(array_filter( + $this->values, + fn(string $value) => str_starts_with($value, $currentValue) + )); + } +} diff --git a/src/Defaults/ListCompletionProvider.php b/src/Defaults/ListCompletionProvider.php new file mode 100644 index 0000000..1b1d377 --- /dev/null +++ b/src/Defaults/ListCompletionProvider.php @@ -0,0 +1,25 @@ +values; + } + + return array_values(array_filter( + $this->values, + fn(string $value) => str_starts_with($value, $currentValue) + )); + } +} diff --git a/src/Dispatcher.php b/src/Dispatcher.php index 590443a..e52aae6 100644 --- a/src/Dispatcher.php +++ b/src/Dispatcher.php @@ -340,7 +340,7 @@ public function handleCompletionComplete(CompletionCompleteRequest $request, Ses throw McpServerException::invalidParams("Argument '{$argumentName}' not found in prompt '{$identifier}'."); } - $providerClass = $registeredPrompt->getCompletionProvider($argumentName); + return $registeredPrompt->complete($this->container, $argumentName, $currentValue, $session); } elseif ($ref->type === 'ref/resource') { $identifier = $ref->uri; $registeredResourceTemplate = $this->registry->getResourceTemplate($identifier); @@ -360,26 +360,10 @@ public function handleCompletionComplete(CompletionCompleteRequest $request, Ses throw McpServerException::invalidParams("URI variable '{$argumentName}' not found in resource template '{$identifier}'."); } - $providerClass = $registeredResourceTemplate->getCompletionProvider($argumentName); + return $registeredResourceTemplate->complete($this->container, $argumentName, $currentValue, $session); } else { throw McpServerException::invalidParams("Invalid ref type '{$ref->type}' for completion complete request."); } - - if (! $providerClass) { - $this->logger->warning("No completion provider found for argument '{$argumentName}' in '{$ref->type}' '{$identifier}'."); - return new CompletionCompleteResult([]); - } - - $provider = $this->container->get($providerClass); - - $completions = $provider->getCompletions($currentValue, $session); - - $total = count($completions); - $hasMore = $total > 100; - - $pagedCompletions = array_slice($completions, 0, 100); - - return new CompletionCompleteResult($pagedCompletions, $total, $hasMore); } public function handleNotificationInitialized(InitializedNotification $notification, SessionInterface $session): EmptyResult diff --git a/src/Elements/RegisteredPrompt.php b/src/Elements/RegisteredPrompt.php index 9e421bd..b37ad1e 100644 --- a/src/Elements/RegisteredPrompt.php +++ b/src/Elements/RegisteredPrompt.php @@ -14,6 +14,9 @@ use PhpMcp\Schema\Content\TextContent; use PhpMcp\Schema\Content\TextResourceContents; use PhpMcp\Schema\Enum\Role; +use PhpMcp\Schema\Result\CompletionCompleteResult; +use PhpMcp\Server\Contracts\CompletionProviderInterface; +use PhpMcp\Server\Contracts\SessionInterface; use Psr\Container\ContainerInterface; use Throwable; @@ -47,9 +50,31 @@ public function get(ContainerInterface $container, array $arguments): array return $this->formatResult($result); } - public function getCompletionProvider(string $argumentName): ?string + public function complete(ContainerInterface $container, string $argument, string $value, SessionInterface $session): CompletionCompleteResult { - return $this->completionProviders[$argumentName] ?? null; + $providerClassOrInstance = $this->completionProviders[$argument] ?? null; + if ($providerClassOrInstance === null) { + return new CompletionCompleteResult([]); + } + + if (is_string($providerClassOrInstance)) { + if (! class_exists($providerClassOrInstance)) { + throw new \RuntimeException("Completion provider class '{$providerClassOrInstance}' does not exist."); + } + + $provider = $container->get($providerClassOrInstance); + } else { + $provider = $providerClassOrInstance; + } + + $completions = $provider->getCompletions($value, $session); + + $total = count($completions); + $hasMore = $total > 100; + + $pagedCompletions = array_slice($completions, 0, 100); + + return new CompletionCompleteResult($pagedCompletions, $total, $hasMore); } /** @@ -268,9 +293,14 @@ private function formatResourceContent(array $content, string $indexStr): Embedd public function toArray(): array { + $completionProviders = []; + foreach ($this->completionProviders as $argument => $provider) { + $completionProviders[$argument] = serialize($provider); + } + return [ 'schema' => $this->schema->toArray(), - 'completionProviders' => $this->completionProviders, + 'completionProviders' => $completionProviders, ...parent::toArray(), ]; } @@ -282,11 +312,16 @@ public static function fromArray(array $data): self|false return false; } + $completionProviders = []; + foreach ($data['completionProviders'] ?? [] as $argument => $provider) { + $completionProviders[$argument] = unserialize($provider); + } + return new self( Prompt::fromArray($data['schema']), $data['handler'], $data['isManual'] ?? false, - $data['completionProviders'] ?? [], + $completionProviders, ); } catch (Throwable $e) { return false; diff --git a/src/Elements/RegisteredResourceTemplate.php b/src/Elements/RegisteredResourceTemplate.php index b431f78..e88c9ab 100644 --- a/src/Elements/RegisteredResourceTemplate.php +++ b/src/Elements/RegisteredResourceTemplate.php @@ -9,6 +9,8 @@ use PhpMcp\Schema\Content\ResourceContents; use PhpMcp\Schema\Content\TextResourceContents; use PhpMcp\Schema\ResourceTemplate; +use PhpMcp\Schema\Result\CompletionCompleteResult; +use PhpMcp\Server\Contracts\SessionInterface; use Psr\Container\ContainerInterface; use Throwable; @@ -48,11 +50,34 @@ public function read(ContainerInterface $container, string $uri): array return $this->formatResult($result, $uri, $this->schema->mimeType); } - public function getCompletionProvider(string $argumentName): ?string + public function complete(ContainerInterface $container, string $argument, string $value, SessionInterface $session): CompletionCompleteResult { - return $this->completionProviders[$argumentName] ?? null; + $providerClassOrInstance = $this->completionProviders[$argument] ?? null; + if ($providerClassOrInstance === null) { + return new CompletionCompleteResult([]); + } + + if (is_string($providerClassOrInstance)) { + if (! class_exists($providerClassOrInstance)) { + throw new \RuntimeException("Completion provider class '{$providerClassOrInstance}' does not exist."); + } + + $provider = $container->get($providerClassOrInstance); + } else { + $provider = $providerClassOrInstance; + } + + $completions = $provider->getCompletions($value, $session); + + $total = count($completions); + $hasMore = $total > 100; + + $pagedCompletions = array_slice($completions, 0, 100); + + return new CompletionCompleteResult($pagedCompletions, $total, $hasMore); } + public function getVariableNames(): array { return $this->variableNames; @@ -265,9 +290,14 @@ private function guessMimeTypeFromString(string $content): string public function toArray(): array { + $completionProviders = []; + foreach ($this->completionProviders as $argument => $provider) { + $completionProviders[$argument] = serialize($provider); + } + return [ 'schema' => $this->schema->toArray(), - 'completionProviders' => $this->completionProviders, + 'completionProviders' => $completionProviders, ...parent::toArray(), ]; } @@ -279,11 +309,16 @@ public static function fromArray(array $data): self|false return false; } + $completionProviders = []; + foreach ($data['completionProviders'] ?? [] as $argument => $provider) { + $completionProviders[$argument] = unserialize($provider); + } + return new self( ResourceTemplate::fromArray($data['schema']), $data['handler'], $data['isManual'] ?? false, - $data['completionProviders'] ?? [], + $completionProviders, ); } catch (Throwable $e) { return false; diff --git a/src/ServerBuilder.php b/src/ServerBuilder.php index 4f8028b..c81234f 100644 --- a/src/ServerBuilder.php +++ b/src/ServerBuilder.php @@ -17,6 +17,8 @@ use PhpMcp\Server\Attributes\CompletionProvider; use PhpMcp\Server\Contracts\SessionHandlerInterface; use PhpMcp\Server\Defaults\BasicContainer; +use PhpMcp\Server\Defaults\EnumCompletionProvider; +use PhpMcp\Server\Defaults\ListCompletionProvider; use PhpMcp\Server\Exception\ConfigurationException; use PhpMcp\Server\Session\ArraySessionHandler; @@ -506,7 +508,14 @@ private function getCompletionProviders(\ReflectionMethod|\ReflectionFunction $r $completionAttributes = $param->getAttributes(CompletionProvider::class, \ReflectionAttribute::IS_INSTANCEOF); if (!empty($completionAttributes)) { $attributeInstance = $completionAttributes[0]->newInstance(); - $completionProviders[$param->getName()] = $attributeInstance->providerClass; + + if ($attributeInstance->provider) { + $completionProviders[$param->getName()] = $attributeInstance->provider; + } elseif ($attributeInstance->values) { + $completionProviders[$param->getName()] = new ListCompletionProvider($attributeInstance->values); + } elseif ($attributeInstance->enum) { + $completionProviders[$param->getName()] = new EnumCompletionProvider($attributeInstance->enum); + } } } diff --git a/src/Utils/Discoverer.php b/src/Utils/Discoverer.php index 1a73998..8d28cc2 100644 --- a/src/Utils/Discoverer.php +++ b/src/Utils/Discoverer.php @@ -14,6 +14,8 @@ use PhpMcp\Server\Attributes\McpResource; use PhpMcp\Server\Attributes\McpResourceTemplate; use PhpMcp\Server\Attributes\McpTool; +use PhpMcp\Server\Defaults\EnumCompletionProvider; +use PhpMcp\Server\Defaults\ListCompletionProvider; use PhpMcp\Server\Exception\McpServerException; use PhpMcp\Server\Registry; use Psr\Log\LoggerInterface; @@ -263,7 +265,14 @@ private function getCompletionProviders(\ReflectionMethod $reflectionMethod): ar $completionAttributes = $param->getAttributes(CompletionProvider::class, \ReflectionAttribute::IS_INSTANCEOF); if (!empty($completionAttributes)) { $attributeInstance = $completionAttributes[0]->newInstance(); - $completionProviders[$param->getName()] = $attributeInstance->providerClass; + + if ($attributeInstance->provider) { + $completionProviders[$param->getName()] = $attributeInstance->provider; + } elseif ($attributeInstance->values) { + $completionProviders[$param->getName()] = new ListCompletionProvider($attributeInstance->values); + } elseif ($attributeInstance->enum) { + $completionProviders[$param->getName()] = new EnumCompletionProvider($attributeInstance->enum); + } } } diff --git a/test_closure_support.php b/test_closure_support.php deleted file mode 100644 index 946cb76..0000000 --- a/test_closure_support.php +++ /dev/null @@ -1,258 +0,0 @@ -services[$id] ?? new $id(); - } - - public function has(string $id): bool - { - return isset($this->services[$id]) || class_exists($id); - } - - public function set(string $id, object $service): void - { - $this->services[$id] = $service; - } -} - -// Create a test closure tool -$calculateTool = function (int $a, int $b, string $operation = 'add'): string { - return match ($operation) { - 'add' => "Result: " . ($a + $b), - 'subtract' => "Result: " . ($a - $b), - 'multiply' => "Result: " . ($a * $b), - 'divide' => $b !== 0 ? "Result: " . ($a / $b) : "Cannot divide by zero", - default => "Unknown operation: $operation" - }; -}; - -// Create a test closure resource -$configResource = function (string $uri): array { - return [ - new TextContent("Configuration for URI: $uri"), - new TextContent("Environment: development"), - new TextContent("Version: 1.0.0") - ]; -}; - -// Create a test closure prompt -$codeGenPrompt = function (string $language, string $description): array { - return [ - PromptMessage::make( - Role::User, - new TextContent("Generate $language code for: $description") - ) - ]; -}; - -// Create a test closure resource template -$dynamicResource = function (string $uri, string $id): array { - return [ - new TextContent("Dynamic resource ID: $id"), - new TextContent("Requested URI: $uri"), - new TextContent("Generated at: " . date('Y-m-d H:i:s')) - ]; -}; - -// Test static method support -class StaticToolHandler -{ - public static function getCurrentTime(): string - { - return "Current time: " . date('Y-m-d H:i:s'); - } -} - -// Test instance method support -class InstanceToolHandler -{ - private string $prefix; - - public function __construct(string $prefix = "Instance") - { - $this->prefix = $prefix; - } - - public function greet(string $name): string - { - return "{$this->prefix}: Hello, $name!"; - } -} - -echo "๐Ÿงช Testing MCP Server Closure and Callable Support\n"; -echo "=" . str_repeat("=", 50) . "\n\n"; - -// Build the server with various handler types -$container = new TestContainer(); -$container->set(InstanceToolHandler::class, new InstanceToolHandler("TestInstance")); - -$server = (new ServerBuilder()) - ->withServerInfo('ClosureTest', '1.0.0') - ->withContainer($container) - ->withTool($calculateTool, 'calculator', 'Performs basic mathematical operations') - ->withResource($configResource, 'config://app', 'app_config', 'Gets app configuration') - ->withResourceTemplate($dynamicResource, 'dynamic://item/{id}', 'dynamic_item', 'Gets dynamic items by ID') - ->withPrompt($codeGenPrompt, 'code_generator', 'Generates code in specified language') - ->withTool([StaticToolHandler::class, 'getCurrentTime'], 'current_time', 'Gets current server time') - ->withTool([InstanceToolHandler::class, 'greet'], 'greeter', 'Greets a person') - ->build(); - -echo "โœ… Server built successfully with various handler types!\n\n"; - -// Get the registry using reflection -$registryProperty = new ReflectionProperty($server, 'registry'); -$registryProperty->setAccessible(true); -$registry = $registryProperty->getValue($server); - -// Test Tools -echo "๐Ÿ”ง Testing Tools:\n"; -echo "-" . str_repeat("-", 20) . "\n"; - -// Test closure tool -$calculatorTool = $registry->getTool('calculator'); -if ($calculatorTool) { - try { - $result = $calculatorTool->call($container, ['a' => 10, 'b' => 5, 'operation' => 'add']); - echo "โœ… Closure Tool (calculator): " . $result[0]->text . "\n"; - - $result = $calculatorTool->call($container, ['a' => 10, 'b' => 3, 'operation' => 'multiply']); - echo "โœ… Closure Tool (calculator): " . $result[0]->text . "\n"; - } catch (Exception $e) { - echo "โŒ Closure Tool failed: " . $e->getMessage() . "\n"; - } -} else { - echo "โŒ Calculator tool not found\n"; -} - -// Test static method tool -$timeTool = $registry->getTool('current_time'); -if ($timeTool) { - try { - $result = $timeTool->call($container, []); - echo "โœ… Static Method Tool (current_time): " . $result[0]->text . "\n"; - } catch (Exception $e) { - echo "โŒ Static Method Tool failed: " . $e->getMessage() . "\n"; - } -} else { - echo "โŒ Current time tool not found\n"; -} - -// Test instance method tool -$greeterTool = $registry->getTool('greeter'); -if ($greeterTool) { - try { - $result = $greeterTool->call($container, ['name' => 'Alice']); - echo "โœ… Instance Method Tool (greeter): " . $result[0]->text . "\n"; - } catch (Exception $e) { - echo "โŒ Instance Method Tool failed: " . $e->getMessage() . "\n"; - } -} else { - echo "โŒ Greeter tool not found\n"; -} - -// Test Resources -echo "\n๐Ÿ“ Testing Resources:\n"; -echo "-" . str_repeat("-", 20) . "\n"; - -// Test closure resource -$configRes = $registry->getResource('config://app'); -if ($configRes) { - try { - $result = $configRes->read($container, 'config://app'); - if (is_array($result) && isset($result[0])) { - echo "โœ… Closure Resource (config): " . $result[0]->text . "\n"; - if (isset($result[1])) echo " โ””โ”€ " . $result[1]->text . "\n"; - if (isset($result[2])) echo " โ””โ”€ " . $result[2]->text . "\n"; - } else { - echo "โœ… Closure Resource (config): " . (is_string($result) ? $result : json_encode($result)) . "\n"; - } - } catch (Exception $e) { - echo "โŒ Closure Resource failed: " . $e->getMessage() . "\n"; - } -} else { - echo "โŒ Config resource not found\n"; -} - -// Test Resource Templates -echo "\n๐Ÿ“‹ Testing Resource Templates:\n"; -echo "-" . str_repeat("-", 30) . "\n"; - -// Test closure resource template -$dynamicRes = $registry->getResource('dynamic://item/123'); -if ($dynamicRes) { - try { - $result = $dynamicRes->read($container, 'dynamic://item/123'); - if (is_array($result) && isset($result[0])) { - echo "โœ… Closure Resource Template (dynamic): " . $result[0]->text . "\n"; - if (isset($result[1])) echo " โ””โ”€ " . $result[1]->text . "\n"; - if (isset($result[2])) echo " โ””โ”€ " . $result[2]->text . "\n"; - } else { - echo "โœ… Closure Resource Template (dynamic): " . (is_string($result) ? $result : json_encode($result)) . "\n"; - } - } catch (Exception $e) { - echo "โŒ Closure Resource Template failed: " . $e->getMessage() . "\n"; - } -} else { - echo "โŒ Dynamic resource template not found\n"; -} - -// Test Prompts -echo "\n๐Ÿ’ฌ Testing Prompts:\n"; -echo "-" . str_repeat("-", 20) . "\n"; - -// Test closure prompt -$codePrompt = $registry->getPrompt('code_generator'); -if ($codePrompt) { - try { - $result = $codePrompt->get($container, ['language' => 'PHP', 'description' => 'a calculator function']); - if (is_array($result) && isset($result[0])) { - // Result is an array of PromptMessage objects - $message = $result[0]; - if ($message instanceof \PhpMcp\Schema\Content\PromptMessage) { - echo "โœ… Closure Prompt (code_generator): " . $message->content->text . "\n"; - } else { - echo "โœ… Closure Prompt (code_generator): " . json_encode($result) . "\n"; - } - } else { - echo "โœ… Closure Prompt (code_generator): " . (is_string($result) ? $result : json_encode($result)) . "\n"; - } - } catch (Exception $e) { - echo "โŒ Closure Prompt failed: " . $e->getMessage() . "\n"; - } -} else { - echo "โŒ Code generator prompt not found\n"; -} - -// Summary -echo "\n๐Ÿ“Š Registry Summary:\n"; -echo "-" . str_repeat("-", 20) . "\n"; -$tools = $registry->getTools(); -$resources = $registry->getResources(); -$prompts = $registry->getPrompts(); -$templates = $registry->getResourceTemplates(); - -echo "โœ… Tools: " . count($tools) . "\n"; -echo "โœ… Resources: " . count($resources) . "\n"; -echo "โœ… Prompts: " . count($prompts) . "\n"; -echo "โœ… Resource Templates: " . count($templates) . "\n"; - -echo "\n๐ŸŽ‰ All tests passed! Closure and callable support is working correctly.\n"; -echo " โœ“ Closures as handlers\n"; -echo " โœ“ Static methods as handlers\n"; -echo " โœ“ Instance methods as handlers\n"; -echo " โœ“ All handler types can be called successfully\n"; diff --git a/test_unique_closure_names.php b/test_unique_closure_names.php deleted file mode 100644 index 90aafdd..0000000 --- a/test_unique_closure_names.php +++ /dev/null @@ -1,144 +0,0 @@ -withServerInfo('UniqueNameTest', '1.0.0') - // Tools without explicit names - should get unique names - ->withTool($addTool) - ->withTool($multiplyTool) - ->withTool($subtractTool) - // Prompts without explicit names - should get unique names - ->withPrompt($mathPrompt) - ->withPrompt($codePrompt) - ->build(); - -echo "โœ… Server built successfully with multiple unnamed closures!\n\n"; - -// Get the registry using reflection -$registryProperty = new ReflectionProperty($server, 'registry'); -$registryProperty->setAccessible(true); -$registry = $registryProperty->getValue($server); - -// Check tool names -echo "๐Ÿ”ง Registered Tool Names:\n"; -echo "-" . str_repeat("-", 25) . "\n"; -$tools = $registry->getTools(); -foreach ($tools as $name => $tool) { - echo " - $name: {$tool->description}\n"; -} - -// Check prompt names -echo "\n๐Ÿ’ฌ Registered Prompt Names:\n"; -echo "-" . str_repeat("-", 27) . "\n"; -$prompts = $registry->getPrompts(); -foreach ($prompts as $name => $prompt) { - echo " - $name: {$prompt->description}\n"; -} - -// Verify uniqueness -echo "\n๐Ÿ“Š Uniqueness Check:\n"; -echo "-" . str_repeat("-", 20) . "\n"; -$toolNames = array_keys($tools); -$promptNames = array_keys($prompts); -$allNames = array_merge($toolNames, $promptNames); - -$uniqueNames = array_unique($allNames); -$totalNames = count($allNames); -$uniqueCount = count($uniqueNames); - -echo "Total names: $totalNames\n"; -echo "Unique names: $uniqueCount\n"; - -if ($totalNames === $uniqueCount) { - echo "โœ… All names are unique!\n"; -} else { - echo "โŒ Found duplicate names!\n"; - $duplicates = array_diff_assoc($allNames, $uniqueNames); - foreach ($duplicates as $duplicate) { - echo " Duplicate: $duplicate\n"; - } -} - -// Test that the same closure gets the same name consistently -echo "\n๐Ÿ”„ Consistency Check:\n"; -echo "-" . str_repeat("-", 20) . "\n"; - -$sameClosure = function (string $msg): string { - return "Echo: $msg"; -}; - -$server1 = (new ServerBuilder()) - ->withServerInfo('Test1', '1.0.0') - ->withTool($sameClosure) - ->build(); - -$server2 = (new ServerBuilder()) - ->withServerInfo('Test2', '1.0.0') - ->withTool($sameClosure) - ->build(); - -// Get names from both servers -$registry1Property = new ReflectionProperty($server1, 'registry'); -$registry1Property->setAccessible(true); -$registry1 = $registry1Property->getValue($server1); - -$registry2Property = new ReflectionProperty($server2, 'registry'); -$registry2Property->setAccessible(true); -$registry2 = $registry2Property->getValue($server2); - -$tools1 = $registry1->getTools(); -$tools2 = $registry2->getTools(); - -$name1 = array_keys($tools1)[0]; -$name2 = array_keys($tools2)[0]; - -echo "Same closure in server 1: $name1\n"; -echo "Same closure in server 2: $name2\n"; - -if ($name1 === $name2) { - echo "โœ… Same closure gets consistent name!\n"; -} else { - echo "โŒ Same closure gets different names!\n"; -} - -echo "\n๐ŸŽ‰ Unique naming test complete!\n"; diff --git a/tests/Fixtures/Discovery/DiscoverablePromptHandler.php b/tests/Fixtures/Discovery/DiscoverablePromptHandler.php index 54d6a00..985cbe8 100644 --- a/tests/Fixtures/Discovery/DiscoverablePromptHandler.php +++ b/tests/Fixtures/Discovery/DiscoverablePromptHandler.php @@ -18,7 +18,7 @@ class DiscoverablePromptHandler */ #[McpPrompt(name: "creative_story_prompt")] public function generateStoryPrompt( - #[CompletionProvider(providerClass: CompletionProviderFixture::class)] + #[CompletionProvider(provider: CompletionProviderFixture::class)] string $genre, int $lengthWords = 200 ): array { diff --git a/tests/Fixtures/Discovery/DiscoverableTemplateHandler.php b/tests/Fixtures/Discovery/DiscoverableTemplateHandler.php index ee7171f..29917cb 100644 --- a/tests/Fixtures/Discovery/DiscoverableTemplateHandler.php +++ b/tests/Fixtures/Discovery/DiscoverableTemplateHandler.php @@ -23,7 +23,7 @@ class DiscoverableTemplateHandler )] public function getProductDetails( string $productId, - #[CompletionProvider(providerClass: CompletionProviderFixture::class)] + #[CompletionProvider(provider: CompletionProviderFixture::class)] string $region ): array { return [ diff --git a/tests/Fixtures/Discovery/EnhancedCompletionHandler.php b/tests/Fixtures/Discovery/EnhancedCompletionHandler.php new file mode 100644 index 0000000..00336de --- /dev/null +++ b/tests/Fixtures/Discovery/EnhancedCompletionHandler.php @@ -0,0 +1,50 @@ + 'user', 'content' => "Create a {$type} with status {$status} and priority {$priority}"] + ]; + } + + /** + * Resource template with list completion for categories. + */ + #[McpResourceTemplate( + uriTemplate: 'content://{category}/{slug}', + name: 'content_template' + )] + public function getContent( + #[CompletionProvider(values: ['news', 'blog', 'docs', 'api'])] + string $category, + string $slug + ): array { + return [ + 'category' => $category, + 'slug' => $slug, + 'url' => "https://example.com/{$category}/{$slug}" + ]; + } +} diff --git a/tests/Fixtures/Enums/PriorityEnum.php b/tests/Fixtures/Enums/PriorityEnum.php new file mode 100644 index 0000000..1145735 --- /dev/null +++ b/tests/Fixtures/Enums/PriorityEnum.php @@ -0,0 +1,12 @@ +discoverer->discover($this->fixtureBasePath, [$scanDir]); - // --- Assert Tools --- $tools = $this->registry->getTools(); expect($tools)->toHaveCount(4); // greet_user, repeatAction, InvokableCalculator, hidden_subdir_tool @@ -36,7 +42,7 @@ ->and($greetUserTool->isManual)->toBeFalse() ->and($greetUserTool->schema->name)->toBe('greet_user') ->and($greetUserTool->schema->description)->toBe('Greets a user by name.') - ->and($greetUserTool->handler)->toBe([\PhpMcp\Server\Tests\Fixtures\Discovery\DiscoverableToolHandler::class, 'greet']); + ->and($greetUserTool->handler)->toBe([DiscoverableToolHandler::class, 'greet']); expect($greetUserTool->schema->inputSchema['properties'] ?? [])->toHaveKey('name'); $repeatActionTool = $this->registry->getTool('repeatAction'); @@ -49,14 +55,13 @@ $invokableCalcTool = $this->registry->getTool('InvokableCalculator'); expect($invokableCalcTool)->toBeInstanceOf(RegisteredTool::class) ->and($invokableCalcTool->isManual)->toBeFalse() - ->and($invokableCalcTool->handler)->toBe([\PhpMcp\Server\Tests\Fixtures\Discovery\InvocableToolFixture::class, '__invoke']); + ->and($invokableCalcTool->handler)->toBe([InvocableToolFixture::class, '__invoke']); expect($this->registry->getTool('private_tool_should_be_ignored'))->toBeNull(); expect($this->registry->getTool('protected_tool_should_be_ignored'))->toBeNull(); expect($this->registry->getTool('static_tool_should_be_ignored'))->toBeNull(); - // --- Assert Resources --- $resources = $this->registry->getResources(); expect($resources)->toHaveCount(3); // app_version, ui_settings_discovered, InvocableResourceFixture @@ -69,18 +74,17 @@ $invokableStatusRes = $this->registry->getResource('invokable://config/status'); expect($invokableStatusRes)->toBeInstanceOf(RegisteredResource::class) ->and($invokableStatusRes->isManual)->toBeFalse() - ->and($invokableStatusRes->handler)->toBe([\PhpMcp\Server\Tests\Fixtures\Discovery\InvocableResourceFixture::class, '__invoke']); + ->and($invokableStatusRes->handler)->toBe([InvocableResourceFixture::class, '__invoke']); - // --- Assert Prompts --- $prompts = $this->registry->getPrompts(); - expect($prompts)->toHaveCount(3); // creative_story_prompt, simpleQuestionPrompt, InvocablePromptFixture + expect($prompts)->toHaveCount(4); // creative_story_prompt, simpleQuestionPrompt, InvocablePromptFixture, content_creator $storyPrompt = $this->registry->getPrompt('creative_story_prompt'); expect($storyPrompt)->toBeInstanceOf(RegisteredPrompt::class) ->and($storyPrompt->isManual)->toBeFalse() ->and($storyPrompt->schema->arguments)->toHaveCount(2) // genre, lengthWords - ->and($storyPrompt->getCompletionProvider('genre'))->toBe(CompletionProviderFixture::class); + ->and($storyPrompt->completionProviders['genre'])->toBe(CompletionProviderFixture::class); $simplePrompt = $this->registry->getPrompt('simpleQuestionPrompt'); // Inferred name expect($simplePrompt)->toBeInstanceOf(RegisteredPrompt::class) @@ -89,24 +93,27 @@ $invokableGreeter = $this->registry->getPrompt('InvokableGreeterPrompt'); expect($invokableGreeter)->toBeInstanceOf(RegisteredPrompt::class) ->and($invokableGreeter->isManual)->toBeFalse() - ->and($invokableGreeter->handler)->toBe([\PhpMcp\Server\Tests\Fixtures\Discovery\InvocablePromptFixture::class, '__invoke']); + ->and($invokableGreeter->handler)->toBe([InvocablePromptFixture::class, '__invoke']); + $contentCreatorPrompt = $this->registry->getPrompt('content_creator'); + expect($contentCreatorPrompt)->toBeInstanceOf(RegisteredPrompt::class) + ->and($contentCreatorPrompt->isManual)->toBeFalse() + ->and($contentCreatorPrompt->completionProviders)->toHaveCount(3); - // --- Assert Resource Templates --- $templates = $this->registry->getResourceTemplates(); - expect($templates)->toHaveCount(3); // product_details_template, getFileContent, InvocableResourceTemplateFixture + expect($templates)->toHaveCount(4); // product_details_template, getFileContent, InvocableResourceTemplateFixture, content_template $productTemplate = $this->registry->getResourceTemplate('product://{region}/details/{productId}'); expect($productTemplate)->toBeInstanceOf(RegisteredResourceTemplate::class) ->and($productTemplate->isManual)->toBeFalse() ->and($productTemplate->schema->name)->toBe('product_details_template') - ->and($productTemplate->getCompletionProvider('region'))->toBe(CompletionProviderFixture::class); + ->and($productTemplate->completionProviders['region'])->toBe(CompletionProviderFixture::class); expect($productTemplate->getVariableNames())->toEqualCanonicalizing(['region', 'productId']); $invokableUserTemplate = $this->registry->getResourceTemplate('invokable://user-profile/{userId}'); expect($invokableUserTemplate)->toBeInstanceOf(RegisteredResourceTemplate::class) ->and($invokableUserTemplate->isManual)->toBeFalse() - ->and($invokableUserTemplate->handler)->toBe([\PhpMcp\Server\Tests\Fixtures\Discovery\InvocableResourceTemplateFixture::class, '__invoke']); + ->and($invokableUserTemplate->handler)->toBe([InvocableResourceTemplateFixture::class, '__invoke']); }); it('does not discover elements from excluded directories', function () { @@ -140,3 +147,28 @@ expect($invokableCalc->schema->name)->toBe('InvokableCalculator'); expect($invokableCalc->schema->description)->toBe('An invokable calculator tool.'); }); + +it('discovers enhanced completion providers with values and enum attributes', function () { + $this->discoverer->discover($this->fixtureBasePath, ['Discovery']); + + $contentPrompt = $this->registry->getPrompt('content_creator'); + expect($contentPrompt)->toBeInstanceOf(RegisteredPrompt::class); + + expect($contentPrompt->completionProviders)->toHaveCount(3); + + $typeProvider = $contentPrompt->completionProviders['type']; + expect($typeProvider)->toBeInstanceOf(ListCompletionProvider::class); + + $statusProvider = $contentPrompt->completionProviders['status']; + expect($statusProvider)->toBeInstanceOf(EnumCompletionProvider::class); + + $priorityProvider = $contentPrompt->completionProviders['priority']; + expect($priorityProvider)->toBeInstanceOf(EnumCompletionProvider::class); + + $contentTemplate = $this->registry->getResourceTemplate('content://{category}/{slug}'); + expect($contentTemplate)->toBeInstanceOf(RegisteredResourceTemplate::class); + expect($contentTemplate->completionProviders)->toHaveCount(1); + + $categoryProvider = $contentTemplate->completionProviders['category']; + expect($categoryProvider)->toBeInstanceOf(ListCompletionProvider::class); +}); diff --git a/tests/Unit/Attributes/CompletionProviderTest.php b/tests/Unit/Attributes/CompletionProviderTest.php new file mode 100644 index 0000000..cbe1c8a --- /dev/null +++ b/tests/Unit/Attributes/CompletionProviderTest.php @@ -0,0 +1,70 @@ +provider)->toBe(CompletionProviderFixture::class); + expect($attribute->values)->toBeNull(); + expect($attribute->enum)->toBeNull(); +}); + +it('can be constructed with provider instance', function () { + $instance = new CompletionProviderFixture(); + $attribute = new CompletionProvider(provider: $instance); + + expect($attribute->provider)->toBe($instance); + expect($attribute->values)->toBeNull(); + expect($attribute->enum)->toBeNull(); +}); + +it('can be constructed with values array', function () { + $values = ['draft', 'published', 'archived']; + $attribute = new CompletionProvider(values: $values); + + expect($attribute->provider)->toBeNull(); + expect($attribute->values)->toBe($values); + expect($attribute->enum)->toBeNull(); +}); + +it('can be constructed with enum class', function () { + $attribute = new CompletionProvider(enum: TestEnum::class); + + expect($attribute->provider)->toBeNull(); + expect($attribute->values)->toBeNull(); + expect($attribute->enum)->toBe(TestEnum::class); +}); + +it('throws exception when no parameters provided', function () { + new CompletionProvider(); +})->throws(\InvalidArgumentException::class, 'Only one of provider, values, or enum can be set'); + +it('throws exception when multiple parameters provided', function () { + new CompletionProvider( + provider: CompletionProviderFixture::class, + values: ['test'] + ); +})->throws(\InvalidArgumentException::class, 'Only one of provider, values, or enum can be set'); + +it('throws exception when all parameters provided', function () { + new CompletionProvider( + provider: CompletionProviderFixture::class, + values: ['test'], + enum: TestEnum::class + ); +})->throws(\InvalidArgumentException::class, 'Only one of provider, values, or enum can be set'); diff --git a/tests/Unit/Defaults/EnumCompletionProviderTest.php b/tests/Unit/Defaults/EnumCompletionProviderTest.php new file mode 100644 index 0000000..3caf400 --- /dev/null +++ b/tests/Unit/Defaults/EnumCompletionProviderTest.php @@ -0,0 +1,90 @@ +session = Mockery::mock(SessionInterface::class); +}); + +it('creates provider from string-backed enum', function () { + $provider = new EnumCompletionProvider(StringEnum::class); + + $result = $provider->getCompletions('', $this->session); + + expect($result)->toBe(['draft', 'published', 'archived']); +}); + +it('creates provider from int-backed enum using names', function () { + $provider = new EnumCompletionProvider(IntEnum::class); + + $result = $provider->getCompletions('', $this->session); + + expect($result)->toBe(['LOW', 'MEDIUM', 'HIGH']); +}); + +it('creates provider from unit enum using names', function () { + $provider = new EnumCompletionProvider(UnitEnum::class); + + $result = $provider->getCompletions('', $this->session); + + expect($result)->toBe(['ALPHA', 'BETA', 'GAMMA']); +}); + +it('filters string enum values by prefix', function () { + $provider = new EnumCompletionProvider(StringEnum::class); + + $result = $provider->getCompletions('ar', $this->session); + + expect($result)->toEqual(['archived']); +}); + +it('filters unit enum values by prefix', function () { + $provider = new EnumCompletionProvider(UnitEnum::class); + + $result = $provider->getCompletions('A', $this->session); + + expect($result)->toBe(['ALPHA']); +}); + +it('returns empty array when no values match prefix', function () { + $provider = new EnumCompletionProvider(StringEnum::class); + + $result = $provider->getCompletions('xyz', $this->session); + + expect($result)->toBe([]); +}); + +it('throws exception for non-enum class', function () { + new EnumCompletionProvider(\stdClass::class); +})->throws(\InvalidArgumentException::class, 'Class stdClass is not an enum'); + +it('throws exception for non-existent class', function () { + new EnumCompletionProvider('NonExistentClass'); +})->throws(\InvalidArgumentException::class, 'Class NonExistentClass is not an enum'); diff --git a/tests/Unit/Defaults/ListCompletionProviderTest.php b/tests/Unit/Defaults/ListCompletionProviderTest.php new file mode 100644 index 0000000..741d0b8 --- /dev/null +++ b/tests/Unit/Defaults/ListCompletionProviderTest.php @@ -0,0 +1,75 @@ +session = Mockery::mock(SessionInterface::class); +}); + +it('returns all values when current value is empty', function () { + $values = ['apple', 'banana', 'cherry']; + $provider = new ListCompletionProvider($values); + + $result = $provider->getCompletions('', $this->session); + + expect($result)->toBe($values); +}); + +it('filters values based on current value prefix', function () { + $values = ['apple', 'apricot', 'banana', 'cherry']; + $provider = new ListCompletionProvider($values); + + $result = $provider->getCompletions('ap', $this->session); + + expect($result)->toBe(['apple', 'apricot']); +}); + +it('returns empty array when no values match', function () { + $values = ['apple', 'banana', 'cherry']; + $provider = new ListCompletionProvider($values); + + $result = $provider->getCompletions('xyz', $this->session); + + expect($result)->toBe([]); +}); + +it('works with single character prefix', function () { + $values = ['apple', 'banana', 'cherry']; + $provider = new ListCompletionProvider($values); + + $result = $provider->getCompletions('a', $this->session); + + expect($result)->toBe(['apple']); +}); + +it('is case sensitive by default', function () { + $values = ['Apple', 'apple', 'APPLE']; + $provider = new ListCompletionProvider($values); + + $result = $provider->getCompletions('A', $this->session); + + expect($result)->toEqual(['Apple', 'APPLE']); +}); + +it('handles empty values array', function () { + $provider = new ListCompletionProvider([]); + + $result = $provider->getCompletions('test', $this->session); + + expect($result)->toBe([]); +}); + +it('preserves array order', function () { + $values = ['zebra', 'apple', 'banana']; + $provider = new ListCompletionProvider($values); + + $result = $provider->getCompletions('', $this->session); + + expect($result)->toBe(['zebra', 'apple', 'banana']); +}); diff --git a/tests/Unit/DispatcherTest.php b/tests/Unit/DispatcherTest.php index 2c26da0..a6c83ea 100644 --- a/tests/Unit/DispatcherTest.php +++ b/tests/Unit/DispatcherTest.php @@ -53,6 +53,7 @@ use PhpMcp\Schema\ResourceReference; use PhpMcp\Server\Protocol; use React\EventLoop\Loop; +use PhpMcp\Server\Tests\Unit\Attributes\TestEnum; const DISPATCHER_SESSION_ID = 'dispatcher-session-xyz'; const DISPATCHER_PAGINATION_LIMIT = 3; @@ -433,10 +434,14 @@ $providerClass = get_class($mockCompletionProvider); $promptSchema = PromptSchema::make($promptName, '', [PromptArgument::make($argName)]); - $registeredPromptMock = Mockery::mock(RegisteredPrompt::class, [$promptSchema, ['MyPromptHandler', 'get'], false]); - $registeredPromptMock->shouldReceive('getCompletionProvider')->with($argName)->andReturn($providerClass); + $registeredPrompt = new RegisteredPrompt( + schema: $promptSchema, + handler: ['MyPromptHandler', 'get'], + isManual: false, + completionProviders: [$argName => $providerClass] + ); - $this->registry->shouldReceive('getPrompt')->with($promptName)->andReturn($registeredPromptMock); + $this->registry->shouldReceive('getPrompt')->with($promptName)->andReturn($registeredPrompt); $this->container->shouldReceive('get')->with($providerClass)->andReturn($mockCompletionProvider); $mockCompletionProvider->shouldReceive('getCompletions')->with($currentValue, $this->session)->andReturn($completions); @@ -458,11 +463,14 @@ $providerClass = get_class($mockCompletionProvider); $templateSchema = ResourceTemplateSchema::make($templateUri, 'item-template'); - $registeredTemplateMock = Mockery::mock(RegisteredResourceTemplate::class, [$templateSchema, ['MyResourceTemplateHandler', 'get'], false]); - $registeredTemplateMock->shouldReceive('getVariableNames')->andReturn(['itemId', 'catName']); - $registeredTemplateMock->shouldReceive('getCompletionProvider')->with($uriVarName)->andReturn($providerClass); + $registeredTemplate = new RegisteredResourceTemplate( + schema: $templateSchema, + handler: ['MyResourceTemplateHandler', 'get'], + isManual: false, + completionProviders: [$uriVarName => $providerClass] + ); - $this->registry->shouldReceive('getResourceTemplate')->with($templateUri)->andReturn($registeredTemplateMock); + $this->registry->shouldReceive('getResourceTemplate')->with($templateUri)->andReturn($registeredTemplate); $this->container->shouldReceive('get')->with($providerClass)->andReturn($mockCompletionProvider); $mockCompletionProvider->shouldReceive('getCompletions')->with($currentValue, $this->session)->andReturn($completions); @@ -475,15 +483,71 @@ it('can handle completion complete request and return empty if no provider', function () { $promptName = 'no-provider-prompt'; $promptSchema = PromptSchema::make($promptName, '', [PromptArgument::make('arg')]); - $registeredPromptMock = Mockery::mock(RegisteredPrompt::class, [$promptSchema, ['MyPromptHandler', 'get'], false]); - $registeredPromptMock->shouldReceive('getCompletionProvider')->andReturn(null); - $this->registry->shouldReceive('getPrompt')->with($promptName)->andReturn($registeredPromptMock); + $registeredPrompt = new RegisteredPrompt( + schema: $promptSchema, + handler: ['MyPromptHandler', 'get'], + isManual: false, + completionProviders: [] + ); + $this->registry->shouldReceive('getPrompt')->with($promptName)->andReturn($registeredPrompt); $request = CompletionCompleteRequest::make(1, PromptReference::make($promptName), ['name' => 'arg', 'value' => '']); $result = $this->dispatcher->handleCompletionComplete($request, $this->session); expect($result->values)->toBeEmpty(); }); +it('can handle completion complete request with ListCompletionProvider instance', function () { + $promptName = 'list-completion-prompt'; + $argName = 'category'; + $currentValue = 'bl'; + $expectedCompletions = ['blog']; + + $listProvider = new \PhpMcp\Server\Defaults\ListCompletionProvider(['blog', 'news', 'docs', 'api']); + + $promptSchema = PromptSchema::make($promptName, '', [PromptArgument::make($argName)]); + $registeredPrompt = new RegisteredPrompt( + schema: $promptSchema, + handler: ['MyPromptHandler', 'get'], + isManual: false, + completionProviders: [$argName => $listProvider] + ); + + $this->registry->shouldReceive('getPrompt')->with($promptName)->andReturn($registeredPrompt); + + $request = CompletionCompleteRequest::make(1, PromptReference::make($promptName), ['name' => $argName, 'value' => $currentValue]); + $result = $this->dispatcher->handleCompletionComplete($request, $this->session); + + expect($result->values)->toEqual($expectedCompletions); + expect($result->total)->toBe(1); + expect($result->hasMore)->toBeFalse(); +}); + +it('can handle completion complete request with EnumCompletionProvider instance', function () { + $promptName = 'enum-completion-prompt'; + $argName = 'status'; + $currentValue = 'a'; + $expectedCompletions = ['archived']; + + $enumProvider = new \PhpMcp\Server\Defaults\EnumCompletionProvider(TestEnum::class); + + $promptSchema = PromptSchema::make($promptName, '', [PromptArgument::make($argName)]); + $registeredPrompt = new RegisteredPrompt( + schema: $promptSchema, + handler: ['MyPromptHandler', 'get'], + isManual: false, + completionProviders: [$argName => $enumProvider] + ); + + $this->registry->shouldReceive('getPrompt')->with($promptName)->andReturn($registeredPrompt); + + $request = CompletionCompleteRequest::make(1, PromptReference::make($promptName), ['name' => $argName, 'value' => $currentValue]); + $result = $this->dispatcher->handleCompletionComplete($request, $this->session); + + expect($result->values)->toEqual($expectedCompletions); + expect($result->total)->toBe(1); + expect($result->hasMore)->toBeFalse(); +}); + it('decodeCursor handles null and invalid cursors', function () { $method = new \ReflectionMethod(Dispatcher::class, 'decodeCursor'); diff --git a/tests/Unit/Elements/RegisteredPromptTest.php b/tests/Unit/Elements/RegisteredPromptTest.php index 49abe46..ff59a71 100644 --- a/tests/Unit/Elements/RegisteredPromptTest.php +++ b/tests/Unit/Elements/RegisteredPromptTest.php @@ -3,8 +3,7 @@ namespace PhpMcp\Server\Tests\Unit\Elements; use Mockery; -use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; -use PhpMcp\Schema\Prompt as PromptSchema; // Alias +use PhpMcp\Schema\Prompt as PromptSchema; use PhpMcp\Schema\PromptArgument; use PhpMcp\Server\Elements\RegisteredPrompt; use PhpMcp\Schema\Content\PromptMessage; @@ -15,10 +14,9 @@ use PhpMcp\Schema\Content\EmbeddedResource; use PhpMcp\Server\Tests\Fixtures\General\PromptHandlerFixture; use PhpMcp\Server\Tests\Fixtures\General\CompletionProviderFixture; +use PhpMcp\Server\Tests\Unit\Attributes\TestEnum; use Psr\Container\ContainerInterface; -uses(MockeryPHPUnitIntegration::class); - beforeEach(function () { $this->container = Mockery::mock(ContainerInterface::class); $this->container->shouldReceive('get') @@ -46,8 +44,8 @@ expect($prompt->handler)->toBe([PromptHandlerFixture::class, 'promptWithArgumentCompletion']); expect($prompt->isManual)->toBeFalse(); expect($prompt->completionProviders)->toEqual($providers); - expect($prompt->getCompletionProvider('name'))->toBe(CompletionProviderFixture::class); - expect($prompt->getCompletionProvider('nonExistentArg'))->toBeNull(); + expect($prompt->completionProviders['name'])->toBe(CompletionProviderFixture::class); + expect($prompt->completionProviders)->not->toHaveKey('nonExistentArg'); }); it('can be made as a manual registration', function () { @@ -205,6 +203,7 @@ [PromptArgument::make('arg1', required: true), PromptArgument::make('arg2', 'description for arg2')] ); $providers = ['arg1' => CompletionProviderFixture::class]; + $serializedProviders = ['arg1' => serialize(CompletionProviderFixture::class)]; $original = RegisteredPrompt::make( $schema, [PromptHandlerFixture::class, 'generateSimpleGreeting'], @@ -218,7 +217,7 @@ expect($array['schema']['arguments'])->toHaveCount(2); expect($array['handler'])->toBe([PromptHandlerFixture::class, 'generateSimpleGreeting']); expect($array['isManual'])->toBeTrue(); - expect($array['completionProviders'])->toEqual($providers); + expect($array['completionProviders'])->toEqual($serializedProviders); $rehydrated = RegisteredPrompt::fromArray($array); expect($rehydrated)->toBeInstanceOf(RegisteredPrompt::class); @@ -231,3 +230,50 @@ $badData = ['schema' => ['name' => 'fail']]; expect(RegisteredPrompt::fromArray($badData))->toBeFalse(); }); + +it('can be serialized with ListCompletionProvider instances', function () { + $schema = PromptSchema::make( + 'list-prompt', + 'Test list completion', + [PromptArgument::make('status')] + ); + $listProvider = new \PhpMcp\Server\Defaults\ListCompletionProvider(['draft', 'published', 'archived']); + $providers = ['status' => $listProvider]; + + $original = RegisteredPrompt::make( + $schema, + [PromptHandlerFixture::class, 'generateSimpleGreeting'], + true, + $providers + ); + + $array = $original->toArray(); + expect($array['completionProviders']['status'])->toBeString(); // Serialized instance + + $rehydrated = RegisteredPrompt::fromArray($array); + expect($rehydrated->completionProviders['status'])->toBeInstanceOf(\PhpMcp\Server\Defaults\ListCompletionProvider::class); +}); + +it('can be serialized with EnumCompletionProvider instances', function () { + $schema = PromptSchema::make( + 'enum-prompt', + 'Test enum completion', + [PromptArgument::make('priority')] + ); + + $enumProvider = new \PhpMcp\Server\Defaults\EnumCompletionProvider(TestEnum::class); + $providers = ['priority' => $enumProvider]; + + $original = RegisteredPrompt::make( + $schema, + [PromptHandlerFixture::class, 'generateSimpleGreeting'], + true, + $providers + ); + + $array = $original->toArray(); + expect($array['completionProviders']['priority'])->toBeString(); // Serialized instance + + $rehydrated = RegisteredPrompt::fromArray($array); + expect($rehydrated->completionProviders['priority'])->toBeInstanceOf(\PhpMcp\Server\Defaults\EnumCompletionProvider::class); +}); diff --git a/tests/Unit/Elements/RegisteredResourceTemplateTest.php b/tests/Unit/Elements/RegisteredResourceTemplateTest.php index 758b5e7..ce3dd5e 100644 --- a/tests/Unit/Elements/RegisteredResourceTemplateTest.php +++ b/tests/Unit/Elements/RegisteredResourceTemplateTest.php @@ -3,7 +3,6 @@ namespace PhpMcp\Server\Tests\Unit\Elements; use Mockery; -use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; use PhpMcp\Schema\ResourceTemplate; use PhpMcp\Server\Elements\RegisteredResourceTemplate; use PhpMcp\Schema\Content\TextResourceContents; @@ -12,8 +11,6 @@ use Psr\Container\ContainerInterface; use PhpMcp\Schema\Annotations; -uses(MockeryPHPUnitIntegration::class); - beforeEach(function () { $this->container = Mockery::mock(ContainerInterface::class); $this->handlerInstance = new ResourceHandlerFixture(); @@ -59,9 +56,9 @@ expect($template->handler)->toBe([ResourceHandlerFixture::class, 'getUserDocument']); expect($template->isManual)->toBeFalse(); expect($template->completionProviders)->toEqual($completionProviders); - expect($template->getCompletionProvider('userId'))->toBe(CompletionProviderFixture::class); - expect($template->getCompletionProvider('documentId'))->toBe('Another\ProviderClass'); - expect($template->getCompletionProvider('nonExistentVar'))->toBeNull(); + expect($template->completionProviders['userId'])->toBe(CompletionProviderFixture::class); + expect($template->completionProviders['documentId'])->toBe('Another\ProviderClass'); + expect($template->completionProviders)->not->toHaveKey('nonExistentVar'); }); it('can be made as a manual registration', function () { @@ -185,6 +182,7 @@ ); $providers = ['type' => CompletionProviderFixture::class]; + $serializedProviders = ['type' => serialize(CompletionProviderFixture::class)]; $original = RegisteredResourceTemplate::make( $schema, @@ -201,7 +199,7 @@ expect($array['schema']['annotations']['priority'])->toBe(0.7); expect($array['handler'])->toBe([ResourceHandlerFixture::class, 'getUserDocument']); expect($array['isManual'])->toBeTrue(); - expect($array['completionProviders'])->toEqual($providers); + expect($array['completionProviders'])->toEqual($serializedProviders); $rehydrated = RegisteredResourceTemplate::fromArray($array); expect($rehydrated)->toBeInstanceOf(RegisteredResourceTemplate::class); diff --git a/tests/Unit/ServerBuilderTest.php b/tests/Unit/ServerBuilderTest.php index 69ded02..ab46f62 100644 --- a/tests/Unit/ServerBuilderTest.php +++ b/tests/Unit/ServerBuilderTest.php @@ -42,7 +42,7 @@ public function noArgsHandler(): string public function handlerWithCompletion( string $name, - #[CompletionProvider(providerClass: SB_DummyCompletionProvider::class)] + #[CompletionProvider(provider: SB_DummyCompletionProvider::class)] string $uriParam ): array { return []; @@ -467,7 +467,56 @@ public function getCompletions(string $currentValue, SessionInterface $session): expect($prompt->schema->arguments)->toHaveCount(2); expect($prompt->schema->arguments[0]->name)->toBe('name'); expect($prompt->schema->arguments[1]->name)->toBe('uriParam'); - expect($prompt->getCompletionProvider('uriParam'))->toBe(SB_DummyCompletionProvider::class); + expect($prompt->completionProviders['uriParam'])->toBe(SB_DummyCompletionProvider::class); +}); + +// Add test fixtures for enhanced completion providers +class SB_DummyHandlerWithEnhancedCompletion +{ + public function handleWithListCompletion( + #[CompletionProvider(values: ['option1', 'option2', 'option3'])] + string $choice + ): array { + return [['role' => 'user', 'content' => "Selected: {$choice}"]]; + } + + public function handleWithEnumCompletion( + #[CompletionProvider(enum: SB_TestEnum::class)] + string $status + ): array { + return [['role' => 'user', 'content' => "Status: {$status}"]]; + } +} + +enum SB_TestEnum: string +{ + case PENDING = 'pending'; + case ACTIVE = 'active'; + case INACTIVE = 'inactive'; +} + +it('creates ListCompletionProvider for values attribute in manual registration', function () { + $handler = [SB_DummyHandlerWithEnhancedCompletion::class, 'handleWithListCompletion']; + + $server = $this->builder + ->withServerInfo('Test', '1.0') + ->withPrompt($handler, 'listPrompt') + ->build(); + + $prompt = $server->getRegistry()->getPrompt('listPrompt'); + expect($prompt->completionProviders['choice'])->toBeInstanceOf(\PhpMcp\Server\Defaults\ListCompletionProvider::class); +}); + +it('creates EnumCompletionProvider for enum attribute in manual registration', function () { + $handler = [SB_DummyHandlerWithEnhancedCompletion::class, 'handleWithEnumCompletion']; + + $server = $this->builder + ->withServerInfo('Test', '1.0') + ->withPrompt($handler, 'enumPrompt') + ->build(); + + $prompt = $server->getRegistry()->getPrompt('enumPrompt'); + expect($prompt->completionProviders['status'])->toBeInstanceOf(\PhpMcp\Server\Defaults\EnumCompletionProvider::class); }); // it('throws DefinitionException if HandlerResolver fails for a manual element', function () {