diff --git a/docs/mcp-elements.md b/docs/mcp-elements.md index b1a045f5..c19036bc 100644 --- a/docs/mcp-elements.md +++ b/docs/mcp-elements.md @@ -42,6 +42,11 @@ Each capability can be registered using two methods: For manual registration details, see [Server Builder Manual Registration](server-builder.md#manual-capability-registration). +For runtime, config-driven elements whose shape is not known at compile time +(e.g. bridging configuration entities into MCP elements), see [Explicit +element registration](server-builder.md#explicit-element-registration) in the +Server Builder docs. + ## Tools Tools are callable functions that perform actions and return results. diff --git a/docs/server-builder.md b/docs/server-builder.md index 1a8e4663..485cb13d 100644 --- a/docs/server-builder.md +++ b/docs/server-builder.md @@ -362,6 +362,67 @@ the handler's method name and docblock. For more details on MCP elements, handlers, and attribute-based discovery, see [MCP Elements](mcp-elements.md). +### Explicit element registration + +When an element's name, schema, or description is only known at runtime +(for example, a Drupal module bridging configuration entities into MCP +tools), pair an `Mcp\Schema\*` value object with one of the four handler +interfaces below and register it through `Builder::add()`. + +| Element kind | Handler interface | +|-------------------|-------------------------------------------------------| +| Tool | `Mcp\Server\Handler\ToolHandlerInterface` | +| Resource | `Mcp\Server\Handler\ResourceHandlerInterface` | +| Resource template | `Mcp\Server\Handler\ResourceTemplateHandlerInterface` | +| Prompt | `Mcp\Server\Handler\PromptHandlerInterface` | + +Each handler interface declares a single execution method. Tool and +prompt handlers receive an arguments map and a `ClientGateway`. Resource +handlers receive the requested URI; resource template handlers +additionally receive the parsed template variables. + +```php +use Mcp\Schema\Tool; +use Mcp\Server; +use Mcp\Server\ClientGateway; +use Mcp\Server\Handler\ToolHandlerInterface; + +final class WeatherHandler implements ToolHandlerInterface +{ + public function execute(array $arguments, ClientGateway $gateway): mixed + { + return ['temperature' => 21, 'unit' => 'C']; + } +} + +$tool = new Tool( + name: 'get_weather', + title: null, + inputSchema: [ + 'type' => 'object', + 'properties' => ['city' => ['type' => 'string']], + 'required' => ['city'], + ], + description: 'Returns the current weather for a city.', + annotations: null, +); + +$server = Server::builder() + ->add($tool, new WeatherHandler()) + ->build(); +``` + +`Builder::add()` validates the pairing at registration time. Pairing a +`Tool` definition with, for example, a `PromptHandlerInterface` raises +`Mcp\Exception\ConfigurationException`. The schema value object validates +its own inputs (name pattern, schema shape, etc.), so passing an +incomplete definition fails before `add()` returns. + +Use `add()` when the metadata cannot be inferred from a handler class via +reflection. For statically-known elements, prefer +`addTool/addResource/addResourceTemplate/addPrompt`, which can derive +metadata from the handler's signature and docblock. + ## Service Dependencies ### Container @@ -583,4 +644,5 @@ $server = Server::builder() | `addResource()` | handler, uri, name?, description?, mimeType?, size?, annotations? | Register resource | | `addResourceTemplate()` | handler, uriTemplate, name?, description?, mimeType?, annotations? | Register resource template | | `addPrompt()` | handler, name?, description? | Register prompt | +| `add()` | definition, handler | Register an element from a schema VO + handler pair | | `build()` | - | Create the server instance | diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index 2a327ae4..822d43e1 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -30,6 +30,7 @@ use Mcp\Schema\Resource; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\Tool; +use Mcp\Server\Handler\ElementHandlerInterface; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -68,7 +69,7 @@ public function __construct( ) { } - public function registerTool(Tool $tool, callable|array|string $handler, bool $isManual = false): void + public function registerTool(Tool $tool, callable|array|string|ElementHandlerInterface $handler, bool $isManual = false): void { $toolName = $tool->name; $existing = $this->tools[$toolName] ?? null; @@ -92,7 +93,7 @@ public function registerTool(Tool $tool, callable|array|string $handler, bool $i $this->eventDispatcher?->dispatch(new ToolListChangedEvent()); } - public function registerResource(Resource $resource, callable|array|string $handler, bool $isManual = false): void + public function registerResource(Resource $resource, callable|array|string|ElementHandlerInterface $handler, bool $isManual = false): void { $uri = $resource->uri; $existing = $this->resources[$uri] ?? null; @@ -112,7 +113,7 @@ public function registerResource(Resource $resource, callable|array|string $hand public function registerResourceTemplate( ResourceTemplate $template, - callable|array|string $handler, + callable|array|string|ElementHandlerInterface $handler, array $completionProviders = [], bool $isManual = false, ): void { @@ -139,7 +140,7 @@ public function registerResourceTemplate( public function registerPrompt( Prompt $prompt, - callable|array|string $handler, + callable|array|string|ElementHandlerInterface $handler, array $completionProviders = [], bool $isManual = false, ): void { diff --git a/src/Capability/Registry/ElementReference.php b/src/Capability/Registry/ElementReference.php index 6425ba13..803ec765 100644 --- a/src/Capability/Registry/ElementReference.php +++ b/src/Capability/Registry/ElementReference.php @@ -11,8 +11,11 @@ namespace Mcp\Capability\Registry; +use Mcp\Server\Handler\ElementHandlerInterface; + /** - * @phpstan-type Handler \Closure|array{0: object|string, 1: string}|string + * @phpstan-type CallableHandler \Closure|array{0: object|string, 1: string}|string + * @phpstan-type Handler CallableHandler|ElementHandlerInterface * * @author Kyrian Obikwelu */ @@ -22,7 +25,7 @@ class ElementReference * @param Handler $handler */ public function __construct( - public readonly \Closure|array|string $handler, + public readonly \Closure|array|string|ElementHandlerInterface $handler, public readonly bool $isManual = false, ) { } diff --git a/src/Capability/Registry/Loader/ArrayLoader.php b/src/Capability/Registry/Loader/ArrayLoader.php index 34055a2d..e9f578f1 100644 --- a/src/Capability/Registry/Loader/ArrayLoader.php +++ b/src/Capability/Registry/Loader/ArrayLoader.php @@ -37,13 +37,13 @@ /** * @author Antoine Bluchet * - * @phpstan-import-type Handler from ElementReference + * @phpstan-import-type CallableHandler from ElementReference */ final class ArrayLoader implements LoaderInterface { /** * @param array{ - * handler: Handler, + * handler: CallableHandler, * name: ?string, * title: ?string, * description: ?string, @@ -53,7 +53,7 @@ final class ArrayLoader implements LoaderInterface * outputSchema: ?array * }[] $tools * @param array{ - * handler: Handler, + * handler: CallableHandler, * uri: string, * name: ?string, * description: ?string, @@ -64,7 +64,7 @@ final class ArrayLoader implements LoaderInterface * meta: ?array * }[] $resources * @param array{ - * handler: Handler, + * handler: CallableHandler, * uriTemplate: string, * name: ?string, * description: ?string, @@ -73,7 +73,7 @@ final class ArrayLoader implements LoaderInterface * meta: ?array * }[] $resourceTemplates * @param array{ - * handler: Handler, + * handler: CallableHandler, * name: ?string, * description: ?string, * icons: ?Icon[], @@ -278,7 +278,7 @@ public function load(RegistryInterface $registry): void } /** - * @param Handler $handler + * @param CallableHandler $handler */ private function getHandlerDescription(\Closure|array|string $handler): string { diff --git a/src/Capability/Registry/Loader/ExplicitElementLoader.php b/src/Capability/Registry/Loader/ExplicitElementLoader.php new file mode 100644 index 00000000..447e823d --- /dev/null +++ b/src/Capability/Registry/Loader/ExplicitElementLoader.php @@ -0,0 +1,65 @@ + + */ +final class ExplicitElementLoader implements LoaderInterface +{ + /** + * @param list $tools + * @param list $resources + * @param list $resourceTemplates + * @param list $prompts + */ + public function __construct( + private readonly array $tools = [], + private readonly array $resources = [], + private readonly array $resourceTemplates = [], + private readonly array $prompts = [], + ) { + } + + public function load(RegistryInterface $registry): void + { + foreach ($this->tools as $entry) { + $registry->registerTool($entry['definition'], $entry['handler'], true); + } + + foreach ($this->resources as $entry) { + $registry->registerResource($entry['definition'], $entry['handler'], true); + } + + foreach ($this->resourceTemplates as $entry) { + $registry->registerResourceTemplate($entry['definition'], $entry['handler'], [], true); + } + + foreach ($this->prompts as $entry) { + $registry->registerPrompt($entry['definition'], $entry['handler'], [], true); + } + } +} diff --git a/src/Capability/Registry/PromptReference.php b/src/Capability/Registry/PromptReference.php index 5de3a195..955df1bd 100644 --- a/src/Capability/Registry/PromptReference.php +++ b/src/Capability/Registry/PromptReference.php @@ -14,6 +14,7 @@ use Mcp\Capability\Formatter\PromptResultFormatter; use Mcp\Schema\Content\PromptMessage; use Mcp\Schema\Prompt; +use Mcp\Server\Handler\ElementHandlerInterface; /** * @phpstan-import-type Handler from ElementReference @@ -28,7 +29,7 @@ class PromptReference extends ElementReference */ public function __construct( public readonly Prompt $prompt, - \Closure|array|string $handler, + \Closure|array|string|ElementHandlerInterface $handler, bool $isManual = false, public readonly array $completionProviders = [], ) { diff --git a/src/Capability/Registry/ReferenceHandler.php b/src/Capability/Registry/ReferenceHandler.php index 7b4a0cdc..7032edf3 100644 --- a/src/Capability/Registry/ReferenceHandler.php +++ b/src/Capability/Registry/ReferenceHandler.php @@ -13,6 +13,12 @@ use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\RegistryException; +use Mcp\Server\ClientGateway; +use Mcp\Server\Handler\ElementHandlerInterface; +use Mcp\Server\Handler\PromptHandlerInterface; +use Mcp\Server\Handler\ResourceHandlerInterface; +use Mcp\Server\Handler\ResourceTemplateHandlerInterface; +use Mcp\Server\Handler\ToolHandlerInterface; use Mcp\Server\RequestContext; use Mcp\Server\Session\SessionInterface; use Psr\Container\ContainerInterface; @@ -33,33 +39,54 @@ public function __construct( public function handle(ElementReference $reference, array $arguments): mixed { $session = $arguments['_session']; + $handler = $reference->handler; + + if ($handler instanceof ElementHandlerInterface) { + $client = new ClientGateway($session); + $callArgs = array_diff_key($arguments, array_flip(['_session', '_request'])); + + return match (true) { + $handler instanceof ToolHandlerInterface => $handler->execute($callArgs, $client), + $handler instanceof PromptHandlerInterface => $handler->get($callArgs, $client), + $handler instanceof ResourceHandlerInterface => $handler->read( + $arguments['uri'] ?? throw new InvalidArgumentException('Resource dispatch requires a "uri" argument.'), + $client, + ), + $handler instanceof ResourceTemplateHandlerInterface => $handler->read( + $arguments['uri'] ?? throw new InvalidArgumentException('Resource template dispatch requires a "uri" argument.'), + array_diff_key($callArgs, ['uri' => null]), + $client, + ), + default => throw new InvalidArgumentException(\sprintf('Unsupported %s implementation: %s.', ElementHandlerInterface::class, $handler::class)), + }; + } - if (\is_string($reference->handler)) { - if (class_exists($reference->handler) && method_exists($reference->handler, '__invoke')) { - $reflection = new \ReflectionMethod($reference->handler, '__invoke'); - $instance = $this->getClassInstance($reference->handler); + if (\is_string($handler)) { + if (class_exists($handler) && method_exists($handler, '__invoke')) { + $reflection = new \ReflectionMethod($handler, '__invoke'); + $instance = $this->getClassInstance($handler); $arguments = $this->prepareArguments($reflection, $arguments); return \call_user_func($instance, ...$arguments); } - if (\function_exists($reference->handler)) { - $reflection = new \ReflectionFunction($reference->handler); + if (\function_exists($handler)) { + $reflection = new \ReflectionFunction($handler); $arguments = $this->prepareArguments($reflection, $arguments); - return \call_user_func($reference->handler, ...$arguments); + return \call_user_func($handler, ...$arguments); } } - if (\is_callable($reference->handler)) { - $reflection = $this->getReflectionForCallable($reference->handler, $session); + if (\is_callable($handler)) { + $reflection = $this->getReflectionForCallable($handler, $session); $arguments = $this->prepareArguments($reflection, $arguments); - return \call_user_func($reference->handler, ...$arguments); + return \call_user_func($handler, ...$arguments); } - if (\is_array($reference->handler)) { - [$className, $methodName] = $reference->handler; + if (\is_array($handler)) { + [$className, $methodName] = $handler; $reflection = new \ReflectionMethod($className, $methodName); $instance = $this->getClassInstance($className); $arguments = $this->prepareArguments($reflection, $arguments); diff --git a/src/Capability/Registry/ResourceReference.php b/src/Capability/Registry/ResourceReference.php index d65f461e..0bfbb6f3 100644 --- a/src/Capability/Registry/ResourceReference.php +++ b/src/Capability/Registry/ResourceReference.php @@ -14,6 +14,7 @@ use Mcp\Capability\Formatter\ResourceResultFormatter; use Mcp\Schema\Content\ResourceContents; use Mcp\Schema\Resource; +use Mcp\Server\Handler\ElementHandlerInterface; /** * @phpstan-import-type Handler from ElementReference @@ -27,7 +28,7 @@ class ResourceReference extends ElementReference */ public function __construct( public readonly Resource $resource, - callable|array|string $handler, + callable|array|string|ElementHandlerInterface $handler, bool $isManual = false, ) { parent::__construct($handler, $isManual); diff --git a/src/Capability/Registry/ResourceTemplateReference.php b/src/Capability/Registry/ResourceTemplateReference.php index ef2d915a..f38e7533 100644 --- a/src/Capability/Registry/ResourceTemplateReference.php +++ b/src/Capability/Registry/ResourceTemplateReference.php @@ -14,6 +14,7 @@ use Mcp\Capability\Formatter\ResourceResultFormatter; use Mcp\Schema\Content\ResourceContents; use Mcp\Schema\ResourceTemplate; +use Mcp\Server\Handler\ElementHandlerInterface; /** * @phpstan-import-type Handler from ElementReference @@ -35,7 +36,7 @@ class ResourceTemplateReference extends ElementReference */ public function __construct( public readonly ResourceTemplate $resourceTemplate, - callable|array|string $handler, + callable|array|string|ElementHandlerInterface $handler, bool $isManual = false, public readonly array $completionProviders = [], ) { diff --git a/src/Capability/Registry/ToolReference.php b/src/Capability/Registry/ToolReference.php index 9aa5a3c9..649cd18e 100644 --- a/src/Capability/Registry/ToolReference.php +++ b/src/Capability/Registry/ToolReference.php @@ -14,6 +14,7 @@ use Mcp\Capability\Formatter\ToolResultFormatter; use Mcp\Schema\Content\Content; use Mcp\Schema\Tool; +use Mcp\Server\Handler\ElementHandlerInterface; /** * @phpstan-import-type Handler from ElementReference @@ -27,7 +28,7 @@ class ToolReference extends ElementReference */ public function __construct( public readonly Tool $tool, - callable|array|string $handler, + callable|array|string|ElementHandlerInterface $handler, bool $isManual = false, ) { parent::__construct($handler, $isManual); diff --git a/src/Capability/RegistryInterface.php b/src/Capability/RegistryInterface.php index 67295681..783c1429 100644 --- a/src/Capability/RegistryInterface.php +++ b/src/Capability/RegistryInterface.php @@ -25,6 +25,7 @@ use Mcp\Schema\Resource; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\Tool; +use Mcp\Server\Handler\ElementHandlerInterface; /** * @phpstan-import-type Handler from ElementReference @@ -39,14 +40,14 @@ interface RegistryInterface * * @param Handler $handler */ - public function registerTool(Tool $tool, callable|array|string $handler, bool $isManual = false): void; + public function registerTool(Tool $tool, callable|array|string|ElementHandlerInterface $handler, bool $isManual = false): void; /** * Registers a resource with its handler. * * @param Handler $handler */ - public function registerResource(Resource $resource, callable|array|string $handler, bool $isManual = false): void; + public function registerResource(Resource $resource, callable|array|string|ElementHandlerInterface $handler, bool $isManual = false): void; /** * Registers a resource template with its handler and completion providers. @@ -56,7 +57,7 @@ public function registerResource(Resource $resource, callable|array|string $hand */ public function registerResourceTemplate( ResourceTemplate $template, - callable|array|string $handler, + callable|array|string|ElementHandlerInterface $handler, array $completionProviders = [], bool $isManual = false, ): void; @@ -69,7 +70,7 @@ public function registerResourceTemplate( */ public function registerPrompt( Prompt $prompt, - callable|array|string $handler, + callable|array|string|ElementHandlerInterface $handler, array $completionProviders = [], bool $isManual = false, ): void; diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 953c4c2c..88f323a2 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -20,6 +20,7 @@ use Mcp\Capability\Registry\ElementReference; use Mcp\Capability\Registry\Loader\ArrayLoader; use Mcp\Capability\Registry\Loader\DiscoveryLoader; +use Mcp\Capability\Registry\Loader\ExplicitElementLoader; use Mcp\Capability\Registry\Loader\LoaderInterface; use Mcp\Capability\Registry\ReferenceHandler; use Mcp\Capability\Registry\ReferenceHandlerInterface; @@ -30,11 +31,20 @@ use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Icon; use Mcp\Schema\Implementation; +use Mcp\Schema\Prompt; +use Mcp\Schema\Resource; +use Mcp\Schema\ResourceTemplate; use Mcp\Schema\ServerCapabilities; +use Mcp\Schema\Tool; use Mcp\Schema\ToolAnnotations; use Mcp\Server; +use Mcp\Server\Handler\ElementHandlerInterface; use Mcp\Server\Handler\Notification\NotificationHandlerInterface; +use Mcp\Server\Handler\PromptHandlerInterface; use Mcp\Server\Handler\Request\RequestHandlerInterface; +use Mcp\Server\Handler\ResourceHandlerInterface; +use Mcp\Server\Handler\ResourceTemplateHandlerInterface; +use Mcp\Server\Handler\ToolHandlerInterface; use Mcp\Server\Resource\SessionSubscriptionManager; use Mcp\Server\Resource\SubscriptionManagerInterface; use Mcp\Server\Session\InMemorySessionStore; @@ -49,6 +59,7 @@ use Symfony\Component\Finder\Finder; /** + * @phpstan-import-type CallableHandler from ElementReference * @phpstan-import-type Handler from ElementReference * * @author Kyrian Obikwelu @@ -97,11 +108,12 @@ final class Builder /** * @var array{ - * handler: Handler, + * handler: CallableHandler, * name: ?string, * title: ?string, * description: ?string, * annotations: ?ToolAnnotations, + * inputSchema: ?array, * icons: ?Icon[], * meta: ?array, * outputSchema: ?array, @@ -111,7 +123,7 @@ final class Builder /** * @var array{ - * handler: Handler, + * handler: CallableHandler, * uri: string, * name: ?string, * description: ?string, @@ -126,7 +138,7 @@ final class Builder /** * @var array{ - * handler: Handler, + * handler: CallableHandler, * uriTemplate: string, * name: ?string, * description: ?string, @@ -139,8 +151,9 @@ final class Builder /** * @var array{ - * handler: Handler, + * handler: CallableHandler, * name: ?string, + * title: ?string, * description: ?string, * icons: ?Icon[], * meta: ?array @@ -148,6 +161,26 @@ final class Builder */ private array $prompts = []; + /** + * @var list + */ + private array $explicitTools = []; + + /** + * @var list + */ + private array $explicitResources = []; + + /** + * @var list + */ + private array $explicitResourceTemplates = []; + + /** + * @var list + */ + private array $explicitPrompts = []; + private ?string $discoveryBasePath = null; /** @@ -373,7 +406,7 @@ public function setProtocolVersion(ProtocolVersion $protocolVersion): self /** * Manually registers a tool handler. * - * @param Handler $handler + * @param CallableHandler $handler * @param ?string $title Optional human-readable title for display in UI * @param array|null $inputSchema * @param ?Icon[] $icons @@ -409,7 +442,7 @@ public function addTool( /** * Manually registers a resource handler. * - * @param Handler $handler + * @param CallableHandler $handler * @param ?Icon[] $icons * @param array|null $meta */ @@ -442,7 +475,7 @@ public function addResource( /** * Manually registers a resource template handler. * - * @param Handler $handler + * @param CallableHandler $handler * @param array|null $meta */ public function addResourceTemplate( @@ -470,7 +503,7 @@ public function addResourceTemplate( /** * Manually registers a prompt handler. * - * @param Handler $handler + * @param CallableHandler $handler * @param ?Icon[] $icons * @param array|null $meta */ @@ -487,6 +520,31 @@ public function addPrompt( return $this; } + /** + * Registers an element using an explicit schema value object paired with a handler interface. + * + * Use this entry point when an element's name, schema, or description is only known at + * runtime (e.g. config-driven integrations). For statically-known elements, prefer + * `addTool/addResource/addResourceTemplate/addPrompt`, which can derive metadata from + * reflection of the handler. + * + * Mismatched pairings (e.g. a `Tool` with a `PromptHandlerInterface`) raise `\TypeError`. + */ + public function add( + Tool|Resource|ResourceTemplate|Prompt $definition, + ElementHandlerInterface $handler, + ): self { + match (true) { + $definition instanceof Tool && $handler instanceof ToolHandlerInterface => $this->explicitTools[] = ['definition' => $definition, 'handler' => $handler], + $definition instanceof Resource && $handler instanceof ResourceHandlerInterface => $this->explicitResources[] = ['definition' => $definition, 'handler' => $handler], + $definition instanceof ResourceTemplate && $handler instanceof ResourceTemplateHandlerInterface => $this->explicitResourceTemplates[] = ['definition' => $definition, 'handler' => $handler], + $definition instanceof Prompt && $handler instanceof PromptHandlerInterface => $this->explicitPrompts[] = ['definition' => $definition, 'handler' => $handler], + default => throw new \TypeError(\sprintf('%s definition cannot be paired with %s; expected the matching handler interface.', $definition::class, $handler::class)), + }; + + return $this; + } + /** * Register a single custom loader. */ @@ -520,6 +578,12 @@ public function build(): Server $subscriptionManager = $this->subscriptionManager ?? new SessionSubscriptionManager($logger); $loaders = [ ...$this->loaders, + new ExplicitElementLoader( + $this->explicitTools, + $this->explicitResources, + $this->explicitResourceTemplates, + $this->explicitPrompts, + ), new ArrayLoader($this->tools, $this->resources, $this->resourceTemplates, $this->prompts, $logger, $this->schemaGenerator), ]; diff --git a/src/Server/Handler/ElementHandlerInterface.php b/src/Server/Handler/ElementHandlerInterface.php new file mode 100644 index 00000000..6b08955d --- /dev/null +++ b/src/Server/Handler/ElementHandlerInterface.php @@ -0,0 +1,23 @@ + + */ +interface ElementHandlerInterface +{ +} diff --git a/src/Server/Handler/PromptHandlerInterface.php b/src/Server/Handler/PromptHandlerInterface.php new file mode 100644 index 00000000..4143c976 --- /dev/null +++ b/src/Server/Handler/PromptHandlerInterface.php @@ -0,0 +1,28 @@ + + */ +interface PromptHandlerInterface extends ElementHandlerInterface +{ + /** + * @param array $arguments + */ + public function get(array $arguments, ClientGateway $gateway): mixed; +} diff --git a/src/Server/Handler/ResourceHandlerInterface.php b/src/Server/Handler/ResourceHandlerInterface.php new file mode 100644 index 00000000..8914ebd4 --- /dev/null +++ b/src/Server/Handler/ResourceHandlerInterface.php @@ -0,0 +1,25 @@ + + */ +interface ResourceHandlerInterface extends ElementHandlerInterface +{ + public function read(string $uri, ClientGateway $gateway): mixed; +} diff --git a/src/Server/Handler/ResourceTemplateHandlerInterface.php b/src/Server/Handler/ResourceTemplateHandlerInterface.php new file mode 100644 index 00000000..892e4393 --- /dev/null +++ b/src/Server/Handler/ResourceTemplateHandlerInterface.php @@ -0,0 +1,28 @@ + + */ +interface ResourceTemplateHandlerInterface extends ElementHandlerInterface +{ + /** + * @param array $variables + */ + public function read(string $uri, array $variables, ClientGateway $gateway): mixed; +} diff --git a/src/Server/Handler/ToolHandlerInterface.php b/src/Server/Handler/ToolHandlerInterface.php new file mode 100644 index 00000000..4f2ee3e9 --- /dev/null +++ b/src/Server/Handler/ToolHandlerInterface.php @@ -0,0 +1,28 @@ + + */ +interface ToolHandlerInterface extends ElementHandlerInterface +{ + /** + * @param array $arguments + */ + public function execute(array $arguments, ClientGateway $gateway): mixed; +} diff --git a/tests/Unit/Capability/Registry/Loader/ExplicitElementLoaderTest.php b/tests/Unit/Capability/Registry/Loader/ExplicitElementLoaderTest.php new file mode 100644 index 00000000..32fb4104 --- /dev/null +++ b/tests/Unit/Capability/Registry/Loader/ExplicitElementLoaderTest.php @@ -0,0 +1,222 @@ + 'object', 'properties' => ['foo' => ['type' => 'string']], 'required' => []], + description: 'A demo tool', + annotations: null, + ); + $handler = new class implements ToolHandlerInterface { + /** @var array|null */ + public ?array $receivedArguments = null; + public ?ClientGateway $receivedGateway = null; + + public function execute(array $arguments, ClientGateway $gateway): mixed + { + $this->receivedArguments = $arguments; + $this->receivedGateway = $gateway; + + return 'tool-ok'; + } + }; + + $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->add($tool, $handler)); + + $reference = $registry->getTool('demo'); + $this->assertSame('demo', $reference->tool->name); + $this->assertSame('A demo tool', $reference->tool->description); + $this->assertSame(['type' => 'object', 'properties' => ['foo' => ['type' => 'string']], 'required' => []], $reference->tool->inputSchema); + $this->assertTrue($reference->isManual); + + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + + $result = (new ReferenceHandler())->handle($reference, [ + '_session' => $session, + '_request' => new \stdClass(), + 'foo' => 'bar', + ]); + + $this->assertSame('tool-ok', $result); + $this->assertSame(['foo' => 'bar'], $handler->receivedArguments); + $this->assertInstanceOf(ClientGateway::class, $handler->receivedGateway); + } + + public function testAddResourceRegistersDefinitionAndDispatchesToHandler(): void + { + $resource = new Resource( + uri: 'config://demo', + name: 'demo', + description: 'A demo resource', + mimeType: 'text/plain', + ); + $handler = new class implements ResourceHandlerInterface { + public ?string $receivedUri = null; + public ?ClientGateway $receivedGateway = null; + + public function read(string $uri, ClientGateway $gateway): mixed + { + $this->receivedUri = $uri; + $this->receivedGateway = $gateway; + + return ['contents' => 'resource-ok']; + } + }; + + $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->add($resource, $handler)); + + $reference = $registry->getResource('config://demo', false); + $this->assertSame('config://demo', $reference->resource->uri); + $this->assertSame('demo', $reference->resource->name); + $this->assertSame('text/plain', $reference->resource->mimeType); + $this->assertTrue($reference->isManual); + + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + + $result = (new ReferenceHandler())->handle($reference, [ + '_session' => $session, + '_request' => new \stdClass(), + 'uri' => 'config://demo', + ]); + + $this->assertSame(['contents' => 'resource-ok'], $result); + $this->assertSame('config://demo', $handler->receivedUri); + $this->assertInstanceOf(ClientGateway::class, $handler->receivedGateway); + } + + public function testAddResourceTemplateRegistersDefinitionAndDispatchesToHandler(): void + { + $template = new ResourceTemplate( + uriTemplate: 'config://{key}', + name: 'config_template', + description: 'A demo template', + ); + $handler = new class implements ResourceTemplateHandlerInterface { + public ?string $receivedUri = null; + /** @var array|null */ + public ?array $receivedVariables = null; + public ?ClientGateway $receivedGateway = null; + + public function read(string $uri, array $variables, ClientGateway $gateway): mixed + { + $this->receivedUri = $uri; + $this->receivedVariables = $variables; + $this->receivedGateway = $gateway; + + return ['contents' => 'template-ok']; + } + }; + + $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->add($template, $handler)); + + $reference = $registry->getResourceTemplate('config://{key}'); + $this->assertSame('config://{key}', $reference->resourceTemplate->uriTemplate); + $this->assertSame('config_template', $reference->resourceTemplate->name); + $this->assertTrue($reference->isManual); + + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + + $result = (new ReferenceHandler())->handle($reference, [ + '_session' => $session, + '_request' => new \stdClass(), + 'uri' => 'config://abc', + 'key' => 'abc', + ]); + + $this->assertSame(['contents' => 'template-ok'], $result); + $this->assertSame('config://abc', $handler->receivedUri); + $this->assertSame(['key' => 'abc'], $handler->receivedVariables); + $this->assertInstanceOf(ClientGateway::class, $handler->receivedGateway); + } + + public function testAddPromptRegistersDefinitionAndDispatchesToHandler(): void + { + $prompt = new Prompt( + name: 'demo_prompt', + title: null, + description: 'A demo prompt', + ); + $handler = new class implements PromptHandlerInterface { + /** @var array|null */ + public ?array $receivedArguments = null; + public ?ClientGateway $receivedGateway = null; + + public function get(array $arguments, ClientGateway $gateway): mixed + { + $this->receivedArguments = $arguments; + $this->receivedGateway = $gateway; + + return 'prompt-ok'; + } + }; + + $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->add($prompt, $handler)); + + $reference = $registry->getPrompt('demo_prompt'); + $this->assertSame('demo_prompt', $reference->prompt->name); + $this->assertSame('A demo prompt', $reference->prompt->description); + $this->assertTrue($reference->isManual); + + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + + $result = (new ReferenceHandler())->handle($reference, [ + '_session' => $session, + '_request' => new \stdClass(), + 'topic' => 'php', + ]); + + $this->assertSame('prompt-ok', $result); + $this->assertSame(['topic' => 'php'], $handler->receivedArguments); + $this->assertInstanceOf(ClientGateway::class, $handler->receivedGateway); + } + + /** + * @param callable(Server\Builder): Server\Builder $configure + */ + private function buildAndGetRegistry(callable $configure): RegistryInterface + { + $registry = new Registry(); + $builder = Server::builder() + ->setServerInfo('test', '1.0.0') + ->setRegistry($registry); + $configure($builder)->build(); + + return $registry; + } +} diff --git a/tests/Unit/Capability/Registry/ReferenceHandlerTest.php b/tests/Unit/Capability/Registry/ReferenceHandlerTest.php new file mode 100644 index 00000000..92a2f33a --- /dev/null +++ b/tests/Unit/Capability/Registry/ReferenceHandlerTest.php @@ -0,0 +1,135 @@ +createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + + $toolHandler = new class implements ToolHandlerInterface { + /** @var array|null */ + public ?array $executedWith = null; + public ?ClientGateway $receivedGateway = null; + + public function execute(array $arguments, ClientGateway $gateway): mixed + { + $this->executedWith = $arguments; + $this->receivedGateway = $gateway; + + return 'tool-result'; + } + }; + + $reference = new ElementReference($toolHandler, true); + $referenceHandler = new ReferenceHandler(); + + $request = new \stdClass(); + $result = $referenceHandler->handle($reference, [ + '_session' => $session, + '_request' => $request, + 'kept' => 'value', + 'other' => 'value2', + ]); + + $this->assertSame('tool-result', $result); + $this->assertSame( + ['kept' => 'value', 'other' => 'value2'], + $toolHandler->executedWith, + ); + $this->assertInstanceOf(ClientGateway::class, $toolHandler->receivedGateway); + } + + public function testToolHandlerTakesPriorityOverInvokeAndCallableDetection(): void + { + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + + $toolHandler = new class implements ToolHandlerInterface { + public bool $executed = false; + + public function __invoke(): string + { + throw new \LogicException('__invoke must not be called when ToolHandlerInterface is implemented'); + } + + public function execute(array $arguments, ClientGateway $gateway): mixed + { + $this->executed = true; + + return 'priority-ok'; + } + }; + + $reference = new ElementReference($toolHandler); + $referenceHandler = new ReferenceHandler(); + + $this->assertSame('priority-ok', $referenceHandler->handle($reference, ['_session' => $session])); + $this->assertTrue($toolHandler->executed); + } + + public function testHandleDispatchesToResourceHandlerAndForwardsClientGateway(): void + { + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + + $resourceHandler = new class implements ResourceHandlerInterface { + public ?string $receivedUri = null; + public ?ClientGateway $receivedGateway = null; + + public function read(string $uri, ClientGateway $gateway): mixed + { + $this->receivedUri = $uri; + $this->receivedGateway = $gateway; + + return ['contents' => 'r-ok']; + } + }; + + $reference = new ElementReference($resourceHandler, true); + $referenceHandler = new ReferenceHandler(); + + $request = new \stdClass(); + $result = $referenceHandler->handle($reference, [ + '_session' => $session, + '_request' => $request, + 'uri' => 'config://x', + ]); + + $this->assertSame(['contents' => 'r-ok'], $result); + $this->assertSame('config://x', $resourceHandler->receivedUri); + $this->assertInstanceOf(ClientGateway::class, $resourceHandler->receivedGateway); + } + + public function testHandleThrowsForStringHandlerThatIsNeitherFunctionNorClass(): void + { + $session = $this->createMock(SessionInterface::class); + + $reference = new ElementReference('definitely_not_a_function_or_class_xyz'); + + $this->expectException(InvalidArgumentException::class); + + (new ReferenceHandler())->handle($reference, ['_session' => $session]); + } +}