diff --git a/CHANGELOG.md b/CHANGELOG.md index 8db034bd..32dee04c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to `mcp/sdk` will be documented in this file. +0.6.1 +----- + +* Allow registering an element handler as a pre-built object instance (`[$instance, 'methodName']`) via `Builder::addTool()`, `addResource()`, `addResourceTemplate()`, and `addPrompt()`. `HandlerResolver` previously rejected instances with "Invalid array handler format" even though the `Handler` type already permitted them — this unblocks handler objects with constructor dependencies that the container-less fallback cannot build. + 0.6.0 ----- diff --git a/src/Capability/Discovery/HandlerResolver.php b/src/Capability/Discovery/HandlerResolver.php index cd78aaf4..9f5d2786 100644 --- a/src/Capability/Discovery/HandlerResolver.php +++ b/src/Capability/Discovery/HandlerResolver.php @@ -11,11 +11,14 @@ namespace Mcp\Capability\Discovery; +use Mcp\Capability\Registry\ElementReference; use Mcp\Exception\InvalidArgumentException; /** * Utility class to validate and resolve MCP element handlers. * + * @phpstan-import-type Handler from ElementReference + * * @author Kyrian Obikwelu */ class HandlerResolver @@ -25,11 +28,12 @@ class HandlerResolver * * A handler can be: * - A Closure: function() { ... } - * - An array: [ClassName::class, 'methodName'] (instance method) + * - An array: [ClassName::class, 'methodName'] (resolved on a new or container-provided instance) + * - An array: [$instance, 'methodName'] (method on a pre-built object instance) * - An array: [ClassName::class, 'staticMethod'] (static method, if callable) * - A string: InvokableClassName::class (which will resolve to its '__invoke' method) * - * @param \Closure|array{0: string, 1: string}|string $handler the handler to resolve + * @param Handler $handler the handler to resolve * * @throws InvalidArgumentException If the handler format is invalid, the class/method doesn't exist, * or the method is unsuitable (e.g., private, abstract). @@ -41,10 +45,11 @@ public static function resolve(\Closure|array|string $handler): \ReflectionMetho } if (\is_array($handler)) { - if (2 !== \count($handler) || !isset($handler[0]) || !isset($handler[1]) || !\is_string($handler[0]) || !\is_string($handler[1])) { - throw new InvalidArgumentException('Invalid array handler format. Expected [ClassName::class, \'methodName\'].'); + if (2 !== \count($handler) || !isset($handler[0]) || !isset($handler[1]) || !(\is_string($handler[0]) || \is_object($handler[0])) || !\is_string($handler[1])) { + throw new InvalidArgumentException('Invalid array handler format. Expected [ClassName::class, \'methodName\'] or [$instance, \'methodName\'].'); } - [$className, $methodName] = $handler; + [$classOrObject, $methodName] = $handler; + $className = \is_object($classOrObject) ? $classOrObject::class : $classOrObject; if (!class_exists($className)) { throw new InvalidArgumentException(\sprintf('Handler class "%s" not found for array handler.', $className)); } diff --git a/tests/Unit/Capability/Discovery/HandlerResolverTest.php b/tests/Unit/Capability/Discovery/HandlerResolverTest.php index 6a87a0b3..24e91d44 100644 --- a/tests/Unit/Capability/Discovery/HandlerResolverTest.php +++ b/tests/Unit/Capability/Discovery/HandlerResolverTest.php @@ -38,6 +38,15 @@ public function testResolvesValidArrayHandler(): void $this->assertEquals(ValidHandlerClass::class, $resolved->getDeclaringClass()->getName()); } + public function testResolvesValidInstanceArrayHandler(): void + { + $handler = [new ValidHandlerClass(), 'publicMethod']; + $resolved = HandlerResolver::resolve($handler); + $this->assertInstanceOf(\ReflectionMethod::class, $resolved); + $this->assertEquals('publicMethod', $resolved->getName()); + $this->assertEquals(ValidHandlerClass::class, $resolved->getDeclaringClass()->getName()); + } + public function testResolvesValidInvokableClassStringHandler(): void { $handler = ValidInvokableClass::class; @@ -59,14 +68,14 @@ public function testResolvesStaticMethodsForManualRegistration(): void public function testThrowsForInvalidArrayHandlerFormatCount(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage("Invalid array handler format. Expected [ClassName::class, 'methodName']."); + $this->expectExceptionMessage('Invalid array handler format. Expected [ClassName::class, \'methodName\'] or [$instance, \'methodName\'].'); HandlerResolver::resolve([ValidHandlerClass::class]); /* @phpstan-ignore argument.type */ } public function testThrowsForInvalidArrayHandlerFormatTypes(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage("Invalid array handler format. Expected [ClassName::class, 'methodName']."); + $this->expectExceptionMessage('Invalid array handler format. Expected [ClassName::class, \'methodName\'] or [$instance, \'methodName\'].'); HandlerResolver::resolve([ValidHandlerClass::class, 123]); /* @phpstan-ignore argument.type */ } diff --git a/tests/Unit/Server/BuilderTest.php b/tests/Unit/Server/BuilderTest.php index 4435639c..19308793 100644 --- a/tests/Unit/Server/BuilderTest.php +++ b/tests/Unit/Server/BuilderTest.php @@ -83,6 +83,25 @@ public function testCustomReferenceHandlerIsUsedForToolCalls(): void $this->assertSame('intercepted', $result); } + #[TestDox('A pre-built instance handler with constructor dependencies is registered and invoked on that instance')] + public function testPreBuiltInstanceHandlerIsInvokedOnTheGivenInstance(): void + { + // The handler's constructor requires an argument the container-less + // `new $className()` fallback can never satisfy. If the tool call + // succeeds, it can only be because the pre-built instance — carrying + // its injected dependency — was the one invoked. + $handler = new GreetingService('World'); + + $server = Server::builder() + ->setServerInfo('test', '1.0.0') + ->addTool(handler: [$handler, 'greet'], name: 'greet', description: 'Greets using the injected name') + ->build(); + + $result = $this->callTool($server, 'greet'); + + $this->assertSame('Hello, World', $result); + } + #[TestDox('enableExtension() registers an extension and announces its capability payload')] public function testEnableExtensionRegistersExtension(): void { @@ -166,3 +185,20 @@ private function callTool(Server $server, string $toolName): mixed $this->fail('CallToolHandler not found in request handlers'); } } + +/** + * A handler whose constructor dependency cannot be satisfied by the + * container-less `new $className()` fallback, so it must be registered as a + * pre-built instance. + */ +final class GreetingService +{ + public function __construct(private string $name) + { + } + + public function greet(): string + { + return 'Hello, '.$this->name; + } +}