diff --git a/README.md b/README.md index 1683ac9..42e41cb 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ This SDK enables you to expose your PHP application's functionality as standardi - **๐Ÿ—๏ธ Modern Architecture**: Built with PHP 8.1+ features, PSR standards, and modular design - **๐Ÿ“ก Multiple Transports**: Supports `stdio`, `http+sse`, and new **streamable HTTP** with resumability - **๐ŸŽฏ Attribute-Based Definition**: Use PHP 8 Attributes (`#[McpTool]`, `#[McpResource]`, etc.) for zero-config element registration +- **๐Ÿ”ง Flexible Handlers**: Support for closures, class methods, static methods, and invokable classes - **๐Ÿ“ Smart Schema Generation**: Automatic JSON schema generation from method signatures with optional `#[Schema]` attribute enhancements - **โšก Session Management**: Advanced session handling with multiple storage backends - **๐Ÿ”„ Event-Driven**: ReactPHP-based for high concurrency and non-blocking operations @@ -332,7 +333,7 @@ $server->discover( ### 2. ๐Ÿ”ง Manual Registration -Register elements programmatically using the `ServerBuilder` before calling `build()`. Useful for dynamic registration or when you prefer explicit control. +Register elements programmatically using the `ServerBuilder` before calling `build()`. Useful for dynamic registration, closures, or when you prefer explicit control. ```php use App\Handlers\{EmailHandler, ConfigHandler, UserHandler, PromptHandler}; @@ -354,10 +355,21 @@ $server = Server::make() // Register invokable class as tool ->withTool(UserHandler::class) // Handler: Invokable class - // Register a resource + // Register a closure as tool + ->withTool( + function(int $a, int $b): int { // Handler: Closure + return $a + $b; + }, + name: 'add_numbers', + description: 'Add two numbers together' + ) + + // Register a resource with closure ->withResource( - [ConfigHandler::class, 'getConfig'], - uri: 'config://app/settings', // URI (required) + function(): array { // Handler: Closure + return ['timestamp' => time(), 'server' => 'php-mcp']; + }, + uri: 'config://runtime/status', // URI (required) mimeType: 'application/json' // MIME type (optional) ) @@ -367,22 +379,26 @@ $server = Server::make() uriTemplate: 'user://{userId}/profile' // URI template (required) ) - // Register a prompt + // Register a prompt with closure ->withPrompt( - [PromptHandler::class, 'generateSummary'], - name: 'summarize_text' // Prompt name (optional) + function(string $topic, string $tone = 'professional'): array { + return [ + ['role' => 'user', 'content' => "Write about {$topic} in a {$tone} tone"] + ]; + }, + name: 'writing_prompt' // Prompt name (optional) ) ->build(); ``` -**Key Features:** +The server supports three flexible handler formats: `[ClassName::class, 'methodName']` for class method handlers, `InvokableClass::class` for invokable class handlers (classes with `__invoke` method), and any PHP callable including closures, static methods like `[SomeClass::class, 'staticMethod']`, or function names. Class-based handlers are resolved via the configured PSR-11 container for dependency injection. Manual registrations are never cached and take precedence over discovered elements with the same identifier. -- **Handler Formats**: Use `[ClassName::class, 'methodName']` or `InvokableClass::class` -- **Dependency Injection**: Handlers resolved via configured PSR-11 container -- **Immediate Registration**: Elements registered when `build()` is called -- **No Caching**: Manual elements are never cached (always fresh) -- **Precedence**: Manual registrations override discovered elements with same identifier +> [!IMPORTANT] +> When using closures as handlers, the server generates minimal JSON schemas based only on PHP type hints since there are no docblocks or class context available. For more detailed schemas with validation constraints, descriptions, and formats, you have two options: +> +> - Use the [`#[Schema]` attribute](#-schema-generation-and-validation) for enhanced schema generation +> - Provide a custom `$inputSchema` parameter when registering tools with `->withTool()` ### ๐Ÿ† Element Precedence & Discovery @@ -1289,8 +1305,3 @@ The MIT License (MIT). See [LICENSE](LICENSE) for details. - Built on the [Model Context Protocol](https://modelcontextprotocol.io/) specification - Powered by [ReactPHP](https://reactphp.org/) for async operations - Uses [PSR standards](https://www.php-fig.org/) for maximum interoperability - ---- - -**Ready to build powerful MCP servers with PHP?** Start with our [Quick Start](#-quick-start-stdio-server-with-discovery) guide! ๐Ÿš€ - diff --git a/examples/02-discovery-http-userprofile/server.php b/examples/02-discovery-http-userprofile/server.php index 2b924ce..30b2665 100644 --- a/examples/02-discovery-http-userprofile/server.php +++ b/examples/02-discovery-http-userprofile/server.php @@ -70,6 +70,48 @@ public function log($level, \Stringable|string $message, array $context = []): v ->withCapabilities(ServerCapabilities::make(completions: true, logging: true)) ->withLogger($logger) ->withContainer($container) + ->withTool( + function (float $a, float $b, string $operation = 'add'): array { + $result = match ($operation) { + 'add' => $a + $b, + 'subtract' => $a - $b, + 'multiply' => $a * $b, + 'divide' => $b != 0 ? $a / $b : throw new \InvalidArgumentException('Cannot divide by zero'), + default => throw new \InvalidArgumentException("Unknown operation: {$operation}") + }; + + return [ + 'operation' => $operation, + 'operands' => [$a, $b], + 'result' => $result + ]; + }, + name: 'calculator', + description: 'Perform basic math operations (add, subtract, multiply, divide)' + ) + ->withResource( + function (): array { + $memoryUsage = memory_get_usage(true); + $memoryPeak = memory_get_peak_usage(true); + $uptime = time() - $_SERVER['REQUEST_TIME_FLOAT'] ?? time(); + $serverSoftware = $_SERVER['SERVER_SOFTWARE'] ?? 'CLI'; + + return [ + 'server_time' => date('Y-m-d H:i:s'), + 'uptime_seconds' => $uptime, + 'memory_usage_mb' => round($memoryUsage / 1024 / 1024, 2), + 'memory_peak_mb' => round($memoryPeak / 1024 / 1024, 2), + 'php_version' => PHP_VERSION, + 'server_software' => $serverSoftware, + 'operating_system' => PHP_OS_FAMILY, + 'status' => 'healthy' + ]; + }, + uri: 'system://status', + name: 'system_status', + description: 'Current system status and runtime information', + mimeType: 'application/json' + ) ->build(); $server->discover(__DIR__, ['.']); diff --git a/src/Elements/RegisteredElement.php b/src/Elements/RegisteredElement.php index 18a7846..918e3a7 100644 --- a/src/Elements/RegisteredElement.php +++ b/src/Elements/RegisteredElement.php @@ -9,6 +9,7 @@ use PhpMcp\Server\Exception\McpServerException; use Psr\Container\ContainerInterface; use ReflectionException; +use ReflectionFunctionAbstract; use ReflectionMethod; use ReflectionNamedType; use ReflectionParameter; @@ -18,32 +19,43 @@ class RegisteredElement implements JsonSerializable { public function __construct( - public readonly string $handlerClass, - public readonly string $handlerMethod, + public readonly \Closure|array|string $handler, public readonly bool $isManual = false, - ) { - } + ) {} public function handle(ContainerInterface $container, array $arguments): mixed { - $instance = $container->get($this->handlerClass); - $arguments = $this->prepareArguments($instance, $arguments); - $method = $this->handlerMethod; + if (is_string($this->handler)) { + $reflection = new \ReflectionFunction($this->handler); + $arguments = $this->prepareArguments($reflection, $arguments); + $instance = $container->get($this->handler); + return call_user_func($instance, ...$arguments); + } - return $instance->$method(...$arguments); - } + if (is_callable($this->handler)) { + $reflection = $this->getReflectionForCallable($this->handler); + $arguments = $this->prepareArguments($reflection, $arguments); + return call_user_func($this->handler, ...$arguments); + } - protected function prepareArguments(object $instance, array $arguments): array - { - if (! method_exists($instance, $this->handlerMethod)) { - throw new ReflectionException("Method does not exist: {$this->handlerClass}::{$this->handlerMethod}"); + if (is_array($this->handler)) { + [$className, $methodName] = $this->handler; + $reflection = new \ReflectionMethod($className, $methodName); + $arguments = $this->prepareArguments($reflection, $arguments); + + $instance = $container->get($className); + return call_user_func([$instance, $methodName], ...$arguments); } - $reflectionMethod = new ReflectionMethod($instance, $this->handlerMethod); + throw new \InvalidArgumentException('Invalid handler type'); + } + + protected function prepareArguments(\ReflectionFunctionAbstract $reflection, array $arguments): array + { $finalArgs = []; - foreach ($reflectionMethod->getParameters() as $parameter) { + foreach ($reflection->getParameters() as $parameter) { // TODO: Handle variadic parameters. $paramName = $parameter->getName(); $paramPosition = $parameter->getPosition(); @@ -67,8 +79,11 @@ protected function prepareArguments(object $instance, array $arguments): array } elseif ($parameter->isOptional()) { continue; } else { + $reflectionName = $reflection instanceof \ReflectionMethod + ? $reflection->class . '::' . $reflection->name + : 'Closure'; throw McpServerException::internalError( - "Missing required argument `{$paramName}` for {$reflectionMethod->class}::{$this->handlerMethod}." + "Missing required argument `{$paramName}` for {$reflectionName}." ); } } @@ -76,6 +91,27 @@ protected function prepareArguments(object $instance, array $arguments): array return array_values($finalArgs); } + /** + * Gets a ReflectionMethod or ReflectionFunction for a callable. + */ + private function getReflectionForCallable(callable $handler): \ReflectionMethod|\ReflectionFunction + { + if (is_string($handler)) { + return new \ReflectionFunction($handler); + } + + if ($handler instanceof \Closure) { + return new \ReflectionFunction($handler); + } + + if (is_array($handler) && count($handler) === 2) { + [$class, $method] = $handler; + return new \ReflectionMethod($class, $method); + } + + throw new \InvalidArgumentException('Cannot create reflection for this callable type'); + } + /** * Attempts type casting based on ReflectionParameter type hints. * @@ -118,7 +154,7 @@ private function castArgumentType(mixed $argument, ReflectionParameter $paramete return $case; } } - $validNames = array_map(fn ($c) => $c->name, $typeName::cases()); + $validNames = array_map(fn($c) => $c->name, $typeName::cases()); throw new InvalidArgumentException( "Invalid value '{$argument}' for unit enum {$typeName}. Expected one of: " . implode(', ', $validNames) . "." ); @@ -205,8 +241,7 @@ private function castToArray(mixed $argument): array public function toArray(): array { return [ - 'handlerClass' => $this->handlerClass, - 'handlerMethod' => $this->handlerMethod, + 'handler' => $this->handler, 'isManual' => $this->isManual, ]; } diff --git a/src/Elements/RegisteredPrompt.php b/src/Elements/RegisteredPrompt.php index b9bc8e9..9e421bd 100644 --- a/src/Elements/RegisteredPrompt.php +++ b/src/Elements/RegisteredPrompt.php @@ -21,17 +21,16 @@ class RegisteredPrompt extends RegisteredElement { public function __construct( public readonly Prompt $schema, - string $handlerClass, - string $handlerMethod, + \Closure|array|string $handler, bool $isManual = false, public readonly array $completionProviders = [] ) { - parent::__construct($handlerClass, $handlerMethod, $isManual); + parent::__construct($handler, $isManual); } - public static function make(Prompt $schema, string $handlerClass, string $handlerMethod, bool $isManual = false, array $completionProviders = []): self + public static function make(Prompt $schema, \Closure|array|string $handler, bool $isManual = false, array $completionProviders = []): self { - return new self($schema, $handlerClass, $handlerMethod, $isManual, $completionProviders); + return new self($schema, $handler, $isManual, $completionProviders); } /** @@ -279,10 +278,13 @@ public function toArray(): array public static function fromArray(array $data): self|false { try { + if (! isset($data['schema']) || ! isset($data['handler'])) { + return false; + } + return new self( Prompt::fromArray($data['schema']), - $data['handlerClass'], - $data['handlerMethod'], + $data['handler'], $data['isManual'] ?? false, $data['completionProviders'] ?? [], ); diff --git a/src/Elements/RegisteredResource.php b/src/Elements/RegisteredResource.php index b196e80..d139774 100644 --- a/src/Elements/RegisteredResource.php +++ b/src/Elements/RegisteredResource.php @@ -16,16 +16,15 @@ class RegisteredResource extends RegisteredElement { public function __construct( public readonly Resource $schema, - string $handlerClass, - string $handlerMethod, + \Closure|array|string $handler, bool $isManual = false, ) { - parent::__construct($handlerClass, $handlerMethod, $isManual); + parent::__construct($handler, $isManual); } - public static function make(Resource $schema, string $handlerClass, string $handlerMethod, bool $isManual = false): self + public static function make(Resource $schema, \Closure|array|string $handler, bool $isManual = false): self { - return new self($schema, $handlerClass, $handlerMethod, $isManual); + return new self($schema, $handler, $isManual); } /** @@ -99,7 +98,7 @@ protected function formatResult(mixed $readResult, string $uri, ?string $mimeTyp } if ($allAreEmbeddedResource && $hasEmbeddedResource) { - return array_map(fn ($item) => $item->resource, $readResult); + return array_map(fn($item) => $item->resource, $readResult); } if ($hasResourceContents || $hasEmbeddedResource) { @@ -218,10 +217,13 @@ public function toArray(): array public static function fromArray(array $data): self|false { try { + if (! isset($data['schema']) || ! isset($data['handler'])) { + return false; + } + return new self( Resource::fromArray($data['schema']), - $data['handlerClass'], - $data['handlerMethod'], + $data['handler'], $data['isManual'] ?? false, ); } catch (Throwable $e) { diff --git a/src/Elements/RegisteredResourceTemplate.php b/src/Elements/RegisteredResourceTemplate.php index 50ebd4f..b431f78 100644 --- a/src/Elements/RegisteredResourceTemplate.php +++ b/src/Elements/RegisteredResourceTemplate.php @@ -20,19 +20,18 @@ class RegisteredResourceTemplate extends RegisteredElement public function __construct( public readonly ResourceTemplate $schema, - string $handlerClass, - string $handlerMethod, + \Closure|array|string $handler, bool $isManual = false, public readonly array $completionProviders = [] ) { - parent::__construct($handlerClass, $handlerMethod, $isManual); + parent::__construct($handler, $isManual); $this->compileTemplate(); } - public static function make(ResourceTemplate $schema, string $handlerClass, string $handlerMethod, bool $isManual = false, array $completionProviders = []): self + public static function make(ResourceTemplate $schema, \Closure|array|string $handler, bool $isManual = false, array $completionProviders = []): self { - return new self($schema, $handlerClass, $handlerMethod, $isManual, $completionProviders); + return new self($schema, $handler, $isManual, $completionProviders); } /** @@ -156,7 +155,7 @@ protected function formatResult(mixed $readResult, string $uri, ?string $mimeTyp } if ($allAreEmbeddedResource && $hasEmbeddedResource) { - return array_map(fn ($item) => $item->resource, $readResult); + return array_map(fn($item) => $item->resource, $readResult); } if ($hasResourceContents || $hasEmbeddedResource) { @@ -276,10 +275,13 @@ public function toArray(): array public static function fromArray(array $data): self|false { try { + if (! isset($data['schema']) || ! isset($data['handler'])) { + return false; + } + return new self( ResourceTemplate::fromArray($data['schema']), - $data['handlerClass'], - $data['handlerMethod'], + $data['handler'], $data['isManual'] ?? false, $data['completionProviders'] ?? [], ); diff --git a/src/Elements/RegisteredTool.php b/src/Elements/RegisteredTool.php index d16e99b..2592662 100644 --- a/src/Elements/RegisteredTool.php +++ b/src/Elements/RegisteredTool.php @@ -14,16 +14,15 @@ class RegisteredTool extends RegisteredElement { public function __construct( public readonly Tool $schema, - string $handlerClass, - string $handlerMethod, + \Closure|array|string $handler, bool $isManual = false, ) { - parent::__construct($handlerClass, $handlerMethod, $isManual); + parent::__construct($handler, $isManual); } - public static function make(Tool $schema, string $handlerClass, string $handlerMethod, bool $isManual = false): self + public static function make(Tool $schema, \Closure|array|string $handler, bool $isManual = false): self { - return new self($schema, $handlerClass, $handlerMethod, $isManual); + return new self($schema, $handler, $isManual); } /** @@ -125,10 +124,13 @@ public function toArray(): array public static function fromArray(array $data): self|false { try { + if (! isset($data['schema']) || ! isset($data['handler'])) { + return false; + } + return new self( Tool::fromArray($data['schema']), - $data['handlerClass'], - $data['handlerMethod'], + $data['handler'], $data['isManual'] ?? false, ); } catch (Throwable $e) { diff --git a/src/Registry.php b/src/Registry.php index ddb3a51..689a0a9 100644 --- a/src/Registry.php +++ b/src/Registry.php @@ -181,7 +181,7 @@ public function load(): void } } - public function registerTool(Tool $tool, string $handlerClass, string $handlerMethod, bool $isManual = false): void + public function registerTool(Tool $tool, \Closure|array|string $handler, bool $isManual = false): void { $toolName = $tool->name; $existing = $this->tools[$toolName] ?? null; @@ -192,12 +192,12 @@ public function registerTool(Tool $tool, string $handlerClass, string $handlerMe return; } - $this->tools[$toolName] = RegisteredTool::make($tool, $handlerClass, $handlerMethod, $isManual); + $this->tools[$toolName] = RegisteredTool::make($tool, $handler, $isManual); $this->checkAndEmitChange('tools', $this->tools); } - public function registerResource(Resource $resource, string $handlerClass, string $handlerMethod, bool $isManual = false): void + public function registerResource(Resource $resource, \Closure|array|string $handler, bool $isManual = false): void { $uri = $resource->uri; $existing = $this->resources[$uri] ?? null; @@ -208,15 +208,14 @@ public function registerResource(Resource $resource, string $handlerClass, strin return; } - $this->resources[$uri] = RegisteredResource::make($resource, $handlerClass, $handlerMethod, $isManual); + $this->resources[$uri] = RegisteredResource::make($resource, $handler, $isManual); $this->checkAndEmitChange('resources', $this->resources); } public function registerResourceTemplate( ResourceTemplate $template, - string $handlerClass, - string $handlerMethod, + \Closure|array|string $handler, array $completionProviders = [], bool $isManual = false, ): void { @@ -229,15 +228,14 @@ public function registerResourceTemplate( return; } - $this->resourceTemplates[$uriTemplate] = RegisteredResourceTemplate::make($template, $handlerClass, $handlerMethod, $isManual, $completionProviders); + $this->resourceTemplates[$uriTemplate] = RegisteredResourceTemplate::make($template, $handler, $isManual, $completionProviders); $this->checkAndEmitChange('resource_templates', $this->resourceTemplates); } public function registerPrompt( Prompt $prompt, - string $handlerClass, - string $handlerMethod, + \Closure|array|string $handler, array $completionProviders = [], bool $isManual = false, ): void { @@ -250,7 +248,7 @@ public function registerPrompt( return; } - $this->prompts[$promptName] = RegisteredPrompt::make($prompt, $handlerClass, $handlerMethod, $isManual, $completionProviders); + $this->prompts[$promptName] = RegisteredPrompt::make($prompt, $handler, $isManual, $completionProviders); $this->checkAndEmitChange('prompts', $this->prompts); } @@ -297,24 +295,40 @@ public function save(): bool foreach ($this->tools as $name => $tool) { if (! $tool->isManual) { + if ($tool->handler instanceof \Closure) { + $this->logger->warning("Skipping closure tool from cache: {$name}"); + continue; + } $discoveredData['tools'][$name] = json_encode($tool); } } foreach ($this->resources as $uri => $resource) { if (! $resource->isManual) { + if ($resource->handler instanceof \Closure) { + $this->logger->warning("Skipping closure resource from cache: {$uri}"); + continue; + } $discoveredData['resources'][$uri] = json_encode($resource); } } foreach ($this->prompts as $name => $prompt) { if (! $prompt->isManual) { + if ($prompt->handler instanceof \Closure) { + $this->logger->warning("Skipping closure prompt from cache: {$name}"); + continue; + } $discoveredData['prompts'][$name] = json_encode($prompt); } } foreach ($this->resourceTemplates as $uriTemplate => $template) { if (! $template->isManual) { + if ($template->handler instanceof \Closure) { + $this->logger->warning("Skipping closure template from cache: {$uriTemplate}"); + continue; + } $discoveredData['resourceTemplates'][$uriTemplate] = json_encode($template); } } diff --git a/src/ServerBuilder.php b/src/ServerBuilder.php index 3f9b361..4f8028b 100644 --- a/src/ServerBuilder.php +++ b/src/ServerBuilder.php @@ -4,6 +4,7 @@ namespace PhpMcp\Server; +use Closure; use PhpMcp\Schema\Annotations; use PhpMcp\Schema\Implementation; use PhpMcp\Schema\Prompt; @@ -55,7 +56,7 @@ final class ServerBuilder private ?string $instructions = null; /** @var array< - * array{handler: array|string, + * array{handler: array|string|Closure, * name: string|null, * description: string|null, * annotations: ToolAnnotations|null} @@ -63,7 +64,7 @@ final class ServerBuilder private array $manualTools = []; /** @var array< - * array{handler: array|string, + * array{handler: array|string|Closure, * uri: string, * name: string|null, * description: string|null, @@ -74,7 +75,7 @@ final class ServerBuilder private array $manualResources = []; /** @var array< - * array{handler: array|string, + * array{handler: array|string|Closure, * uriTemplate: string, * name: string|null, * description: string|null, @@ -84,7 +85,7 @@ final class ServerBuilder private array $manualResourceTemplates = []; /** @var array< - * array{handler: array|string, + * array{handler: array|string|Closure, * name: string|null, * description: string|null} * > */ @@ -212,9 +213,9 @@ public function withLoop(LoopInterface $loop): self /** * Manually registers a tool handler. */ - public function withTool(array|string $handler, ?string $name = null, ?string $description = null, ?ToolAnnotations $annotations = null): self + public function withTool(\Closure|array|string $handler, ?string $name = null, ?string $description = null, ?ToolAnnotations $annotations = null, ?array $inputSchema = null): self { - $this->manualTools[] = compact('handler', 'name', 'description', 'annotations'); + $this->manualTools[] = compact('handler', 'name', 'description', 'annotations', 'inputSchema'); return $this; } @@ -222,7 +223,7 @@ public function withTool(array|string $handler, ?string $name = null, ?string $d /** * Manually registers a resource handler. */ - public function withResource(array|string $handler, string $uri, ?string $name = null, ?string $description = null, ?string $mimeType = null, ?int $size = null, ?Annotations $annotations = null): self + public function withResource(\Closure|array|string $handler, string $uri, ?string $name = null, ?string $description = null, ?string $mimeType = null, ?int $size = null, ?Annotations $annotations = null): self { $this->manualResources[] = compact('handler', 'uri', 'name', 'description', 'mimeType', 'size', 'annotations'); @@ -232,7 +233,7 @@ public function withResource(array|string $handler, string $uri, ?string $name = /** * Manually registers a resource template handler. */ - public function withResourceTemplate(array|string $handler, string $uriTemplate, ?string $name = null, ?string $description = null, ?string $mimeType = null, ?Annotations $annotations = null): self + public function withResourceTemplate(\Closure|array|string $handler, string $uriTemplate, ?string $name = null, ?string $description = null, ?string $mimeType = null, ?Annotations $annotations = null): self { $this->manualResourceTemplates[] = compact('handler', 'uriTemplate', 'name', 'description', 'mimeType', 'annotations'); @@ -242,7 +243,7 @@ public function withResourceTemplate(array|string $handler, string $uriTemplate, /** * Manually registers a prompt handler. */ - public function withPrompt(array|string $handler, ?string $name = null, ?string $description = null): self + public function withPrompt(\Closure|array|string $handler, ?string $name = null, ?string $description = null): self { $this->manualPrompts[] = compact('handler', 'name', 'description'); @@ -309,20 +310,27 @@ private function registerManualElements(Registry $registry, LoggerInterface $log // Register Tools foreach ($this->manualTools as $data) { try { - $reflectionMethod = HandlerResolver::resolve($data['handler']); - $className = $reflectionMethod->getDeclaringClass()->getName(); - $classShortName = $reflectionMethod->getDeclaringClass()->getShortName(); - $methodName = $reflectionMethod->getName(); - $docBlock = $docBlockParser->parseDocBlock($reflectionMethod->getDocComment() ?? null); + $reflection = HandlerResolver::resolve($data['handler']); + + if ($reflection instanceof \ReflectionFunction) { + $name = $data['name'] ?? 'closure_tool_' . spl_object_id($data['handler']); + $description = $data['description'] ?? null; + } else { + $classShortName = $reflection->getDeclaringClass()->getShortName(); + $methodName = $reflection->getName(); + $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null); + + $name = $data['name'] ?? ($methodName === '__invoke' ? $classShortName : $methodName); + $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; + } - $name = $data['name'] ?? ($methodName === '__invoke' ? $classShortName : $methodName); - $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; - $inputSchema = $schemaGenerator->generate($reflectionMethod); + $inputSchema = $data['inputSchema'] ?? $schemaGenerator->generate($reflection); $tool = Tool::make($name, $inputSchema, $description, $data['annotations']); - $registry->registerTool($tool, $className, $methodName, true); + $registry->registerTool($tool, $data['handler'], true); - $logger->debug("Registered manual tool {$name} from handler {$className}::{$methodName}"); + $handlerDesc = $data['handler'] instanceof \Closure ? 'Closure' : (is_array($data['handler']) ? implode('::', $data['handler']) : $data['handler']); + $logger->debug("Registered manual tool {$name} from handler {$handlerDesc}"); } catch (Throwable $e) { $logger->error('Failed to register manual tool', ['handler' => $data['handler'], 'name' => $data['name'], 'exception' => $e]); throw new ConfigurationException("Error registering manual tool '{$data['name']}': {$e->getMessage()}", 0, $e); @@ -332,23 +340,30 @@ private function registerManualElements(Registry $registry, LoggerInterface $log // Register Resources foreach ($this->manualResources as $data) { try { - $reflectionMethod = HandlerResolver::resolve($data['handler']); - $className = $reflectionMethod->getDeclaringClass()->getName(); - $classShortName = $reflectionMethod->getDeclaringClass()->getShortName(); - $methodName = $reflectionMethod->getName(); - $docBlock = $docBlockParser->parseDocBlock($reflectionMethod->getDocComment() ?? null); + $reflection = HandlerResolver::resolve($data['handler']); + + if ($reflection instanceof \ReflectionFunction) { + $name = $data['name'] ?? 'closure_resource_' . spl_object_id($data['handler']); + $description = $data['description'] ?? null; + } else { + $classShortName = $reflection->getDeclaringClass()->getShortName(); + $methodName = $reflection->getName(); + $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null); + + $name = $data['name'] ?? ($methodName === '__invoke' ? $classShortName : $methodName); + $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; + } $uri = $data['uri']; - $name = $data['name'] ?? ($methodName === '__invoke' ? $classShortName : $methodName); - $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; $mimeType = $data['mimeType']; $size = $data['size']; $annotations = $data['annotations']; $resource = Resource::make($uri, $name, $description, $mimeType, $annotations, $size); - $registry->registerResource($resource, $className, $methodName, true); + $registry->registerResource($resource, $data['handler'], true); - $logger->debug("Registered manual resource {$name} from handler {$className}::{$methodName}"); + $handlerDesc = $data['handler'] instanceof \Closure ? 'Closure' : (is_array($data['handler']) ? implode('::', $data['handler']) : $data['handler']); + $logger->debug("Registered manual resource {$name} from handler {$handlerDesc}"); } catch (Throwable $e) { $logger->error('Failed to register manual resource', ['handler' => $data['handler'], 'uri' => $data['uri'], 'exception' => $e]); throw new ConfigurationException("Error registering manual resource '{$data['uri']}': {$e->getMessage()}", 0, $e); @@ -358,23 +373,30 @@ private function registerManualElements(Registry $registry, LoggerInterface $log // Register Templates foreach ($this->manualResourceTemplates as $data) { try { - $reflectionMethod = HandlerResolver::resolve($data['handler']); - $className = $reflectionMethod->getDeclaringClass()->getName(); - $classShortName = $reflectionMethod->getDeclaringClass()->getShortName(); - $methodName = $reflectionMethod->getName(); - $docBlock = $docBlockParser->parseDocBlock($reflectionMethod->getDocComment() ?? null); + $reflection = HandlerResolver::resolve($data['handler']); + + if ($reflection instanceof \ReflectionFunction) { + $name = $data['name'] ?? 'closure_template_' . spl_object_id($data['handler']); + $description = $data['description'] ?? null; + } else { + $classShortName = $reflection->getDeclaringClass()->getShortName(); + $methodName = $reflection->getName(); + $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null); + + $name = $data['name'] ?? ($methodName === '__invoke' ? $classShortName : $methodName); + $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; + } $uriTemplate = $data['uriTemplate']; - $name = $data['name'] ?? ($methodName === '__invoke' ? $classShortName : $methodName); - $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; $mimeType = $data['mimeType']; $annotations = $data['annotations']; $template = ResourceTemplate::make($uriTemplate, $name, $description, $mimeType, $annotations); - $completionProviders = $this->getCompletionProviders($reflectionMethod); - $registry->registerResourceTemplate($template, $className, $methodName, $completionProviders, true); + $completionProviders = $this->getCompletionProviders($reflection); + $registry->registerResourceTemplate($template, $data['handler'], $completionProviders, true); - $logger->debug("Registered manual template {$name} from handler {$className}::{$methodName}"); + $handlerDesc = $data['handler'] instanceof \Closure ? 'Closure' : (is_array($data['handler']) ? implode('::', $data['handler']) : $data['handler']); + $logger->debug("Registered manual template {$name} from handler {$handlerDesc}"); } catch (Throwable $e) { $logger->error('Failed to register manual template', ['handler' => $data['handler'], 'uriTemplate' => $data['uriTemplate'], 'exception' => $e]); throw new ConfigurationException("Error registering manual resource template '{$data['uriTemplate']}': {$e->getMessage()}", 0, $e); @@ -384,18 +406,23 @@ private function registerManualElements(Registry $registry, LoggerInterface $log // Register Prompts foreach ($this->manualPrompts as $data) { try { - $reflectionMethod = HandlerResolver::resolve($data['handler']); - $className = $reflectionMethod->getDeclaringClass()->getName(); - $classShortName = $reflectionMethod->getDeclaringClass()->getShortName(); - $methodName = $reflectionMethod->getName(); - $docBlock = $docBlockParser->parseDocBlock($reflectionMethod->getDocComment() ?? null); - - $name = $data['name'] ?? ($methodName === '__invoke' ? $classShortName : $methodName); - $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; + $reflection = HandlerResolver::resolve($data['handler']); + + if ($reflection instanceof \ReflectionFunction) { + $name = $data['name'] ?? 'closure_prompt_' . spl_object_id($data['handler']); + $description = $data['description'] ?? null; + } else { + $classShortName = $reflection->getDeclaringClass()->getShortName(); + $methodName = $reflection->getName(); + $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null); + + $name = $data['name'] ?? ($methodName === '__invoke' ? $classShortName : $methodName); + $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; + } $arguments = []; - $paramTags = $docBlockParser->getParamTags($docBlock); - foreach ($reflectionMethod->getParameters() as $param) { + $paramTags = $reflection instanceof \ReflectionMethod ? $docBlockParser->getParamTags($docBlockParser->parseDocBlock($reflection->getDocComment() ?? null)) : []; + foreach ($reflection->getParameters() as $param) { $reflectionType = $param->getType(); // Basic DI check (heuristic) @@ -412,10 +439,11 @@ private function registerManualElements(Registry $registry, LoggerInterface $log } $prompt = Prompt::make($name, $description, $arguments); - $completionProviders = $this->getCompletionProviders($reflectionMethod); - $registry->registerPrompt($prompt, $className, $methodName, $completionProviders, true); + $completionProviders = $this->getCompletionProviders($reflection); + $registry->registerPrompt($prompt, $data['handler'], $completionProviders, true); - $logger->debug("Registered manual prompt {$name} from handler {$className}::{$methodName}"); + $handlerDesc = $data['handler'] instanceof \Closure ? 'Closure' : (is_array($data['handler']) ? implode('::', $data['handler']) : $data['handler']); + $logger->debug("Registered manual prompt {$name} from handler {$handlerDesc}"); } catch (Throwable $e) { $logger->error('Failed to register manual prompt', ['handler' => $data['handler'], 'name' => $data['name'], 'exception' => $e]); throw new ConfigurationException("Error registering manual prompt '{$data['name']}': {$e->getMessage()}", 0, $e); @@ -466,10 +494,10 @@ private function createCacheSessionHandler(): CacheSessionHandler return new CacheSessionHandler($this->cache, $this->sessionTtl ?? 3600); } - private function getCompletionProviders(\ReflectionMethod $reflectionMethod): array + private function getCompletionProviders(\ReflectionMethod|\ReflectionFunction $reflection): array { $completionProviders = []; - foreach ($reflectionMethod->getParameters() as $param) { + foreach ($reflection->getParameters() as $param) { $reflectionType = $param->getType(); if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) { continue; diff --git a/src/Utils/Discoverer.php b/src/Utils/Discoverer.php index 3913bad..1a73998 100644 --- a/src/Utils/Discoverer.php +++ b/src/Utils/Discoverer.php @@ -196,7 +196,7 @@ private function processMethod(ReflectionMethod $method, array &$discoveredCount $description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null; $inputSchema = $this->schemaGenerator->generate($method); $tool = Tool::make($name, $inputSchema, $description, $instance->annotations); - $this->registry->registerTool($tool, $className, $methodName); + $this->registry->registerTool($tool, [$className, $methodName]); $discoveredCount['tools']++; break; @@ -208,7 +208,7 @@ private function processMethod(ReflectionMethod $method, array &$discoveredCount $size = $instance->size; $annotations = $instance->annotations; $resource = Resource::make($instance->uri, $name, $description, $mimeType, $annotations, $size); - $this->registry->registerResource($resource, $className, $methodName); + $this->registry->registerResource($resource, [$className, $methodName]); $discoveredCount['resources']++; break; @@ -228,7 +228,7 @@ private function processMethod(ReflectionMethod $method, array &$discoveredCount } $prompt = Prompt::make($name, $description, $arguments); $completionProviders = $this->getCompletionProviders($method); - $this->registry->registerPrompt($prompt, $className, $methodName, $completionProviders); + $this->registry->registerPrompt($prompt, [$className, $methodName], $completionProviders); $discoveredCount['prompts']++; break; @@ -240,7 +240,7 @@ private function processMethod(ReflectionMethod $method, array &$discoveredCount $annotations = $instance->annotations; $resourceTemplate = ResourceTemplate::make($instance->uriTemplate, $name, $description, $mimeType, $annotations); $completionProviders = $this->getCompletionProviders($method); - $this->registry->registerResourceTemplate($resourceTemplate, $className, $methodName, $completionProviders); + $this->registry->registerResourceTemplate($resourceTemplate, [$className, $methodName], $completionProviders); $discoveredCount['resourceTemplates']++; break; } diff --git a/src/Utils/HandlerResolver.php b/src/Utils/HandlerResolver.php index 090cbeb..0f0e55f 100644 --- a/src/Utils/HandlerResolver.php +++ b/src/Utils/HandlerResolver.php @@ -14,20 +14,27 @@ class HandlerResolver { /** - * Validates and resolves a handler to its class name, method name, and ReflectionMethod instance. + * Validates and resolves a handler to a ReflectionMethod or ReflectionFunction instance. * * A handler can be: - * - An array: [ClassName::class, 'methodName'] + * - A Closure: function() { ... } + * - An array: [ClassName::class, 'methodName'] (instance method) + * - An array: [ClassName::class, 'staticMethod'] (static method, if callable) * - A string: InvokableClassName::class (which will resolve to its '__invoke' method) * - * @param array|string $handler The handler to resolve. - * @return ReflectionMethod + * @param \Closure|array|string $handler The handler to resolve. + * @return \ReflectionMethod|\ReflectionFunction * * @throws InvalidArgumentException If the handler format is invalid, the class/method doesn't exist, - * or the method is unsuitable (e.g., static, private, abstract). + * or the method is unsuitable (e.g., private, abstract). */ - public static function resolve(array|string $handler): ReflectionMethod + public static function resolve(\Closure|array|string $handler): \ReflectionMethod|\ReflectionFunction { + // Handle Closures + if ($handler instanceof \Closure) { + return new \ReflectionFunction($handler); + } + $className = null; $methodName = null; @@ -49,15 +56,14 @@ public static function resolve(array|string $handler): ReflectionMethod throw new InvalidArgumentException("Invokable handler class '{$className}' must have a public '__invoke' method."); } } else { - throw new InvalidArgumentException('Invalid handler format. Expected [ClassName::class, \'methodName\'] or InvokableClassName::class string.'); + throw new InvalidArgumentException('Invalid handler format. Expected Closure, [ClassName::class, \'methodName\'] or InvokableClassName::class string.'); } try { $reflectionMethod = new ReflectionMethod($className, $methodName); - if ($reflectionMethod->isStatic()) { - throw new InvalidArgumentException("Handler method '{$className}::{$methodName}' cannot be static."); - } + // For discovered elements (non-manual), still reject static methods + // For manual elements, we'll allow static methods since they're callable if (!$reflectionMethod->isPublic()) { throw new InvalidArgumentException("Handler method '{$className}::{$methodName}' must be public."); } diff --git a/src/Utils/SchemaGenerator.php b/src/Utils/SchemaGenerator.php index 2153cc5..16e4a2c 100644 --- a/src/Utils/SchemaGenerator.php +++ b/src/Utils/SchemaGenerator.php @@ -31,27 +31,27 @@ public function __construct(DocBlockParser $docBlockParser) } /** - * Generates a JSON Schema object (as a PHP array) for a method's parameters. + * Generates a JSON Schema object (as a PHP array) for a method's or function's parameters. */ - public function generate(ReflectionMethod $method): array + public function generate(\ReflectionMethod|\ReflectionFunction $reflection): array { - $methodSchema = $this->extractMethodLevelSchema($method); + $methodSchema = $this->extractMethodLevelSchema($reflection); if ($methodSchema && isset($methodSchema['definition'])) { return $methodSchema['definition']; } - $parametersInfo = $this->parseParametersInfo($method); + $parametersInfo = $this->parseParametersInfo($reflection); return $this->buildSchemaFromParameters($parametersInfo, $methodSchema); } /** - * Extracts method-level Schema attribute. + * Extracts method-level or function-level Schema attribute. */ - private function extractMethodLevelSchema(ReflectionMethod $method): ?array + private function extractMethodLevelSchema(\ReflectionMethod|\ReflectionFunction $reflection): ?array { - $schemaAttrs = $method->getAttributes(Schema::class, \ReflectionAttribute::IS_INSTANCEOF); + $schemaAttrs = $reflection->getAttributes(Schema::class, \ReflectionAttribute::IS_INSTANCEOF); if (empty($schemaAttrs)) { return null; } @@ -263,7 +263,7 @@ private function buildVariadicParameterSchema(array $paramInfo): array // If no items specified by Schema attribute, infer from type if (!isset($paramSchema['items'])) { $itemJsonTypes = $this->mapPhpTypeToJsonSchemaType($paramInfo['type_string']); - $nonNullItemTypes = array_filter($itemJsonTypes, fn ($t) => $t !== 'null'); + $nonNullItemTypes = array_filter($itemJsonTypes, fn($t) => $t !== 'null'); if (count($nonNullItemTypes) === 1) { $paramSchema['items'] = ['type' => $nonNullItemTypes[0]]; @@ -405,14 +405,14 @@ private function applyArrayConstraints(array $paramSchema, array $paramInfo): ar * parameter_schema: array * }> */ - private function parseParametersInfo(ReflectionMethod $method): array + private function parseParametersInfo(\ReflectionMethod|\ReflectionFunction $reflection): array { - $docComment = $method->getDocComment() ?: null; + $docComment = $reflection->getDocComment() ?: null; $docBlock = $this->docBlockParser->parseDocBlock($docComment); $paramTags = $this->docBlockParser->getParamTags($docBlock); $parametersInfo = []; - foreach ($method->getParameters() as $rp) { + foreach ($reflection->getParameters() as $rp) { $paramName = $rp->getName(); $paramTag = $paramTags['$' . $paramName] ?? null; @@ -525,7 +525,7 @@ private function getTypeStringFromReflection(?ReflectionType $type, bool $native $types[] = $this->getTypeStringFromReflection($innerType, $innerType->allowsNull()); } if ($nativeAllowsNull) { - $types = array_filter($types, fn ($t) => strtolower($t) !== 'null'); + $types = array_filter($types, fn($t) => strtolower($t) !== 'null'); } $typeString = implode('|', array_unique(array_filter($types))); } elseif ($type instanceof ReflectionIntersectionType) { @@ -570,7 +570,7 @@ private function getTypeStringFromReflection(?ReflectionType $type, bool $native // Remove leading backslash from class names, but handle built-ins like 'int' or unions like 'int|string' if (str_contains($typeString, '\\')) { $parts = preg_split('/([|&])/', $typeString, -1, PREG_SPLIT_DELIM_CAPTURE); - $processedParts = array_map(fn ($part) => str_starts_with($part, '\\') ? ltrim($part, '\\') : $part, $parts); + $processedParts = array_map(fn($part) => str_starts_with($part, '\\') ? ltrim($part, '\\') : $part, $parts); $typeString = implode('', $processedParts); } diff --git a/test_closure_support.php b/test_closure_support.php new file mode 100644 index 0000000..946cb76 --- /dev/null +++ b/test_closure_support.php @@ -0,0 +1,258 @@ +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 new file mode 100644 index 0000000..90aafdd --- /dev/null +++ b/test_unique_closure_names.php @@ -0,0 +1,144 @@ +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/Integration/DiscoveryTest.php b/tests/Integration/DiscoveryTest.php index ec7b54a..7adf82e 100644 --- a/tests/Integration/DiscoveryTest.php +++ b/tests/Integration/DiscoveryTest.php @@ -36,8 +36,7 @@ ->and($greetUserTool->isManual)->toBeFalse() ->and($greetUserTool->schema->name)->toBe('greet_user') ->and($greetUserTool->schema->description)->toBe('Greets a user by name.') - ->and($greetUserTool->handlerClass)->toBe(\PhpMcp\Server\Tests\Fixtures\Discovery\DiscoverableToolHandler::class) - ->and($greetUserTool->handlerMethod)->toBe('greet'); + ->and($greetUserTool->handler)->toBe([\PhpMcp\Server\Tests\Fixtures\Discovery\DiscoverableToolHandler::class, 'greet']); expect($greetUserTool->schema->inputSchema['properties'] ?? [])->toHaveKey('name'); $repeatActionTool = $this->registry->getTool('repeatAction'); @@ -50,8 +49,7 @@ $invokableCalcTool = $this->registry->getTool('InvokableCalculator'); expect($invokableCalcTool)->toBeInstanceOf(RegisteredTool::class) ->and($invokableCalcTool->isManual)->toBeFalse() - ->and($invokableCalcTool->handlerClass)->toBe(\PhpMcp\Server\Tests\Fixtures\Discovery\InvocableToolFixture::class) - ->and($invokableCalcTool->handlerMethod)->toBe('__invoke'); + ->and($invokableCalcTool->handler)->toBe([\PhpMcp\Server\Tests\Fixtures\Discovery\InvocableToolFixture::class, '__invoke']); expect($this->registry->getTool('private_tool_should_be_ignored'))->toBeNull(); expect($this->registry->getTool('protected_tool_should_be_ignored'))->toBeNull(); @@ -71,8 +69,7 @@ $invokableStatusRes = $this->registry->getResource('invokable://config/status'); expect($invokableStatusRes)->toBeInstanceOf(RegisteredResource::class) ->and($invokableStatusRes->isManual)->toBeFalse() - ->and($invokableStatusRes->handlerClass)->toBe(\PhpMcp\Server\Tests\Fixtures\Discovery\InvocableResourceFixture::class) - ->and($invokableStatusRes->handlerMethod)->toBe('__invoke'); + ->and($invokableStatusRes->handler)->toBe([\PhpMcp\Server\Tests\Fixtures\Discovery\InvocableResourceFixture::class, '__invoke']); // --- Assert Prompts --- @@ -92,7 +89,7 @@ $invokableGreeter = $this->registry->getPrompt('InvokableGreeterPrompt'); expect($invokableGreeter)->toBeInstanceOf(RegisteredPrompt::class) ->and($invokableGreeter->isManual)->toBeFalse() - ->and($invokableGreeter->handlerClass)->toBe(\PhpMcp\Server\Tests\Fixtures\Discovery\InvocablePromptFixture::class); + ->and($invokableGreeter->handler)->toBe([\PhpMcp\Server\Tests\Fixtures\Discovery\InvocablePromptFixture::class, '__invoke']); // --- Assert Resource Templates --- @@ -109,7 +106,7 @@ $invokableUserTemplate = $this->registry->getResourceTemplate('invokable://user-profile/{userId}'); expect($invokableUserTemplate)->toBeInstanceOf(RegisteredResourceTemplate::class) ->and($invokableUserTemplate->isManual)->toBeFalse() - ->and($invokableUserTemplate->handlerClass)->toBe(\PhpMcp\Server\Tests\Fixtures\Discovery\InvocableResourceTemplateFixture::class); + ->and($invokableUserTemplate->handler)->toBe([\PhpMcp\Server\Tests\Fixtures\Discovery\InvocableResourceTemplateFixture::class, '__invoke']); }); it('does not discover elements from excluded directories', function () { diff --git a/tests/Unit/DispatcherTest.php b/tests/Unit/DispatcherTest.php index 8d683c6..2c26da0 100644 --- a/tests/Unit/DispatcherTest.php +++ b/tests/Unit/DispatcherTest.php @@ -330,7 +330,7 @@ it('can handle resource read request and return resource contents', function () { $uri = 'file://data.txt'; $resourceSchema = ResourceSchema::make($uri, 'file_resource'); - $registeredResourceMock = Mockery::mock(RegisteredResource::class, [$resourceSchema, 'MyResourceHandler', 'read', false]); + $registeredResourceMock = Mockery::mock(RegisteredResource::class, [$resourceSchema, ['MyResourceHandler', 'read'], false]); $resourceContents = [TextContent::make('File content')]; $this->registry->shouldReceive('getResource')->with($uri)->andReturn($registeredResourceMock); @@ -388,7 +388,7 @@ $promptName = 'daily-summary'; $args = ['date' => '2024-07-16']; $promptSchema = PromptSchema::make($promptName, 'summary_prompt', [PromptArgument::make('date', required: true)]); - $registeredPromptMock = Mockery::mock(RegisteredPrompt::class, [$promptSchema, 'MyPromptHandler', 'get', false]); + $registeredPromptMock = Mockery::mock(RegisteredPrompt::class, [$promptSchema, ['MyPromptHandler', 'get'], false]); $promptMessages = [PromptMessage::make(Role::User, TextContent::make("Summary for 2024-07-16"))]; $this->registry->shouldReceive('getPrompt')->with($promptName)->andReturn($registeredPromptMock); @@ -405,7 +405,7 @@ it('can handle prompt get request and throw exception if required argument is missing', function () { $promptName = 'needs-topic'; $promptSchema = PromptSchema::make($promptName, '', [PromptArgument::make('topic', required: true)]); - $registeredPromptMock = Mockery::mock(RegisteredPrompt::class, [$promptSchema, 'MyPromptHandler', 'get', false]); + $registeredPromptMock = Mockery::mock(RegisteredPrompt::class, [$promptSchema, ['MyPromptHandler', 'get'], false]); $this->registry->shouldReceive('getPrompt')->with($promptName)->andReturn($registeredPromptMock); $request = GetPromptRequest::make(1, $promptName, ['other_arg' => 'value']); // 'topic' is missing @@ -433,7 +433,7 @@ $providerClass = get_class($mockCompletionProvider); $promptSchema = PromptSchema::make($promptName, '', [PromptArgument::make($argName)]); - $registeredPromptMock = Mockery::mock(RegisteredPrompt::class, [$promptSchema, 'MyPromptHandler', 'get', false]); + $registeredPromptMock = Mockery::mock(RegisteredPrompt::class, [$promptSchema, ['MyPromptHandler', 'get'], false]); $registeredPromptMock->shouldReceive('getCompletionProvider')->with($argName)->andReturn($providerClass); $this->registry->shouldReceive('getPrompt')->with($promptName)->andReturn($registeredPromptMock); @@ -458,7 +458,7 @@ $providerClass = get_class($mockCompletionProvider); $templateSchema = ResourceTemplateSchema::make($templateUri, 'item-template'); - $registeredTemplateMock = Mockery::mock(RegisteredResourceTemplate::class, [$templateSchema, 'MyResourceTemplateHandler', 'get', false]); + $registeredTemplateMock = Mockery::mock(RegisteredResourceTemplate::class, [$templateSchema, ['MyResourceTemplateHandler', 'get'], false]); $registeredTemplateMock->shouldReceive('getVariableNames')->andReturn(['itemId', 'catName']); $registeredTemplateMock->shouldReceive('getCompletionProvider')->with($uriVarName)->andReturn($providerClass); @@ -475,7 +475,7 @@ 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 = Mockery::mock(RegisteredPrompt::class, [$promptSchema, ['MyPromptHandler', 'get'], false]); $registeredPromptMock->shouldReceive('getCompletionProvider')->andReturn(null); $this->registry->shouldReceive('getPrompt')->with($promptName)->andReturn($registeredPromptMock); diff --git a/tests/Unit/Elements/RegisteredElementTest.php b/tests/Unit/Elements/RegisteredElementTest.php index 4a896fa..70cf965 100644 --- a/tests/Unit/Elements/RegisteredElementTest.php +++ b/tests/Unit/Elements/RegisteredElementTest.php @@ -18,16 +18,16 @@ }); it('can be constructed as manual or discovered', function () { - $elManual = new RegisteredElement(VariousTypesHandler::class, 'noArgsMethod', true); - $elDiscovered = new RegisteredElement(VariousTypesHandler::class, 'noArgsMethod', false); + $handler = [VariousTypesHandler::class, 'noArgsMethod']; + $elManual = new RegisteredElement($handler, true); + $elDiscovered = new RegisteredElement($handler, false); expect($elManual->isManual)->toBeTrue(); expect($elDiscovered->isManual)->toBeFalse(); - expect($elDiscovered->handlerClass)->toBe(VariousTypesHandler::class); - expect($elDiscovered->handlerMethod)->toBe('noArgsMethod'); + expect($elDiscovered->handler)->toBe($handler); }); it('prepares arguments in correct order for simple required types', function () { - $element = new RegisteredElement(VariousTypesHandler::class, 'simpleRequiredArgs'); + $element = new RegisteredElement([VariousTypesHandler::class, 'simpleRequiredArgs']); $args = ['pString' => 'hello', 'pBool' => true, 'pInt' => 123]; $result = $element->handle($this->container, $args); @@ -37,7 +37,7 @@ }); it('uses default values for missing optional arguments', function () { - $element = new RegisteredElement(VariousTypesHandler::class, 'optionalArgsWithDefaults'); + $element = new RegisteredElement([VariousTypesHandler::class, 'optionalArgsWithDefaults']); $result1 = $element->handle($this->container, ['pString' => 'override']); expect($result1['pString'])->toBe('override'); @@ -53,7 +53,7 @@ }); it('passes null for nullable arguments if not provided', function () { - $elementNoDefaults = new RegisteredElement(VariousTypesHandler::class, 'nullableArgsWithoutDefaults'); + $elementNoDefaults = new RegisteredElement([VariousTypesHandler::class, 'nullableArgsWithoutDefaults']); $result2 = $elementNoDefaults->handle($this->container, []); expect($result2['pString'])->toBeNull(); expect($result2['pInt'])->toBeNull(); @@ -61,7 +61,7 @@ }); it('passes null explicitly for nullable arguments', function () { - $element = new RegisteredElement(VariousTypesHandler::class, 'nullableArgsWithoutDefaults'); + $element = new RegisteredElement([VariousTypesHandler::class, 'nullableArgsWithoutDefaults']); $result = $element->handle($this->container, ['pString' => null, 'pInt' => null, 'pArray' => null]); expect($result['pString'])->toBeNull(); expect($result['pInt'])->toBeNull(); @@ -69,7 +69,7 @@ }); it('handles mixed type arguments', function () { - $element = new RegisteredElement(VariousTypesHandler::class, 'mixedTypeArg'); + $element = new RegisteredElement([VariousTypesHandler::class, 'mixedTypeArg']); $obj = new stdClass(); $testValues = [ 'a string', @@ -86,7 +86,7 @@ }); it('throws McpServerException for missing required argument', function () { - $element = new RegisteredElement(VariousTypesHandler::class, 'simpleRequiredArgs'); + $element = new RegisteredElement([VariousTypesHandler::class, 'simpleRequiredArgs']); $element->handle($this->container, ['pString' => 'hello', 'pInt' => 123]); })->throws(McpServerException::class, 'Missing required argument `pBool`'); @@ -118,7 +118,7 @@ ]); it('casts argument types correctly for valid inputs (comprehensive)', function (string $paramName, mixed $inputValue, mixed $expectedValue) { - $element = new RegisteredElement(VariousTypesHandler::class, 'comprehensiveArgumentTest'); + $element = new RegisteredElement([VariousTypesHandler::class, 'comprehensiveArgumentTest']); $allArgs = [ 'strParam' => 'default string', @@ -162,7 +162,7 @@ ]); it('throws McpServerException for invalid type casting', function (string $paramName, mixed $invalidValue, string $expectedMsgRegex) { - $element = new RegisteredElement(VariousTypesHandler::class, 'comprehensiveArgumentTest'); + $element = new RegisteredElement([VariousTypesHandler::class, 'comprehensiveArgumentTest']); $allArgs = [ /* fill with defaults as in valid_type_casts */ 'strParam' => 's', 'intParam' => 1, @@ -195,40 +195,40 @@ })->with('invalid_type_casts'); it('casts to BackedStringEnum correctly', function () { - $element = new RegisteredElement(VariousTypesHandler::class, 'backedEnumArgs'); + $element = new RegisteredElement([VariousTypesHandler::class, 'backedEnumArgs']); $result = $element->handle($this->container, ['pBackedString' => 'A', 'pBackedInt' => 1]); expect($result['pBackedString'])->toBe(BackedStringEnum::OptionA); }); it('throws for invalid BackedStringEnum value', function () { - $element = new RegisteredElement(VariousTypesHandler::class, 'backedEnumArgs'); + $element = new RegisteredElement([VariousTypesHandler::class, 'backedEnumArgs']); $element->handle($this->container, ['pBackedString' => 'Invalid', 'pBackedInt' => 1]); })->throws(McpServerException::class, "Invalid value 'Invalid' for backed enum"); it('casts to BackedIntEnum correctly', function () { - $element = new RegisteredElement(VariousTypesHandler::class, 'backedEnumArgs'); + $element = new RegisteredElement([VariousTypesHandler::class, 'backedEnumArgs']); $result = $element->handle($this->container, ['pBackedString' => 'A', 'pBackedInt' => 2]); expect($result['pBackedInt'])->toBe(BackedIntEnum::Second); }); it('throws for invalid BackedIntEnum value', function () { - $element = new RegisteredElement(VariousTypesHandler::class, 'backedEnumArgs'); + $element = new RegisteredElement([VariousTypesHandler::class, 'backedEnumArgs']); $element->handle($this->container, ['pBackedString' => 'A', 'pBackedInt' => 999]); })->throws(McpServerException::class, "Invalid value '999' for backed enum"); it('casts to UnitEnum correctly', function () { - $element = new RegisteredElement(VariousTypesHandler::class, 'unitEnumArg'); + $element = new RegisteredElement([VariousTypesHandler::class, 'unitEnumArg']); $result = $element->handle($this->container, ['pUnitEnum' => 'Yes']); expect($result['pUnitEnum'])->toBe(UnitEnum::Yes); }); it('throws for invalid UnitEnum value', function () { - $element = new RegisteredElement(VariousTypesHandler::class, 'unitEnumArg'); + $element = new RegisteredElement([VariousTypesHandler::class, 'unitEnumArg']); $element->handle($this->container, ['pUnitEnum' => 'Invalid']); })->throws(McpServerException::class, "Invalid value 'Invalid' for unit enum"); it('throws ReflectionException if handler method does not exist', function () { - $element = new RegisteredElement(VariousTypesHandler::class, 'nonExistentMethod'); + $element = new RegisteredElement([VariousTypesHandler::class, 'nonExistentMethod']); $element->handle($this->container, []); -})->throws(\ReflectionException::class, "Method does not exist"); +})->throws(\ReflectionException::class, "VariousTypesHandler::nonExistentMethod() does not exist"); diff --git a/tests/Unit/Elements/RegisteredPromptTest.php b/tests/Unit/Elements/RegisteredPromptTest.php index 47bb3cc..49abe46 100644 --- a/tests/Unit/Elements/RegisteredPromptTest.php +++ b/tests/Unit/Elements/RegisteredPromptTest.php @@ -37,15 +37,13 @@ $providers = ['name' => CompletionProviderFixture::class]; $prompt = RegisteredPrompt::make( $this->promptSchema, - PromptHandlerFixture::class, - 'promptWithArgumentCompletion', + [PromptHandlerFixture::class, 'promptWithArgumentCompletion'], false, $providers ); expect($prompt->schema)->toBe($this->promptSchema); - expect($prompt->handlerClass)->toBe(PromptHandlerFixture::class); - expect($prompt->handlerMethod)->toBe('promptWithArgumentCompletion'); + expect($prompt->handler)->toBe([PromptHandlerFixture::class, 'promptWithArgumentCompletion']); expect($prompt->isManual)->toBeFalse(); expect($prompt->completionProviders)->toEqual($providers); expect($prompt->getCompletionProvider('name'))->toBe(CompletionProviderFixture::class); @@ -53,7 +51,7 @@ }); it('can be made as a manual registration', function () { - $manualPrompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, 'generateSimpleGreeting', true); + $manualPrompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'generateSimpleGreeting'], true); expect($manualPrompt->isManual)->toBeTrue(); }); @@ -65,14 +63,14 @@ ->andReturn([['role' => 'user', 'content' => 'Warm greeting for Alice.']]); $this->container->shouldReceive('get')->with(PromptHandlerFixture::class)->andReturn($handlerMock); - $prompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, 'generateSimpleGreeting'); + $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'generateSimpleGreeting']); $messages = $prompt->get($this->container, ['name' => 'Alice', 'style' => 'warm']); expect($messages[0]->content->text)->toBe('Warm greeting for Alice.'); }); it('formats single PromptMessage object from handler', function () { - $prompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, 'returnSinglePromptMessageObject'); + $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnSinglePromptMessageObject']); $messages = $prompt->get($this->container, []); expect($messages)->toBeArray()->toHaveCount(1); expect($messages[0])->toBeInstanceOf(PromptMessage::class); @@ -80,7 +78,7 @@ }); it('formats array of PromptMessage objects from handler as is', function () { - $prompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, 'returnArrayOfPromptMessageObjects'); + $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnArrayOfPromptMessageObjects']); $messages = $prompt->get($this->container, []); expect($messages)->toBeArray()->toHaveCount(2); expect($messages[0]->content->text)->toBe("First message object."); @@ -88,13 +86,13 @@ }); it('formats empty array from handler as empty array', function () { - $prompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, 'returnEmptyArrayForPrompt'); + $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnEmptyArrayForPrompt']); $messages = $prompt->get($this->container, []); expect($messages)->toBeArray()->toBeEmpty(); }); it('formats simple user/assistant map from handler', function () { - $prompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, 'returnSimpleUserAssistantMap'); + $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnSimpleUserAssistantMap']); $messages = $prompt->get($this->container, []); expect($messages)->toHaveCount(2); expect($messages[0]->role)->toBe(Role::User); @@ -104,7 +102,7 @@ }); it('formats user/assistant map with Content objects', function () { - $prompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, 'returnUserAssistantMapWithContentObjects'); + $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnUserAssistantMapWithContentObjects']); $messages = $prompt->get($this->container, []); expect($messages[0]->role)->toBe(Role::User); expect($messages[0]->content)->toBeInstanceOf(TextContent::class)->text->toBe("User text content object."); @@ -113,7 +111,7 @@ }); it('formats user/assistant map with mixed content (string and Content object)', function () { - $prompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, 'returnUserAssistantMapWithMixedContent'); + $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnUserAssistantMapWithMixedContent']); $messages = $prompt->get($this->container, []); expect($messages[0]->role)->toBe(Role::User); expect($messages[0]->content)->toBeInstanceOf(TextContent::class)->text->toBe("Plain user string."); @@ -122,7 +120,7 @@ }); it('formats user/assistant map with array content', function () { - $prompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, 'returnUserAssistantMapWithArrayContent'); + $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnUserAssistantMapWithArrayContent']); $messages = $prompt->get($this->container, []); expect($messages[0]->role)->toBe(Role::User); expect($messages[0]->content)->toBeInstanceOf(TextContent::class)->text->toBe("User array content"); @@ -131,7 +129,7 @@ }); it('formats list of raw message arrays with various content types', function () { - $prompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, 'returnListOfRawMessageArrays'); + $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnListOfRawMessageArrays']); $messages = $prompt->get($this->container, []); expect($messages)->toHaveCount(6); expect($messages[0]->content->text)->toBe("First raw message string."); @@ -145,7 +143,7 @@ }); it('formats list of raw message arrays with scalar or array content (becoming JSON TextContent)', function () { - $prompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, 'returnListOfRawMessageArraysWithScalars'); + $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnListOfRawMessageArraysWithScalars']); $messages = $prompt->get($this->container, []); expect($messages)->toHaveCount(5); expect($messages[0]->content->text)->toBe("123"); @@ -156,7 +154,7 @@ }); it('formats mixed array of PromptMessage objects and raw message arrays', function () { - $prompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, 'returnMixedArrayOfPromptMessagesAndRaw'); + $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnMixedArrayOfPromptMessagesAndRaw']); $messages = $prompt->get($this->container, []); expect($messages)->toHaveCount(4); expect($messages[0]->content)->toBeInstanceOf(TextContent::class)->text->toBe("This is a PromptMessage object."); @@ -176,7 +174,7 @@ it('throws RuntimeException for invalid prompt result formats', function (string|callable $handlerMethodOrCallable, string $expectedErrorPattern) { $methodName = is_string($handlerMethodOrCallable) ? $handlerMethodOrCallable : 'customReturn'; - $prompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, $methodName); + $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, $methodName]); if (is_callable($handlerMethodOrCallable)) { $this->container->shouldReceive('get')->with(PromptHandlerFixture::class)->andReturn( @@ -195,7 +193,7 @@ it('propagates exceptions from handler during get()', function () { - $prompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, 'promptHandlerThrows'); + $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'promptHandlerThrows']); $prompt->get($this->container, []); })->throws(\LogicException::class, "Prompt generation failed inside handler."); @@ -209,8 +207,7 @@ $providers = ['arg1' => CompletionProviderFixture::class]; $original = RegisteredPrompt::make( $schema, - PromptHandlerFixture::class, - 'generateSimpleGreeting', + [PromptHandlerFixture::class, 'generateSimpleGreeting'], true, $providers ); @@ -219,8 +216,7 @@ expect($array['schema']['name'])->toBe('serialize-prompt'); expect($array['schema']['arguments'])->toHaveCount(2); - expect($array['handlerClass'])->toBe(PromptHandlerFixture::class); - expect($array['handlerMethod'])->toBe('generateSimpleGreeting'); + expect($array['handler'])->toBe([PromptHandlerFixture::class, 'generateSimpleGreeting']); expect($array['isManual'])->toBeTrue(); expect($array['completionProviders'])->toEqual($providers); @@ -232,6 +228,6 @@ }); it('fromArray returns false on failure for prompt', function () { - $badData = ['schema' => ['name' => 'fail'], 'handlerClass' => null, 'handlerMethod' => null]; + $badData = ['schema' => ['name' => 'fail']]; expect(RegisteredPrompt::fromArray($badData))->toBeFalse(); }); diff --git a/tests/Unit/Elements/RegisteredResourceTemplateTest.php b/tests/Unit/Elements/RegisteredResourceTemplateTest.php index bc74a52..758b5e7 100644 --- a/tests/Unit/Elements/RegisteredResourceTemplateTest.php +++ b/tests/Unit/Elements/RegisteredResourceTemplateTest.php @@ -51,14 +51,12 @@ $template = RegisteredResourceTemplate::make( schema: $schema, - handlerClass: ResourceHandlerFixture::class, - handlerMethod: 'getUserDocument', + handler: [ResourceHandlerFixture::class, 'getUserDocument'], completionProviders: $completionProviders ); expect($template->schema)->toBe($schema); - expect($template->handlerClass)->toBe(ResourceHandlerFixture::class); - expect($template->handlerMethod)->toBe('getUserDocument'); + expect($template->handler)->toBe([ResourceHandlerFixture::class, 'getUserDocument']); expect($template->isManual)->toBeFalse(); expect($template->completionProviders)->toEqual($completionProviders); expect($template->getCompletionProvider('userId'))->toBe(CompletionProviderFixture::class); @@ -75,8 +73,7 @@ $manualTemplate = RegisteredResourceTemplate::make( schema: $schema, - handlerClass: ResourceHandlerFixture::class, - handlerMethod: 'getUserDocument', + handler: [ResourceHandlerFixture::class, 'getUserDocument'], isManual: true ); @@ -101,7 +98,7 @@ it('matches URIs against template and extracts variables correctly', function (string $templateString, string $uriToTest, ?array $expectedVariables) { $schema = ResourceTemplate::make($templateString, 'test-match'); - $template = RegisteredResourceTemplate::make($schema, ResourceHandlerFixture::class, 'getUserDocument'); + $template = RegisteredResourceTemplate::make($schema, [ResourceHandlerFixture::class, 'getUserDocument']); if ($expectedVariables !== null) { expect($template->matches($uriToTest))->toBeTrue(); @@ -116,7 +113,7 @@ it('gets variable names from compiled template', function () { $schema = ResourceTemplate::make('foo://{varA}/bar/{varB_ext}.{format}', 'vars-test'); - $template = RegisteredResourceTemplate::make($schema, ResourceHandlerFixture::class, 'getUserDocument'); + $template = RegisteredResourceTemplate::make($schema, [ResourceHandlerFixture::class, 'getUserDocument']); expect($template->getVariableNames())->toEqualCanonicalizing(['varA', 'varB_ext', 'format']); }); @@ -124,7 +121,7 @@ $uriTemplate = 'item://{category}/{itemId}?format={format}'; $uri = 'item://electronics/tv-123?format=json_pretty'; $schema = ResourceTemplate::make($uriTemplate, 'item-details-template'); - $template = RegisteredResourceTemplate::make($schema, ResourceHandlerFixture::class, 'getTemplatedContent'); + $template = RegisteredResourceTemplate::make($schema, [ResourceHandlerFixture::class, 'getTemplatedContent']); expect($template->matches($uri))->toBeTrue(); @@ -148,7 +145,7 @@ $uriTemplate = 'item://{category}/{itemId}?format={format}'; $uri = 'item://books/bestseller?format=json_pretty'; $schema = ResourceTemplate::make($uriTemplate, 'test-mime', mimeType: 'application/vnd.custom-template-xml'); - $template = RegisteredResourceTemplate::make($schema, ResourceHandlerFixture::class, 'getTemplatedContent'); + $template = RegisteredResourceTemplate::make($schema, [ResourceHandlerFixture::class, 'getTemplatedContent']); expect($template->matches($uri))->toBeTrue(); $resultContents = $template->read($this->container, $uri); @@ -158,7 +155,7 @@ it('formats a simple string result from handler correctly for template', function () { $uri = 'item://tools/hammer'; $schema = ResourceTemplate::make('item://{type}/{name}', 'test-simple-string', mimeType: 'text/x-custom'); - $template = RegisteredResourceTemplate::make($schema, ResourceHandlerFixture::class, 'returnStringText'); + $template = RegisteredResourceTemplate::make($schema, [ResourceHandlerFixture::class, 'returnStringText']); expect($template->matches($uri))->toBeTrue(); $mockHandler = Mockery::mock(ResourceHandlerFixture::class); @@ -174,7 +171,7 @@ it('propagates exceptions from handler during read', function () { $uri = 'item://tools/hammer'; $schema = ResourceTemplate::make('item://{type}/{name}', 'test-simple-string', mimeType: 'text/x-custom'); - $template = RegisteredResourceTemplate::make($schema, ResourceHandlerFixture::class, 'handlerThrowsException'); + $template = RegisteredResourceTemplate::make($schema, [ResourceHandlerFixture::class, 'handlerThrowsException']); expect($template->matches($uri))->toBeTrue(); $template->read($this->container, $uri); })->throws(\DomainException::class, "Cannot read resource"); @@ -191,8 +188,7 @@ $original = RegisteredResourceTemplate::make( $schema, - ResourceHandlerFixture::class, - 'getUserDocument', + [ResourceHandlerFixture::class, 'getUserDocument'], true, $providers ); @@ -203,8 +199,7 @@ expect($array['schema']['name'])->toBe('my-template'); expect($array['schema']['mimeType'])->toBe('application/template+json'); expect($array['schema']['annotations']['priority'])->toBe(0.7); - expect($array['handlerClass'])->toBe(ResourceHandlerFixture::class); - expect($array['handlerMethod'])->toBe('getUserDocument'); + expect($array['handler'])->toBe([ResourceHandlerFixture::class, 'getUserDocument']); expect($array['isManual'])->toBeTrue(); expect($array['completionProviders'])->toEqual($providers); @@ -217,6 +212,6 @@ }); it('fromArray returns false on failure', function () { - $badData = ['schema' => ['uriTemplate' => 'fail'], 'handlerMethod' => null]; + $badData = ['schema' => ['uriTemplate' => 'fail']]; expect(RegisteredResourceTemplate::fromArray($badData))->toBeFalse(); }); diff --git a/tests/Unit/Elements/RegisteredResourceTest.php b/tests/Unit/Elements/RegisteredResourceTest.php index 5a72143..85976c5 100644 --- a/tests/Unit/Elements/RegisteredResourceTest.php +++ b/tests/Unit/Elements/RegisteredResourceTest.php @@ -27,8 +27,7 @@ $this->resourceSchema = ResourceSchema::make($this->testUri, 'test-resource', mimeType: 'text/plain'); $this->registeredResource = RegisteredResource::make( $this->resourceSchema, - ResourceHandlerFixture::class, - 'returnStringText' + [ResourceHandlerFixture::class, 'returnStringText'] ); }); @@ -41,21 +40,19 @@ it('constructs correctly and exposes schema', function () { expect($this->registeredResource->schema)->toBe($this->resourceSchema); - expect($this->registeredResource->handlerClass)->toBe(ResourceHandlerFixture::class); - expect($this->registeredResource->handlerMethod)->toBe('returnStringText'); + expect($this->registeredResource->handler)->toBe([ResourceHandlerFixture::class, 'returnStringText']); expect($this->registeredResource->isManual)->toBeFalse(); }); it('can be made as a manual registration', function () { - $manualResource = RegisteredResource::make($this->resourceSchema, ResourceHandlerFixture::class, 'returnStringText', true); + $manualResource = RegisteredResource::make($this->resourceSchema, [ResourceHandlerFixture::class, 'returnStringText'], true); expect($manualResource->isManual)->toBeTrue(); }); it('passes URI to handler if handler method expects it', function () { $resource = RegisteredResource::make( ResourceSchema::make($this->testUri, 'needs-uri'), - ResourceHandlerFixture::class, - 'resourceHandlerNeedsUri' + [ResourceHandlerFixture::class, 'resourceHandlerNeedsUri'] ); $handlerMock = Mockery::mock(ResourceHandlerFixture::class); @@ -72,8 +69,7 @@ it('does not require handler method to accept URI', function () { $resource = RegisteredResource::make( ResourceSchema::make($this->testUri, 'no-uri-param'), - ResourceHandlerFixture::class, - 'resourceHandlerDoesNotNeedUri' + [ResourceHandlerFixture::class, 'resourceHandlerDoesNotNeedUri'] ); $handlerMock = Mockery::mock(ResourceHandlerFixture::class); $handlerMock->shouldReceive('resourceHandlerDoesNotNeedUri')->once()->andReturn("Success no URI"); @@ -85,22 +81,22 @@ dataset('resource_handler_return_types', [ - 'string_text' => ['returnStringText', 'text/plain', fn ($text, $uri) => expect($text)->toBe("Plain string content for {$uri}"), null], - 'string_json_guess' => ['returnStringJson', 'application/json', fn ($text, $uri) => expect(json_decode($text, true)['uri_in_json'])->toBe($uri), null], - 'string_html_guess' => ['returnStringHtml', 'text/html', fn ($text, $uri) => expect($text)->toContain("{$uri}"), null], - 'array_json_schema_mime' => ['returnArrayJson', 'application/json', fn ($text, $uri) => expect(json_decode($text, true)['uri_in_array'])->toBe($uri), null], // schema has text/plain, overridden by array + JSON content - 'empty_array' => ['returnEmptyArray', 'application/json', fn ($text) => expect($text)->toBe('[]'), null], - 'stream_octet' => ['returnStream', 'application/octet-stream', null, fn ($blob, $uri) => expect(base64_decode($blob ?? ''))->toBe("Streamed content for {$uri}")], - 'array_for_blob' => ['returnArrayForBlobSchema', 'application/x-custom-blob-array', null, fn ($blob, $uri) => expect(base64_decode($blob ?? ''))->toBe("Blob for {$uri} via array")], - 'array_for_text' => ['returnArrayForTextSchema', 'text/vnd.custom-array-text', fn ($text, $uri) => expect($text)->toBe("Text from array for {$uri} via array"), null], - 'direct_TextResourceContents' => ['returnTextResourceContents', 'text/special-contents', fn ($text) => expect($text)->toBe('Direct TextResourceContents'), null], - 'direct_BlobResourceContents' => ['returnBlobResourceContents', 'application/custom-blob-contents', null, fn ($blob) => expect(base64_decode($blob ?? ''))->toBe('blobbycontents')], - 'direct_EmbeddedResource' => ['returnEmbeddedResource', 'application/vnd.custom-embedded', fn ($text) => expect($text)->toBe('Direct EmbeddedResource content'), null], + 'string_text' => ['returnStringText', 'text/plain', fn($text, $uri) => expect($text)->toBe("Plain string content for {$uri}"), null], + 'string_json_guess' => ['returnStringJson', 'application/json', fn($text, $uri) => expect(json_decode($text, true)['uri_in_json'])->toBe($uri), null], + 'string_html_guess' => ['returnStringHtml', 'text/html', fn($text, $uri) => expect($text)->toContain("{$uri}"), null], + 'array_json_schema_mime' => ['returnArrayJson', 'application/json', fn($text, $uri) => expect(json_decode($text, true)['uri_in_array'])->toBe($uri), null], // schema has text/plain, overridden by array + JSON content + 'empty_array' => ['returnEmptyArray', 'application/json', fn($text) => expect($text)->toBe('[]'), null], + 'stream_octet' => ['returnStream', 'application/octet-stream', null, fn($blob, $uri) => expect(base64_decode($blob ?? ''))->toBe("Streamed content for {$uri}")], + 'array_for_blob' => ['returnArrayForBlobSchema', 'application/x-custom-blob-array', null, fn($blob, $uri) => expect(base64_decode($blob ?? ''))->toBe("Blob for {$uri} via array")], + 'array_for_text' => ['returnArrayForTextSchema', 'text/vnd.custom-array-text', fn($text, $uri) => expect($text)->toBe("Text from array for {$uri} via array"), null], + 'direct_TextResourceContents' => ['returnTextResourceContents', 'text/special-contents', fn($text) => expect($text)->toBe('Direct TextResourceContents'), null], + 'direct_BlobResourceContents' => ['returnBlobResourceContents', 'application/custom-blob-contents', null, fn($blob) => expect(base64_decode($blob ?? ''))->toBe('blobbycontents')], + 'direct_EmbeddedResource' => ['returnEmbeddedResource', 'application/vnd.custom-embedded', fn($text) => expect($text)->toBe('Direct EmbeddedResource content'), null], ]); it('formats various handler return types correctly', function (string $handlerMethod, string $expectedMime, ?callable $textAssertion, ?callable $blobAssertion) { $schema = ResourceSchema::make($this->testUri, 'format-test'); - $resource = RegisteredResource::make($schema, ResourceHandlerFixture::class, $handlerMethod); + $resource = RegisteredResource::make($schema, [ResourceHandlerFixture::class, $handlerMethod]); $resultContents = $resource->read($this->container, $this->testUri); @@ -122,7 +118,7 @@ it('formats SplFileInfo based on schema MIME type (text)', function () { $schema = ResourceSchema::make($this->testUri, 'spl-text', mimeType: 'text/markdown'); - $resource = RegisteredResource::make($schema, ResourceHandlerFixture::class, 'returnSplFileInfo'); + $resource = RegisteredResource::make($schema, [ResourceHandlerFixture::class, 'returnSplFileInfo']); $result = $resource->read($this->container, $this->testUri); expect($result[0])->toBeInstanceOf(TextResourceContents::class); @@ -132,7 +128,7 @@ it('formats SplFileInfo based on schema MIME type (blob if not text like)', function () { $schema = ResourceSchema::make($this->testUri, 'spl-blob', mimeType: 'image/png'); - $resource = RegisteredResource::make($schema, ResourceHandlerFixture::class, 'returnSplFileInfo'); + $resource = RegisteredResource::make($schema, [ResourceHandlerFixture::class, 'returnSplFileInfo']); $result = $resource->read($this->container, $this->testUri); expect($result[0])->toBeInstanceOf(BlobResourceContents::class); @@ -141,7 +137,7 @@ }); it('formats array of ResourceContents as is', function () { - $resource = RegisteredResource::make($this->resourceSchema, ResourceHandlerFixture::class, 'returnArrayOfResourceContents'); + $resource = RegisteredResource::make($this->resourceSchema, [ResourceHandlerFixture::class, 'returnArrayOfResourceContents']); $results = $resource->read($this->container, $this->testUri); expect($results)->toHaveCount(2); expect($results[0])->toBeInstanceOf(TextResourceContents::class)->text->toBe('Part 1 of many RC'); @@ -149,7 +145,7 @@ }); it('formats array of EmbeddedResources by extracting their inner resource', function () { - $resource = RegisteredResource::make($this->resourceSchema, ResourceHandlerFixture::class, 'returnArrayOfEmbeddedResources'); + $resource = RegisteredResource::make($this->resourceSchema, [ResourceHandlerFixture::class, 'returnArrayOfEmbeddedResources']); $results = $resource->read($this->container, $this->testUri); expect($results)->toHaveCount(2); expect($results[0])->toBeInstanceOf(TextResourceContents::class)->text->toBe(''); @@ -157,7 +153,7 @@ }); it('formats mixed array with ResourceContent/EmbeddedResource by processing each item', function () { - $resource = RegisteredResource::make($this->resourceSchema, ResourceHandlerFixture::class, 'returnMixedArrayWithResourceTypes'); + $resource = RegisteredResource::make($this->resourceSchema, [ResourceHandlerFixture::class, 'returnMixedArrayWithResourceTypes']); $results = $resource->read($this->container, $this->testUri); expect($results)->toBeArray()->toHaveCount(4); @@ -172,8 +168,7 @@ it('propagates McpServerException from handler during read', function () { $resource = RegisteredResource::make( $this->resourceSchema, - ResourceHandlerFixture::class, - 'resourceHandlerNeedsUri' + [ResourceHandlerFixture::class, 'resourceHandlerNeedsUri'] ); $this->container->shouldReceive('get')->with(ResourceHandlerFixture::class)->andReturn( Mockery::mock(ResourceHandlerFixture::class, function (Mockery\MockInterface $mock) { @@ -184,12 +179,12 @@ })->throws(McpServerException::class, "Test error"); it('propagates other exceptions from handler during read', function () { - $resource = RegisteredResource::make($this->resourceSchema, ResourceHandlerFixture::class, 'handlerThrowsException'); + $resource = RegisteredResource::make($this->resourceSchema, [ResourceHandlerFixture::class, 'handlerThrowsException']); $resource->read($this->container, $this->testUri); })->throws(\DomainException::class, "Cannot read resource"); it('throws RuntimeException for unformattable handler result', function () { - $resource = RegisteredResource::make($this->resourceSchema, ResourceHandlerFixture::class, 'returnUnformattableType'); + $resource = RegisteredResource::make($this->resourceSchema, [ResourceHandlerFixture::class, 'returnUnformattableType']); $resource->read($this->container, $this->testUri); })->throws(\RuntimeException::class, "Cannot format resource read result for URI"); @@ -202,8 +197,7 @@ 'desc', 'app/foo', ), - ResourceHandlerFixture::class, - 'getStaticText', + [ResourceHandlerFixture::class, 'getStaticText'], true ); @@ -213,8 +207,7 @@ expect($array['schema']['name'])->toBe('my-resource'); expect($array['schema']['description'])->toBe('desc'); expect($array['schema']['mimeType'])->toBe('app/foo'); - expect($array['handlerClass'])->toBe(ResourceHandlerFixture::class); - expect($array['handlerMethod'])->toBe('getStaticText'); + expect($array['handler'])->toBe([ResourceHandlerFixture::class, 'getStaticText']); expect($array['isManual'])->toBeTrue(); $rehydrated = RegisteredResource::fromArray($array); @@ -225,6 +218,6 @@ }); it('fromArray returns false on failure', function () { - $badData = ['schema' => ['uri' => 'fail'], 'handlerClass' => null]; + $badData = ['schema' => ['uri' => 'fail']]; expect(RegisteredResource::fromArray($badData))->toBeFalse(); }); diff --git a/tests/Unit/Elements/RegisteredToolTest.php b/tests/Unit/Elements/RegisteredToolTest.php index 46256b5..f132c3e 100644 --- a/tests/Unit/Elements/RegisteredToolTest.php +++ b/tests/Unit/Elements/RegisteredToolTest.php @@ -29,28 +29,25 @@ $this->registeredTool = RegisteredTool::make( $this->toolSchema, - ToolHandlerFixture::class, - 'greet' + [ToolHandlerFixture::class, 'greet'] ); }); it('constructs correctly and exposes schema', function () { expect($this->registeredTool->schema)->toBe($this->toolSchema); - expect($this->registeredTool->handlerClass)->toBe(ToolHandlerFixture::class); - expect($this->registeredTool->handlerMethod)->toBe('greet'); + expect($this->registeredTool->handler)->toBe([ToolHandlerFixture::class, 'greet']); expect($this->registeredTool->isManual)->toBeFalse(); }); it('can be made as a manual registration', function () { - $manualTool = RegisteredTool::make($this->toolSchema, ToolHandlerFixture::class, 'greet', true); + $manualTool = RegisteredTool::make($this->toolSchema, [ToolHandlerFixture::class, 'greet'], true); expect($manualTool->isManual)->toBeTrue(); }); it('calls the handler with prepared arguments', function () { $tool = RegisteredTool::make( Tool::make('sum-tool', ['type' => 'object', 'properties' => ['a' => ['type' => 'integer'], 'b' => ['type' => 'integer']]]), - ToolHandlerFixture::class, - 'sum' + [ToolHandlerFixture::class, 'sum'] ); $mockHandler = Mockery::mock(ToolHandlerFixture::class); $mockHandler->shouldReceive('sum')->with(5, 10)->once()->andReturn(15); @@ -65,8 +62,7 @@ it('calls handler with no arguments if tool takes none and none provided', function () { $tool = RegisteredTool::make( Tool::make('no-args-tool', ['type' => 'object', 'properties' => []]), - ToolHandlerFixture::class, - 'noParamsTool' + [ToolHandlerFixture::class, 'noParamsTool'] ); $mockHandler = Mockery::mock(ToolHandlerFixture::class); $mockHandler->shouldReceive('noParamsTool')->withNoArgs()->once()->andReturn(['status' => 'done']); @@ -91,8 +87,7 @@ it('formats various scalar and simple object/array handler results into TextContent', function (string $handlerMethod, string $expectedText) { $tool = RegisteredTool::make( Tool::make('format-test-tool', ['type' => 'object', 'properties' => []]), - ToolHandlerFixture::class, - $handlerMethod + [ToolHandlerFixture::class, $handlerMethod] ); $resultContents = $tool->call($this->container, []); @@ -104,8 +99,7 @@ it('returns single Content object from handler as array with one Content object', function () { $tool = RegisteredTool::make( Tool::make('content-test-tool', ['type' => 'object', 'properties' => []]), - ToolHandlerFixture::class, - 'returnTextContent' + [ToolHandlerFixture::class, 'returnTextContent'] ); $resultContents = $tool->call($this->container, []); @@ -116,8 +110,7 @@ it('returns array of Content objects from handler as is', function () { $tool = RegisteredTool::make( Tool::make('content-array-tool', ['type' => 'object', 'properties' => []]), - ToolHandlerFixture::class, - 'returnArrayOfContent' + [ToolHandlerFixture::class, 'returnArrayOfContent'] ); $resultContents = $tool->call($this->container, []); @@ -129,8 +122,7 @@ it('formats mixed array from handler into array of Content objects', function () { $tool = RegisteredTool::make( Tool::make('mixed-array-tool', ['type' => 'object', 'properties' => []]), - ToolHandlerFixture::class, - 'returnMixedArray' + [ToolHandlerFixture::class, 'returnMixedArray'] ); $resultContents = $tool->call($this->container, []); @@ -149,8 +141,7 @@ it('formats empty array from handler into TextContent with "[]"', function () { $tool = RegisteredTool::make( Tool::make('empty-array-tool', ['type' => 'object', 'properties' => []]), - ToolHandlerFixture::class, - 'returnEmptyArray' + [ToolHandlerFixture::class, 'returnEmptyArray'] ); $resultContents = $tool->call($this->container, []); @@ -161,8 +152,7 @@ it('throws JsonException during formatResult if handler returns unencodable value', function () { $tool = RegisteredTool::make( Tool::make('unencodable-tool', ['type' => 'object', 'properties' => []]), - ToolHandlerFixture::class, - 'toolUnencodableResult' + [ToolHandlerFixture::class, 'toolUnencodableResult'] ); $tool->call($this->container, []); })->throws(JsonException::class); @@ -170,8 +160,7 @@ it('re-throws exceptions from handler execution wrapped in McpServerException from handle()', function () { $tool = RegisteredTool::make( Tool::make('exception-tool', ['type' => 'object', 'properties' => []]), - ToolHandlerFixture::class, - 'toolThatThrows' + [ToolHandlerFixture::class, 'toolThatThrows'] ); $this->container->shouldReceive('get')->with(ToolHandlerFixture::class)->once()->andReturn(new ToolHandlerFixture()); diff --git a/tests/Unit/RegistryTest.php b/tests/Unit/RegistryTest.php index 8be6ee6..1bab5fd 100644 --- a/tests/Unit/RegistryTest.php +++ b/tests/Unit/RegistryTest.php @@ -64,7 +64,7 @@ function getRegistryProperty(Registry $reg, string $propName) it('registers manual tool correctly', function () { $toolSchema = createTestToolSchema('manual-tool-1'); - $this->registry->registerTool($toolSchema, 'HandlerClass', 'method', true); + $this->registry->registerTool($toolSchema, ['HandlerClass', 'method'], true); $registeredTool = $this->registry->getTool('manual-tool-1'); expect($registeredTool)->toBeInstanceOf(RegisteredTool::class) @@ -75,7 +75,7 @@ function getRegistryProperty(Registry $reg, string $propName) it('registers discovered tool correctly', function () { $toolSchema = createTestToolSchema('discovered-tool-1'); - $this->registry->registerTool($toolSchema, 'HandlerClass', 'method', false); + $this->registry->registerTool($toolSchema, ['HandlerClass', 'method'], false); $registeredTool = $this->registry->getTool('discovered-tool-1'); expect($registeredTool)->toBeInstanceOf(RegisteredTool::class) @@ -85,7 +85,7 @@ function getRegistryProperty(Registry $reg, string $propName) it('registers manual resource correctly', function () { $resourceSchema = createTestResourceSchema('manual://res/1'); - $this->registry->registerResource($resourceSchema, 'HandlerClass', 'method', true); + $this->registry->registerResource($resourceSchema, ['HandlerClass', 'method'], true); $registeredResource = $this->registry->getResource('manual://res/1'); expect($registeredResource)->toBeInstanceOf(RegisteredResource::class) @@ -96,7 +96,7 @@ function getRegistryProperty(Registry $reg, string $propName) it('registers discovered resource correctly', function () { $resourceSchema = createTestResourceSchema('discovered://res/1'); - $this->registry->registerResource($resourceSchema, 'HandlerClass', 'method', false); + $this->registry->registerResource($resourceSchema, ['HandlerClass', 'method'], false); $registeredResource = $this->registry->getResource('discovered://res/1'); expect($registeredResource)->toBeInstanceOf(RegisteredResource::class) @@ -106,7 +106,7 @@ function getRegistryProperty(Registry $reg, string $propName) it('registers manual prompt correctly', function () { $promptSchema = createTestPromptSchema('manual-prompt-1'); - $this->registry->registerPrompt($promptSchema, 'HandlerClass', 'method', [], true); + $this->registry->registerPrompt($promptSchema, ['HandlerClass', 'method'], [], true); $registeredPrompt = $this->registry->getPrompt('manual-prompt-1'); expect($registeredPrompt)->toBeInstanceOf(RegisteredPrompt::class) @@ -117,7 +117,7 @@ function getRegistryProperty(Registry $reg, string $propName) it('registers discovered prompt correctly', function () { $promptSchema = createTestPromptSchema('discovered-prompt-1'); - $this->registry->registerPrompt($promptSchema, 'HandlerClass', 'method', [], false); + $this->registry->registerPrompt($promptSchema, ['HandlerClass', 'method'], [], false); $registeredPrompt = $this->registry->getPrompt('discovered-prompt-1'); expect($registeredPrompt)->toBeInstanceOf(RegisteredPrompt::class) @@ -127,7 +127,7 @@ function getRegistryProperty(Registry $reg, string $propName) it('registers manual resource template correctly', function () { $templateSchema = createTestTemplateSchema('manual://tmpl/{id}'); - $this->registry->registerResourceTemplate($templateSchema, 'HandlerClass', 'method', [], true); + $this->registry->registerResourceTemplate($templateSchema, ['HandlerClass', 'method'], [], true); $registeredTemplate = $this->registry->getResourceTemplate('manual://tmpl/{id}'); expect($registeredTemplate)->toBeInstanceOf(RegisteredResourceTemplate::class) @@ -138,7 +138,7 @@ function getRegistryProperty(Registry $reg, string $propName) it('registers discovered resource template correctly', function () { $templateSchema = createTestTemplateSchema('discovered://tmpl/{id}'); - $this->registry->registerResourceTemplate($templateSchema, 'HandlerClass', 'method', [], false); + $this->registry->registerResourceTemplate($templateSchema, ['HandlerClass', 'method'], [], false); $registeredTemplate = $this->registry->getResourceTemplate('discovered://tmpl/{id}'); expect($registeredTemplate)->toBeInstanceOf(RegisteredResourceTemplate::class) @@ -150,8 +150,8 @@ function getRegistryProperty(Registry $reg, string $propName) $exactResourceSchema = createTestResourceSchema('test://item/exact'); $templateSchema = createTestTemplateSchema('test://item/{itemId}'); - $this->registry->registerResource($exactResourceSchema, 'H', 'm'); - $this->registry->registerResourceTemplate($templateSchema, 'H', 'm'); + $this->registry->registerResource($exactResourceSchema, ['H', 'm']); + $this->registry->registerResourceTemplate($templateSchema, ['H', 'm']); $found = $this->registry->getResource('test://item/exact'); expect($found)->toBeInstanceOf(RegisteredResource::class) @@ -160,7 +160,7 @@ function getRegistryProperty(Registry $reg, string $propName) test('getResource finds template match if no exact URI match', function () { $templateSchema = createTestTemplateSchema('test://item/{itemId}'); - $this->registry->registerResourceTemplate($templateSchema, 'H', 'm'); + $this->registry->registerResourceTemplate($templateSchema, ['H', 'm']); $found = $this->registry->getResource('test://item/123'); expect($found)->toBeInstanceOf(RegisteredResourceTemplate::class) @@ -169,7 +169,7 @@ function getRegistryProperty(Registry $reg, string $propName) test('getResource returns null if no match and templates excluded', function () { $templateSchema = createTestTemplateSchema('test://item/{itemId}'); - $this->registry->registerResourceTemplate($templateSchema, 'H', 'm'); + $this->registry->registerResourceTemplate($templateSchema, ['H', 'm']); $found = $this->registry->getResource('test://item/123', false); expect($found)->toBeNull(); @@ -182,13 +182,13 @@ function getRegistryProperty(Registry $reg, string $propName) it('hasElements returns true if any manual elements exist', function () { expect($this->registry->hasElements())->toBeFalse(); - $this->registry->registerTool(createTestToolSchema('manual-only'), 'H', 'm', true); + $this->registry->registerTool(createTestToolSchema('manual-only'), ['H', 'm'], true); expect($this->registry->hasElements())->toBeTrue(); }); it('hasElements returns true if any discovered elements exist', function () { expect($this->registry->hasElements())->toBeFalse(); - $this->registry->registerTool(createTestToolSchema('discovered-only'), 'H', 'm', false); + $this->registry->registerTool(createTestToolSchema('discovered-only'), ['H', 'm'], false); expect($this->registry->hasElements())->toBeTrue(); }); @@ -205,17 +205,17 @@ function getRegistryProperty(Registry $reg, string $propName) $manualSchema = clone $discoveredSchema; match ($type) { - 'tool' => $this->registry->registerTool($discoveredSchema, 'H', 'm', false), - 'resource' => $this->registry->registerResource($discoveredSchema, 'H', 'm', false), - 'prompt' => $this->registry->registerPrompt($discoveredSchema, 'H', 'm', [], false), - 'template' => $this->registry->registerResourceTemplate($discoveredSchema, 'H', 'm', [], false), + 'tool' => $this->registry->registerTool($discoveredSchema, ['H', 'm'], false), + 'resource' => $this->registry->registerResource($discoveredSchema, ['H', 'm'], false), + 'prompt' => $this->registry->registerPrompt($discoveredSchema, ['H', 'm'], [], false), + 'template' => $this->registry->registerResourceTemplate($discoveredSchema, ['H', 'm'], [], false), }; match ($type) { - 'tool' => $this->registry->registerTool($manualSchema, 'H', 'm', true), - 'resource' => $this->registry->registerResource($manualSchema, 'H', 'm', true), - 'prompt' => $this->registry->registerPrompt($manualSchema, 'H', 'm', [], true), - 'template' => $this->registry->registerResourceTemplate($manualSchema, 'H', 'm', [], true), + 'tool' => $this->registry->registerTool($manualSchema, ['H', 'm'], true), + 'resource' => $this->registry->registerResource($manualSchema, ['H', 'm'], true), + 'prompt' => $this->registry->registerPrompt($manualSchema, ['H', 'm'], [], true), + 'template' => $this->registry->registerResourceTemplate($manualSchema, ['H', 'm'], [], true), }; $registeredElement = match ($type) { @@ -242,17 +242,17 @@ function getRegistryProperty(Registry $reg, string $propName) $discoveredSchema = clone $manualSchema; match ($type) { - 'tool' => $this->registry->registerTool($manualSchema, 'H', 'm', true), - 'resource' => $this->registry->registerResource($manualSchema, 'H', 'm', true), - 'prompt' => $this->registry->registerPrompt($manualSchema, 'H', 'm', [], true), - 'template' => $this->registry->registerResourceTemplate($manualSchema, 'H', 'm', [], true), + 'tool' => $this->registry->registerTool($manualSchema, ['H', 'm'], true), + 'resource' => $this->registry->registerResource($manualSchema, ['H', 'm'], true), + 'prompt' => $this->registry->registerPrompt($manualSchema, ['H', 'm'], [], true), + 'template' => $this->registry->registerResourceTemplate($manualSchema, ['H', 'm'], [], true), }; match ($type) { - 'tool' => $this->registry->registerTool($discoveredSchema, 'H', 'm', false), - 'resource' => $this->registry->registerResource($discoveredSchema, 'H', 'm', false), - 'prompt' => $this->registry->registerPrompt($discoveredSchema, 'H', 'm', [], false), - 'template' => $this->registry->registerResourceTemplate($discoveredSchema, 'H', 'm', [], false), + 'tool' => $this->registry->registerTool($discoveredSchema, ['H', 'm'], false), + 'resource' => $this->registry->registerResource($discoveredSchema, ['H', 'm'], false), + 'prompt' => $this->registry->registerPrompt($discoveredSchema, ['H', 'm'], [], false), + 'template' => $this->registry->registerResourceTemplate($discoveredSchema, ['H', 'm'], [], false), }; $registeredElement = match ($type) { @@ -271,8 +271,8 @@ function getRegistryProperty(Registry $reg, string $propName) $toolSchema1 = createTestToolSchema('cached-tool-1'); $resourceSchema1 = createTestResourceSchema('cached://res/1'); $cachedData = [ - 'tools' => [$toolSchema1->name => json_encode(RegisteredTool::make($toolSchema1, 'H', 'm'))], - 'resources' => [$resourceSchema1->uri => json_encode(RegisteredResource::make($resourceSchema1, 'H', 'm'))], + 'tools' => [$toolSchema1->name => json_encode(RegisteredTool::make($toolSchema1, ['H', 'm']))], + 'resources' => [$resourceSchema1->uri => json_encode(RegisteredResource::make($resourceSchema1, ['H', 'm']))], 'prompts' => [], 'resourceTemplates' => [], ]; @@ -292,7 +292,7 @@ function getRegistryProperty(Registry $reg, string $propName) $cachedToolSchema = createTestToolSchema($conflictName); $manualToolSchema = createTestToolSchema($conflictName); // Different instance - $cachedData = ['tools' => [$conflictName => json_encode(RegisteredTool::make($cachedToolSchema, 'H', 'm'))]]; + $cachedData = ['tools' => [$conflictName => json_encode(RegisteredTool::make($cachedToolSchema, ['H', 'm']))]]; $this->cache->shouldReceive('get')->with(DISCOVERED_CACHE_KEY_REG)->once()->andReturn($cachedData); $registry = new Registry($this->logger, $this->cache); @@ -300,7 +300,7 @@ function getRegistryProperty(Registry $reg, string $propName) expect($registry->getTool($conflictName)->schema->name)->toBe($cachedToolSchema->name); expect($registry->getTool($conflictName)->isManual)->toBeFalse(); - $registry->registerTool($manualToolSchema, 'H', 'm', true); + $registry->registerTool($manualToolSchema, ['H', 'm'], true); expect($registry->getTool($conflictName)->schema->name)->toBe($manualToolSchema->name); expect($registry->getTool($conflictName)->isManual)->toBeTrue(); @@ -310,10 +310,10 @@ function getRegistryProperty(Registry $reg, string $propName) it('saves only non-manual elements to cache', function () { $manualToolSchema = createTestToolSchema('manual-save'); $discoveredToolSchema = createTestToolSchema('discovered-save'); - $expectedRegisteredDiscoveredTool = RegisteredTool::make($discoveredToolSchema, 'H', 'm', false); + $expectedRegisteredDiscoveredTool = RegisteredTool::make($discoveredToolSchema, ['H', 'm'], false); - $this->registry->registerTool($manualToolSchema, 'H', 'm', true); - $this->registry->registerTool($discoveredToolSchema, 'H', 'm', false); + $this->registry->registerTool($manualToolSchema, ['H', 'm'], true); + $this->registry->registerTool($discoveredToolSchema, ['H', 'm'], false); $expectedCachedData = [ 'tools' => ['discovered-save' => json_encode($expectedRegisteredDiscoveredTool)], @@ -331,7 +331,7 @@ function getRegistryProperty(Registry $reg, string $propName) }); it('does not attempt to save to cache if cache is null', function () { - $this->registryNoCache->registerTool(createTestToolSchema('discovered-no-cache'), 'H', 'm', false); + $this->registryNoCache->registerTool(createTestToolSchema('discovered-no-cache'), ['H', 'm'], false); $result = $this->registryNoCache->save(); expect($result)->toBeFalse(); }); @@ -363,7 +363,7 @@ function getRegistryProperty(Registry $reg, string $propName) }); it('handles cache InvalidArgumentException during load gracefully', function () { - $this->cache->shouldReceive('get')->with(DISCOVERED_CACHE_KEY_REG)->once()->andThrow(new class () extends \Exception implements CacheInvalidArgumentException {}); + $this->cache->shouldReceive('get')->with(DISCOVERED_CACHE_KEY_REG)->once()->andThrow(new class() extends \Exception implements CacheInvalidArgumentException {}); $registry = new Registry($this->logger, $this->cache); expect($registry->hasElements())->toBeFalse(); @@ -371,8 +371,8 @@ function getRegistryProperty(Registry $reg, string $propName) it('clears non-manual elements and deletes cache file', function () { - $this->registry->registerTool(createTestToolSchema('manual-clear'), 'H', 'm', true); - $this->registry->registerTool(createTestToolSchema('discovered-clear'), 'H', 'm', false); + $this->registry->registerTool(createTestToolSchema('manual-clear'), ['H', 'm'], true); + $this->registry->registerTool(createTestToolSchema('discovered-clear'), ['H', 'm'], false); $this->cache->shouldReceive('delete')->with(DISCOVERED_CACHE_KEY_REG)->once()->andReturn(true); @@ -384,7 +384,7 @@ function getRegistryProperty(Registry $reg, string $propName) it('handles cache exceptions during clear gracefully', function () { - $this->registry->registerTool(createTestToolSchema('discovered-clear'), 'H', 'm', false); + $this->registry->registerTool(createTestToolSchema('discovered-clear'), ['H', 'm'], false); $this->cache->shouldReceive('delete')->with(DISCOVERED_CACHE_KEY_REG)->once()->andThrow(new \RuntimeException("Cache delete failed")); $this->registry->clear(); @@ -398,7 +398,7 @@ function getRegistryProperty(Registry $reg, string $propName) $emitted = $listType; }); - $this->registry->registerTool(createTestToolSchema('notifying-tool'), 'H', 'm'); + $this->registry->registerTool(createTestToolSchema('notifying-tool'), ['H', 'm']); expect($emitted)->toBe('tools'); }); @@ -408,7 +408,7 @@ function getRegistryProperty(Registry $reg, string $propName) $emitted = $listType; }); - $this->registry->registerResource(createTestResourceSchema('notify://res'), 'H', 'm'); + $this->registry->registerResource(createTestResourceSchema('notify://res'), ['H', 'm']); expect($emitted)->toBe('resources'); }); @@ -419,7 +419,7 @@ function getRegistryProperty(Registry $reg, string $propName) $emitted = true; }); - $this->registry->registerTool(createTestToolSchema('silent-tool'), 'H', 'm'); + $this->registry->registerTool(createTestToolSchema('silent-tool'), ['H', 'm']); expect($emitted)->toBeFalse(); $this->registry->enableNotifications(); @@ -450,12 +450,143 @@ function getRegistryProperty(Registry $reg, string $propName) } }); - $this->registry->registerTool($tool1, 'H', 'm1'); + $this->registry->registerTool($tool1, ['H', 'm1']); expect($callCount)->toBe(1); - $this->registry->registerTool($tool1, 'H', 'm1'); + $this->registry->registerTool($tool1, ['H', 'm1']); expect($callCount)->toBe(1); - $this->registry->registerTool($tool2, 'H', 'm2'); + $this->registry->registerTool($tool2, ['H', 'm2']); expect($callCount)->toBe(2); }); + +it('registers tool with closure handler correctly', function () { + $toolSchema = createTestToolSchema('closure-tool'); + $closure = function (string $input): string { + return "processed: $input"; + }; + + $this->registry->registerTool($toolSchema, $closure, true); + + $registeredTool = $this->registry->getTool('closure-tool'); + expect($registeredTool)->toBeInstanceOf(RegisteredTool::class) + ->and($registeredTool->schema)->toBe($toolSchema) + ->and($registeredTool->isManual)->toBeTrue() + ->and($registeredTool->handler)->toBe($closure); +}); + +it('registers resource with closure handler correctly', function () { + $resourceSchema = createTestResourceSchema('closure://res'); + $closure = function (string $uri): array { + return [new \PhpMcp\Schema\Content\TextContent("Resource: $uri")]; + }; + + $this->registry->registerResource($resourceSchema, $closure, true); + + $registeredResource = $this->registry->getResource('closure://res'); + expect($registeredResource)->toBeInstanceOf(RegisteredResource::class) + ->and($registeredResource->schema)->toBe($resourceSchema) + ->and($registeredResource->isManual)->toBeTrue() + ->and($registeredResource->handler)->toBe($closure); +}); + +it('registers prompt with closure handler correctly', function () { + $promptSchema = createTestPromptSchema('closure-prompt'); + $closure = function (string $topic): array { + return [ + \PhpMcp\Schema\Content\PromptMessage::make( + \PhpMcp\Schema\Enum\Role::User, + new \PhpMcp\Schema\Content\TextContent("Tell me about $topic") + ) + ]; + }; + + $this->registry->registerPrompt($promptSchema, $closure, [], true); + + $registeredPrompt = $this->registry->getPrompt('closure-prompt'); + expect($registeredPrompt)->toBeInstanceOf(RegisteredPrompt::class) + ->and($registeredPrompt->schema)->toBe($promptSchema) + ->and($registeredPrompt->isManual)->toBeTrue() + ->and($registeredPrompt->handler)->toBe($closure); +}); + +it('registers resource template with closure handler correctly', function () { + $templateSchema = createTestTemplateSchema('closure://item/{id}'); + $closure = function (string $uri, string $id): array { + return [new \PhpMcp\Schema\Content\TextContent("Item $id from $uri")]; + }; + + $this->registry->registerResourceTemplate($templateSchema, $closure, [], true); + + $registeredTemplate = $this->registry->getResourceTemplate('closure://item/{id}'); + expect($registeredTemplate)->toBeInstanceOf(RegisteredResourceTemplate::class) + ->and($registeredTemplate->schema)->toBe($templateSchema) + ->and($registeredTemplate->isManual)->toBeTrue() + ->and($registeredTemplate->handler)->toBe($closure); +}); + +it('does not save closure handlers to cache', function () { + $closure = function (): string { + return 'test'; + }; + $arrayHandler = ['TestClass', 'testMethod']; + + $closureTool = createTestToolSchema('closure-tool'); + $arrayTool = createTestToolSchema('array-tool'); + + $this->registry->registerTool($closureTool, $closure, true); + $this->registry->registerTool($arrayTool, $arrayHandler, false); + + $expectedCachedData = [ + 'tools' => ['array-tool' => json_encode(RegisteredTool::make($arrayTool, $arrayHandler, false))], + 'resources' => [], + 'prompts' => [], + 'resourceTemplates' => [], + ]; + + $this->cache->shouldReceive('set')->once() + ->with(DISCOVERED_CACHE_KEY_REG, $expectedCachedData) + ->andReturn(true); + + $result = $this->registry->save(); + expect($result)->toBeTrue(); +}); + +it('handles static method handlers correctly', function () { + $toolSchema = createTestToolSchema('static-tool'); + $staticHandler = [TestStaticHandler::class, 'handle']; + + $this->registry->registerTool($toolSchema, $staticHandler, true); + + $registeredTool = $this->registry->getTool('static-tool'); + expect($registeredTool)->toBeInstanceOf(RegisteredTool::class) + ->and($registeredTool->handler)->toBe($staticHandler); +}); + +it('handles invokable class string handlers correctly', function () { + $toolSchema = createTestToolSchema('invokable-tool'); + $invokableHandler = TestInvokableHandler::class; + + $this->registry->registerTool($toolSchema, $invokableHandler, true); + + $registeredTool = $this->registry->getTool('invokable-tool'); + expect($registeredTool)->toBeInstanceOf(RegisteredTool::class) + ->and($registeredTool->handler)->toBe($invokableHandler); +}); + +// Test helper classes +class TestStaticHandler +{ + public static function handle(): string + { + return 'static result'; + } +} + +class TestInvokableHandler +{ + public function __invoke(): string + { + return 'invokable result'; + } +} diff --git a/tests/Unit/ServerBuilderTest.php b/tests/Unit/ServerBuilderTest.php index d5884f5..69ded02 100644 --- a/tests/Unit/ServerBuilderTest.php +++ b/tests/Unit/ServerBuilderTest.php @@ -318,8 +318,7 @@ public function getCompletions(string $currentValue, SessionInterface $session): expect($tool->schema->name)->toBe('test-manual-tool'); expect($tool->schema->description)->toBe('A test tool'); expect($tool->schema->inputSchema)->toEqual(['type' => 'object', 'properties' => ['arg' => ['type' => 'string']], 'required' => ['arg']]); - expect($tool->handlerClass)->toBe(SB_DummyHandlerClass::class); - expect($tool->handlerMethod)->toBe('handle'); + expect($tool->handler)->toBe($handler); }); it('infers tool name from invokable class if not provided', function () { @@ -335,6 +334,126 @@ public function getCompletions(string $currentValue, SessionInterface $session): expect($tool->schema->name)->toBe('SB_DummyInvokableClass'); }); +it('registers tool with closure handler', function () { + $closure = function (string $message): string { + return "Hello, $message!"; + }; + + $server = $this->builder + ->withServerInfo('ClosureTest', '1.0') + ->withTool($closure, 'greet-tool', 'A greeting tool') + ->build(); + + $tool = $server->getRegistry()->getTool('greet-tool'); + expect($tool)->toBeInstanceOf(RegisteredTool::class); + expect($tool->isManual)->toBeTrue(); + expect($tool->schema->name)->toBe('greet-tool'); + expect($tool->schema->description)->toBe('A greeting tool'); + expect($tool->handler)->toBe($closure); + expect($tool->schema->inputSchema)->toEqual([ + 'type' => 'object', + 'properties' => ['message' => ['type' => 'string']], + 'required' => ['message'] + ]); +}); + +it('registers tool with static method handler', function () { + $handler = [SB_DummyHandlerClass::class, 'handle']; + + $server = $this->builder + ->withServerInfo('StaticTest', '1.0') + ->withTool($handler, 'static-tool', 'A static method tool') + ->build(); + + $tool = $server->getRegistry()->getTool('static-tool'); + expect($tool)->toBeInstanceOf(RegisteredTool::class); + expect($tool->isManual)->toBeTrue(); + expect($tool->schema->name)->toBe('static-tool'); + expect($tool->handler)->toBe($handler); +}); + +it('registers resource with closure handler', function () { + $closure = function (string $id): array { + return [ + 'uri' => "res://item/$id", + 'name' => "Item $id", + 'mimeType' => 'application/json' + ]; + }; + + $server = $this->builder + ->withServerInfo('ResourceTest', '1.0') + ->withResource($closure, 'res://items/{id}', 'dynamic_resource') + ->build(); + + $resource = $server->getRegistry()->getResource('res://items/{id}'); + expect($resource)->not->toBeNull(); + expect($resource->handler)->toBe($closure); + expect($resource->isManual)->toBeTrue(); +}); + +it('registers prompt with closure handler', function () { + $closure = function (string $topic): array { + return [ + 'role' => 'user', + 'content' => ['type' => 'text', 'text' => "Tell me about $topic"] + ]; + }; + + $server = $this->builder + ->withServerInfo('PromptTest', '1.0') + ->withPrompt($closure, 'topic-prompt', 'A topic-based prompt') + ->build(); + + $prompt = $server->getRegistry()->getPrompt('topic-prompt'); + expect($prompt)->not->toBeNull(); + expect($prompt->handler)->toBe($closure); + expect($prompt->isManual)->toBeTrue(); +}); + +it('infers closure tool name automatically', function () { + $closure = function (int $count): array { + return ['count' => $count]; + }; + + $server = $this->builder + ->withServerInfo('AutoNameTest', '1.0') + ->withTool($closure) + ->build(); + + $tools = $server->getRegistry()->getTools(); + expect($tools)->toHaveCount(1); + + $toolName = array_keys($tools)[0]; + expect($toolName)->toStartWith('closure_tool_'); + + $tool = $server->getRegistry()->getTool($toolName); + expect($tool->handler)->toBe($closure); +}); + +it('generates unique names for multiple closures', function () { + $closure1 = function (string $a): string { + return $a; + }; + $closure2 = function (int $b): int { + return $b; + }; + + $server = $this->builder + ->withServerInfo('MultiClosureTest', '1.0') + ->withTool($closure1) + ->withTool($closure2) + ->build(); + + $tools = $server->getRegistry()->getTools(); + expect($tools)->toHaveCount(2); + + $toolNames = array_keys($tools); + expect($toolNames[0])->toStartWith('closure_tool_'); + expect($toolNames[1])->toStartWith('closure_tool_'); + expect($toolNames[0])->not->toBe($toolNames[1]); +}); + it('infers prompt arguments and completion providers for manual prompt', function () { $handler = [SB_DummyHandlerClass::class, 'handlerWithCompletion']; diff --git a/tests/Unit/Utils/HandlerResolverTest.php b/tests/Unit/Utils/HandlerResolverTest.php index c0b574c..dd7adaa 100644 --- a/tests/Unit/Utils/HandlerResolverTest.php +++ b/tests/Unit/Utils/HandlerResolverTest.php @@ -4,46 +4,44 @@ use PhpMcp\Server\Utils\HandlerResolver; use ReflectionMethod; +use ReflectionFunction; use InvalidArgumentException; class ValidHandlerClass { - public function publicMethod() - { - } - protected function protectedMethod() - { - } - private function privateMethod() - { - } - public static function staticMethod() - { - } - public function __construct() - { - } - public function __destruct() - { - } + public function publicMethod() {} + protected function protectedMethod() {} + private function privateMethod() {} + public static function staticMethod() {} + public function __construct() {} + public function __destruct() {} } class ValidInvokableClass { - public function __invoke() - { - } + public function __invoke() {} } -class NonInvokableClass -{ -} +class NonInvokableClass {} abstract class AbstractHandlerClass { abstract public function abstractMethod(); } +// Test closure support +it('resolves closures to ReflectionFunction', function () { + $closure = function (string $input): string { + return "processed: $input"; + }; + + $resolved = HandlerResolver::resolve($closure); + + expect($resolved)->toBeInstanceOf(ReflectionFunction::class); + expect($resolved->getNumberOfParameters())->toBe(1); + expect($resolved->getReturnType()->getName())->toBe('string'); +}); + it('resolves valid array handler', function () { $handler = [ValidHandlerClass::class, 'publicMethod']; $resolved = HandlerResolver::resolve($handler); @@ -62,6 +60,15 @@ abstract public function abstractMethod(); expect($resolved->getDeclaringClass()->getName())->toBe(ValidInvokableClass::class); }); +it('resolves static methods for manual registration', function () { + $handler = [ValidHandlerClass::class, 'staticMethod']; + $resolved = HandlerResolver::resolve($handler); + + expect($resolved)->toBeInstanceOf(ReflectionMethod::class); + expect($resolved->getName())->toBe('staticMethod'); + expect($resolved->isStatic())->toBeTrue(); +}); + it('throws for invalid array handler format (count)', function () { HandlerResolver::resolve([ValidHandlerClass::class]); })->throws(InvalidArgumentException::class, 'Invalid array handler format. Expected [ClassName::class, \'methodName\'].'); @@ -70,7 +77,6 @@ abstract public function abstractMethod(); HandlerResolver::resolve([ValidHandlerClass::class, 123]); })->throws(InvalidArgumentException::class, 'Invalid array handler format. Expected [ClassName::class, \'methodName\'].'); - it('throws for non-existent class in array handler', function () { HandlerResolver::resolve(['NonExistentClass', 'method']); })->throws(InvalidArgumentException::class, "Handler class 'NonExistentClass' not found"); @@ -81,17 +87,12 @@ abstract public function abstractMethod(); it('throws for non-existent class in string handler', function () { HandlerResolver::resolve('NonExistentInvokableClass'); -})->throws(InvalidArgumentException::class, 'Invalid handler format. Expected [ClassName::class, \'methodName\'] or InvokableClassName::class string.'); - +})->throws(InvalidArgumentException::class, 'Invalid handler format. Expected Closure, [ClassName::class, \'methodName\'] or InvokableClassName::class string.'); it('throws for non-invokable class string handler', function () { HandlerResolver::resolve(NonInvokableClass::class); })->throws(InvalidArgumentException::class, "Invokable handler class '" . NonInvokableClass::class . "' must have a public '__invoke' method."); -it('throws for static method handler', function () { - HandlerResolver::resolve([ValidHandlerClass::class, 'staticMethod']); -})->throws(InvalidArgumentException::class, 'cannot be static'); - it('throws for protected method handler', function () { HandlerResolver::resolve([ValidHandlerClass::class, 'protectedMethod']); })->throws(InvalidArgumentException::class, 'must be public'); @@ -107,3 +108,41 @@ abstract public function abstractMethod(); it('throws for destructor as handler', function () { HandlerResolver::resolve([ValidHandlerClass::class, '__destruct']); })->throws(InvalidArgumentException::class, 'cannot be a constructor or destructor'); + +it('throws for abstract method handler', function () { + HandlerResolver::resolve([AbstractHandlerClass::class, 'abstractMethod']); +})->throws(InvalidArgumentException::class, 'cannot be abstract'); + +// Test different closure types +it('resolves closures with different signatures', function () { + $noParams = function () { + return 'test'; + }; + $withParams = function (int $a, string $b = 'default') { + return $a . $b; + }; + $variadic = function (...$args) { + return $args; + }; + + expect(HandlerResolver::resolve($noParams))->toBeInstanceOf(ReflectionFunction::class); + expect(HandlerResolver::resolve($withParams))->toBeInstanceOf(ReflectionFunction::class); + expect(HandlerResolver::resolve($variadic))->toBeInstanceOf(ReflectionFunction::class); + + expect(HandlerResolver::resolve($noParams)->getNumberOfParameters())->toBe(0); + expect(HandlerResolver::resolve($withParams)->getNumberOfParameters())->toBe(2); + expect(HandlerResolver::resolve($variadic)->isVariadic())->toBeTrue(); +}); + +// Test that we can distinguish between closures and callable arrays +it('distinguishes between closures and callable arrays', function () { + $closure = function () { + return 'closure'; + }; + $array = [ValidHandlerClass::class, 'publicMethod']; + $string = ValidInvokableClass::class; + + expect(HandlerResolver::resolve($closure))->toBeInstanceOf(ReflectionFunction::class); + expect(HandlerResolver::resolve($array))->toBeInstanceOf(ReflectionMethod::class); + expect(HandlerResolver::resolve($string))->toBeInstanceOf(ReflectionMethod::class); +});