From d85861ea27f517003366268924b9246c7d4099bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateu=20Aguil=C3=B3=20Bosch?= Date: Sun, 26 Apr 2026 07:49:12 +0200 Subject: [PATCH 01/12] [Server] feat: allow runtime handlers Adds Mcp\Server\Handler\RunTimeHandlerInterface so integrators (e.g. configuration-driven element sources) can register handlers whose arguments and execution are resolved at runtime rather than via PHP reflection of a callable. ReferenceHandler::handle() dispatches instanceof RunTimeHandler ahead of the callable/array/string branches, calls filterArguments to narrow the argument map, and forwards execute() a ClientGateway built from the active session. The Registry-layer surface (ElementReference and the four *Reference subclasses, RegistryInterface, Registry::register*, ArrayLoader's handler-description helper) is widened to accept the new type. The Builder/ArrayLoader path keeps its narrow callable union; exposing runtime handlers there requires reworking HandlerResolver and is left for a follow-up. --- src/Capability/Registry.php | 9 +- src/Capability/Registry/ElementReference.php | 6 +- .../Registry/Loader/ArrayLoader.php | 7 +- src/Capability/Registry/PromptReference.php | 3 +- src/Capability/Registry/ReferenceHandler.php | 9 ++ src/Capability/Registry/ResourceReference.php | 3 +- .../Registry/ResourceTemplateReference.php | 3 +- src/Capability/Registry/ToolReference.php | 3 +- src/Capability/RegistryInterface.php | 9 +- src/Server/Builder.php | 37 +++--- .../Handler/RunTimeHandlerInterface.php | 50 ++++++++ .../Registry/ReferenceHandlerTest.php | 114 ++++++++++++++++++ 12 files changed, 218 insertions(+), 35 deletions(-) create mode 100644 src/Server/Handler/RunTimeHandlerInterface.php create mode 100644 tests/Unit/Capability/Registry/ReferenceHandlerTest.php diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index 2a327ae4..4f1b9e7c 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\RunTimeHandlerInterface; 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|RunTimeHandlerInterface $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|RunTimeHandlerInterface $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|RunTimeHandlerInterface $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|RunTimeHandlerInterface $handler, array $completionProviders = [], bool $isManual = false, ): void { diff --git a/src/Capability/Registry/ElementReference.php b/src/Capability/Registry/ElementReference.php index 6425ba13..3c4f1e0e 100644 --- a/src/Capability/Registry/ElementReference.php +++ b/src/Capability/Registry/ElementReference.php @@ -11,8 +11,10 @@ namespace Mcp\Capability\Registry; +use Mcp\Server\Handler\RunTimeHandlerInterface; + /** - * @phpstan-type Handler \Closure|array{0: object|string, 1: string}|string + * @phpstan-type Handler \Closure|array{0: object|string, 1: string}|string|RunTimeHandlerInterface * * @author Kyrian Obikwelu */ @@ -22,7 +24,7 @@ class ElementReference * @param Handler $handler */ public function __construct( - public readonly \Closure|array|string $handler, + public readonly \Closure|array|string|RunTimeHandlerInterface $handler, public readonly bool $isManual = false, ) { } diff --git a/src/Capability/Registry/Loader/ArrayLoader.php b/src/Capability/Registry/Loader/ArrayLoader.php index d9a337e0..82b0da57 100644 --- a/src/Capability/Registry/Loader/ArrayLoader.php +++ b/src/Capability/Registry/Loader/ArrayLoader.php @@ -31,6 +31,7 @@ use Mcp\Schema\Tool; use Mcp\Schema\ToolAnnotations; use Mcp\Server\Handler; +use Mcp\Server\Handler\RunTimeHandlerInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -278,12 +279,16 @@ public function load(RegistryInterface $registry): void /** * @param Handler $handler */ - private function getHandlerDescription(\Closure|array|string $handler): string + private function getHandlerDescription(\Closure|array|string|RunTimeHandlerInterface $handler): string { if ($handler instanceof \Closure) { return 'Closure'; } + if ($handler instanceof RunTimeHandlerInterface) { + return $handler::class; + } + if (\is_array($handler)) { return \sprintf( '%s::%s', diff --git a/src/Capability/Registry/PromptReference.php b/src/Capability/Registry/PromptReference.php index 5de3a195..6758e1c0 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\RunTimeHandlerInterface; /** * @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|RunTimeHandlerInterface $handler, bool $isManual = false, public readonly array $completionProviders = [], ) { diff --git a/src/Capability/Registry/ReferenceHandler.php b/src/Capability/Registry/ReferenceHandler.php index 7b4a0cdc..497f8693 100644 --- a/src/Capability/Registry/ReferenceHandler.php +++ b/src/Capability/Registry/ReferenceHandler.php @@ -13,6 +13,8 @@ use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\RegistryException; +use Mcp\Server\ClientGateway; +use Mcp\Server\Handler\RunTimeHandlerInterface; use Mcp\Server\RequestContext; use Mcp\Server\Session\SessionInterface; use Psr\Container\ContainerInterface; @@ -34,6 +36,13 @@ public function handle(ElementReference $reference, array $arguments): mixed { $session = $arguments['_session']; + if ($reference->handler instanceof RunTimeHandlerInterface) { + return $reference->handler->execute( + $reference->handler->filterArguments($arguments), + new ClientGateway($session), + ); + } + if (\is_string($reference->handler)) { if (class_exists($reference->handler) && method_exists($reference->handler, '__invoke')) { $reflection = new \ReflectionMethod($reference->handler, '__invoke'); diff --git a/src/Capability/Registry/ResourceReference.php b/src/Capability/Registry/ResourceReference.php index d65f461e..62a6fe15 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\RunTimeHandlerInterface; /** * @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|RunTimeHandlerInterface $handler, bool $isManual = false, ) { parent::__construct($handler, $isManual); diff --git a/src/Capability/Registry/ResourceTemplateReference.php b/src/Capability/Registry/ResourceTemplateReference.php index ef2d915a..8f5446ac 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\RunTimeHandlerInterface; /** * @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|RunTimeHandlerInterface $handler, bool $isManual = false, public readonly array $completionProviders = [], ) { diff --git a/src/Capability/Registry/ToolReference.php b/src/Capability/Registry/ToolReference.php index 9aa5a3c9..a0a09b6d 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\RunTimeHandlerInterface; /** * @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|RunTimeHandlerInterface $handler, bool $isManual = false, ) { parent::__construct($handler, $isManual); diff --git a/src/Capability/RegistryInterface.php b/src/Capability/RegistryInterface.php index 67295681..f59add70 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\RunTimeHandlerInterface; /** * @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|RunTimeHandlerInterface $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|RunTimeHandlerInterface $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|RunTimeHandlerInterface $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|RunTimeHandlerInterface $handler, array $completionProviders = [], bool $isManual = false, ): void; diff --git a/src/Server/Builder.php b/src/Server/Builder.php index f1c5c053..86e9176f 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -17,7 +17,6 @@ use Mcp\Capability\Discovery\SchemaGeneratorInterface; use Mcp\Capability\Registry; use Mcp\Capability\Registry\Container; -use Mcp\Capability\Registry\ElementReference; use Mcp\Capability\Registry\Loader\ArrayLoader; use Mcp\Capability\Registry\Loader\DiscoveryLoader; use Mcp\Capability\Registry\Loader\LoaderInterface; @@ -49,8 +48,6 @@ use Symfony\Component\Finder\Finder; /** - * @phpstan-import-type Handler from ElementReference - * * @author Kyrian Obikwelu */ final class Builder @@ -97,7 +94,7 @@ final class Builder /** * @var array{ - * handler: Handler, + * handler: \Closure|array{0: object|string, 1: string}|string, * name: ?string, * description: ?string, * annotations: ?ToolAnnotations, @@ -110,7 +107,7 @@ final class Builder /** * @var array{ - * handler: Handler, + * handler: \Closure|array{0: object|string, 1: string}|string, * uri: string, * name: ?string, * description: ?string, @@ -125,7 +122,7 @@ final class Builder /** * @var array{ - * handler: Handler, + * handler: \Closure|array{0: object|string, 1: string}|string, * uriTemplate: string, * name: ?string, * description: ?string, @@ -138,7 +135,7 @@ final class Builder /** * @var array{ - * handler: Handler, + * handler: \Closure|array{0: object|string, 1: string}|string, * name: ?string, * description: ?string, * icons: ?Icon[], @@ -372,11 +369,11 @@ public function setProtocolVersion(ProtocolVersion $protocolVersion): self /** * Manually registers a tool handler. * - * @param Handler $handler - * @param array|null $inputSchema - * @param ?Icon[] $icons - * @param array|null $meta - * @param array|null $outputSchema + * @param callable|array{0: object|string, 1: string}|string $handler + * @param array|null $inputSchema + * @param ?Icon[] $icons + * @param array|null $meta + * @param array|null $outputSchema */ public function addTool( callable|array|string $handler, @@ -405,9 +402,9 @@ public function addTool( /** * Manually registers a resource handler. * - * @param Handler $handler - * @param ?Icon[] $icons - * @param array|null $meta + * @param \Closure|array{0: object|string, 1: string}|string $handler + * @param ?Icon[] $icons + * @param array|null $meta */ public function addResource( \Closure|array|string $handler, @@ -438,8 +435,8 @@ public function addResource( /** * Manually registers a resource template handler. * - * @param Handler $handler - * @param array|null $meta + * @param \Closure|array{0: object|string, 1: string}|string $handler + * @param array|null $meta */ public function addResourceTemplate( \Closure|array|string $handler, @@ -466,9 +463,9 @@ public function addResourceTemplate( /** * Manually registers a prompt handler. * - * @param Handler $handler - * @param ?Icon[] $icons - * @param array|null $meta + * @param \Closure|array{0: object|string, 1: string}|string $handler + * @param ?Icon[] $icons + * @param array|null $meta */ public function addPrompt( \Closure|array|string $handler, diff --git a/src/Server/Handler/RunTimeHandlerInterface.php b/src/Server/Handler/RunTimeHandlerInterface.php new file mode 100644 index 00000000..801103d7 --- /dev/null +++ b/src/Server/Handler/RunTimeHandlerInterface.php @@ -0,0 +1,50 @@ + $arguments arguments as constructed by the reference handler + * + * @return array the arguments the handler cares about + * + * @see \Mcp\Capability\Registry\ReferenceHandler::handle() + */ + public function filterArguments(array $arguments): array; + + /** + * Executes the handler and returns its result. + * + * @param array $arguments the handler arguments as key-value pairs + * @param ClientGateway $gateway client gateway for handlers that support callbacks + * + * @return mixed the handler result + */ + public function execute(array $arguments, ClientGateway $gateway): mixed; +} diff --git a/tests/Unit/Capability/Registry/ReferenceHandlerTest.php b/tests/Unit/Capability/Registry/ReferenceHandlerTest.php new file mode 100644 index 00000000..4d32e0ba --- /dev/null +++ b/tests/Unit/Capability/Registry/ReferenceHandlerTest.php @@ -0,0 +1,114 @@ +createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v7()); + + $runtimeHandler = new class implements RunTimeHandlerInterface { + /** @var array|null */ + public ?array $filteredFrom = null; + /** @var array|null */ + public ?array $executedWith = null; + public ?ClientGateway $receivedGateway = null; + + public function filterArguments(array $arguments): array + { + $this->filteredFrom = $arguments; + + return ['kept' => $arguments['kept'] ?? null]; + } + + public function execute(array $arguments, ClientGateway $gateway): mixed + { + $this->executedWith = $arguments; + $this->receivedGateway = $gateway; + + return 'runtime-result'; + } + }; + + $reference = new ElementReference($runtimeHandler, true); + $referenceHandler = new ReferenceHandler(); + + $result = $referenceHandler->handle($reference, [ + '_session' => $session, + 'kept' => 'value', + 'dropped' => 'noise', + ]); + + $this->assertSame('runtime-result', $result); + $this->assertSame( + ['_session' => $session, 'kept' => 'value', 'dropped' => 'noise'], + $runtimeHandler->filteredFrom, + ); + $this->assertSame(['kept' => 'value'], $runtimeHandler->executedWith); + $this->assertInstanceOf(ClientGateway::class, $runtimeHandler->receivedGateway); + } + + public function testRunTimeHandlerTakesPriorityOverInvokeAndCallableDetection(): void + { + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v7()); + + $runtimeHandler = new class implements RunTimeHandlerInterface { + public bool $executed = false; + + public function __invoke(): string + { + throw new \LogicException('__invoke must not be called when RunTimeHandlerInterface is implemented'); + } + + public function filterArguments(array $arguments): array + { + return []; + } + + public function execute(array $arguments, ClientGateway $gateway): mixed + { + $this->executed = true; + + return 'priority-ok'; + } + }; + + $reference = new ElementReference($runtimeHandler); + $referenceHandler = new ReferenceHandler(); + + $this->assertSame('priority-ok', $referenceHandler->handle($reference, ['_session' => $session])); + $this->assertTrue($runtimeHandler->executed); + } + + 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]); + } +} From 3bb94b43fec5a6d6480f7c350ca77d3c4d8710ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateu=20Aguil=C3=B3=20Bosch?= Date: Sun, 26 Apr 2026 08:02:57 +0200 Subject: [PATCH 02/12] feat(server): add runtime handler metadata API Extend RunTimeHandlerInterface with four nullable metadata accessors (getInputSchema, getOutputSchema, getPromptArguments, getCompletionProviders) so the loader can obtain element metadata without reflection. Add RunTimeHandlerTrait providing null defaults for all four accessors so single-purpose handlers can override only the methods relevant to their element kind. --- .../Handler/RunTimeHandlerInterface.php | 40 ++++++++++++++ src/Server/Handler/RunTimeHandlerTrait.php | 53 +++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 src/Server/Handler/RunTimeHandlerTrait.php diff --git a/src/Server/Handler/RunTimeHandlerInterface.php b/src/Server/Handler/RunTimeHandlerInterface.php index 801103d7..0b59e9a6 100644 --- a/src/Server/Handler/RunTimeHandlerInterface.php +++ b/src/Server/Handler/RunTimeHandlerInterface.php @@ -47,4 +47,44 @@ public function filterArguments(array $arguments): array; * @return mixed the handler result */ public function execute(array $arguments, ClientGateway $gateway): mixed; + + /** + * Returns the JSON Schema describing tool inputs. + * + * Returns null when this handler does not back a tool, or when the + * Builder caller supplies the schema via the `inputSchema:` keyword. + * + * @return array|null + */ + public function getInputSchema(): ?array; + + /** + * Returns the JSON Schema describing tool outputs. + * + * Returns null when no output schema applies (the field is itself optional + * on Tool), or when the Builder caller supplies the schema via the + * `outputSchema:` keyword. + * + * @return array|null + */ + public function getOutputSchema(): ?array; + + /** + * Returns the prompt arguments for prompt-backed runtime handlers. + * + * Returns null when this handler does not back a prompt. + * + * @return list<\Mcp\Schema\PromptArgument>|null + */ + public function getPromptArguments(): ?array; + + /** + * Returns the completion providers for prompts and resource templates. + * + * Map of argument name => provider class-string or provider instance. + * Returns null when no completion providers apply. + * + * @return array|null + */ + public function getCompletionProviders(): ?array; } diff --git a/src/Server/Handler/RunTimeHandlerTrait.php b/src/Server/Handler/RunTimeHandlerTrait.php new file mode 100644 index 00000000..0e306017 --- /dev/null +++ b/src/Server/Handler/RunTimeHandlerTrait.php @@ -0,0 +1,53 @@ +|null + */ + public function getInputSchema(): ?array + { + return null; + } + + /** + * @return array|null + */ + public function getOutputSchema(): ?array + { + return null; + } + + /** + * @return list<\Mcp\Schema\PromptArgument>|null + */ + public function getPromptArguments(): ?array + { + return null; + } + + /** + * @return array|null + */ + public function getCompletionProviders(): ?array + { + return null; + } +} From f4663b50ad2f2239e496811db1c2daeeaf93e07e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateu=20Aguil=C3=B3=20Bosch?= Date: Sun, 26 Apr 2026 08:08:29 +0200 Subject: [PATCH 03/12] feat(server): expose runtime handlers via Builder Widen Builder::addTool, addResource, addResourceTemplate, and addPrompt to accept RunTimeHandlerInterface instances alongside the existing closure/array/string handler forms. Branch ArrayLoader::load() on instanceof RunTimeHandlerInterface in each of the four registration loops so runtime handlers skip the reflection-based HandlerResolver/HandlerSchema path and pull metadata from the interface accessors instead. Require explicit name and description for runtime handlers and raise ConfigurationException when missing or when a runtime tool has no input schema source. Replace direct $data['handler'] use in error log context with getHandlerDescription() to avoid serializing large runtime handler object graphs into log records. Document the runtime handler API in docs/server-builder.md with a new Runtime Handlers subsection and add a cross-reference from docs/mcp-elements.md. --- docs/mcp-elements.md | 7 ++ docs/server-builder.md | 98 +++++++++++++++ .../Registry/Loader/ArrayLoader.php | 115 +++++++++++++++++- src/Server/Builder.php | 48 ++++---- 4 files changed, 243 insertions(+), 25 deletions(-) diff --git a/docs/mcp-elements.md b/docs/mcp-elements.md index b1a045f5..d50efe6a 100644 --- a/docs/mcp-elements.md +++ b/docs/mcp-elements.md @@ -42,6 +42,13 @@ 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 where the input shape is not known at +compile time and reflection-based discovery is therefore impossible (for +example, Drupal-style integrations bridging configuration entities into +MCP elements), see the [Runtime Handlers](server-builder.md#runtime-handlers) +subsection in the Server Builder docs — it covers the +`RunTimeHandlerInterface` contract and the `RunTimeHandlerTrait` shortcut. + ## Tools Tools are callable functions that perform actions and return results. diff --git a/docs/server-builder.md b/docs/server-builder.md index 3ec22ebb..3d139f9f 100644 --- a/docs/server-builder.md +++ b/docs/server-builder.md @@ -361,6 +361,104 @@ the handler's method name and docblock. For more details on MCP elements, handlers, and attribute-based discovery, see [MCP Elements](mcp-elements.md). +### Runtime Handlers + +The handler types listed above all rely on PHP reflection to derive a tool's +input schema, a prompt's arguments, completion providers, and so on. That +works whenever your element is backed by a PHP function or method whose +signature is known at compile time. + +Some integrations need to expose MCP elements whose shape is **not** known at +compile time. The canonical example is a Drupal-style integration that bridges +configuration entities into MCP elements: the input schema, prompt arguments, +and even the element name come from configuration that the SDK cannot +reflect on. For these cases, implement +`Mcp\Server\Handler\RunTimeHandlerInterface` and pass the instance to the +Builder the same way you would pass any other handler. + +A runtime handler defines its behavior through two methods that the registry +calls at request time: + +- `filterArguments(array $arguments): array` narrows the generic argument map + the registry constructs (which includes reserved keys such as `_session` + and `_request`) down to the keys your handler cares about. +- `execute(array $arguments, ClientGateway $gateway): mixed` runs the element + and returns its result. The `ClientGateway` lets you send notifications, + request sampling, etc. + +Because reflection cannot describe the element, the interface also exposes +four nullable metadata accessors: + +- `getInputSchema(): ?array` — JSON Schema for a tool's input. Return `null` + when the handler does not back a tool, or when the Builder caller supplies + the schema via the `inputSchema:` keyword (the kwarg takes precedence). +- `getOutputSchema(): ?array` — JSON Schema for a tool's output. Return + `null` when no output schema applies; the Builder's `outputSchema:` kwarg + takes precedence when supplied. +- `getPromptArguments(): ?array` — list of `PromptArgument` instances for a + prompt-backed runtime handler. Return `null` when the handler does not + back a prompt. There is no `arguments:` kwarg on `addPrompt()`; runtime + prompts source their arguments from this method only. +- `getCompletionProviders(): ?array` — map of `argumentName => class-string|object` + for prompts and resource templates. Return `null` when no completion + providers apply. + +Implementing four nullable accessors on top of the two behavior methods is +boilerplate-heavy for handlers that only back a single element kind. The +companion trait `Mcp\Server\Handler\RunTimeHandlerTrait` returns `null` from +all four accessors so you only override the ones relevant to your element. + +```php +use Mcp\Server\ClientGateway; +use Mcp\Server\Handler\RunTimeHandlerInterface; +use Mcp\Server\Handler\RunTimeHandlerTrait; + +final class WeatherToolHandler implements RunTimeHandlerInterface +{ + use RunTimeHandlerTrait; + + public function getInputSchema(): ?array + { + return [ + 'type' => 'object', + 'properties' => ['city' => ['type' => 'string']], + 'required' => ['city'], + ]; + } + + public function filterArguments(array $arguments): array + { + return ['city' => $arguments['city'] ?? '']; + } + + public function execute(array $arguments, ClientGateway $gateway): mixed + { + return ['temperature' => 21, 'unit' => 'C']; + } +} + +$server = Server::builder() + ->addTool( + handler: new WeatherToolHandler(), + name: 'get_weather', + description: 'Returns the current weather for a city.', + ) + ->build(); +``` + +**Required parameters:** `name` and `description` MUST be passed explicitly +to the Builder call when registering a runtime handler. Reflection-based +fallbacks do not apply because there is no PHP signature to reflect on. +Omitting either raises `Mcp\Exception\ConfigurationException` at registration +time. For tool runtime handlers, an input schema is also required: the +loader prefers the `inputSchema:` kwarg when supplied and otherwise calls +`getInputSchema()` — if both yield `null`, registration raises +`ConfigurationException`. + +The same pattern applies to `addResource()`, `addResourceTemplate()`, and +`addPrompt()`. Override only the metadata accessors relevant to the element +your handler backs. + ## Service Dependencies ### Container diff --git a/src/Capability/Registry/Loader/ArrayLoader.php b/src/Capability/Registry/Loader/ArrayLoader.php index 82b0da57..9cc8259e 100644 --- a/src/Capability/Registry/Loader/ArrayLoader.php +++ b/src/Capability/Registry/Loader/ArrayLoader.php @@ -48,6 +48,7 @@ final class ArrayLoader implements LoaderInterface * name: ?string, * description: ?string, * annotations: ?ToolAnnotations, + * inputSchema: ?array, * icons: ?Icon[], * meta: ?array, * outputSchema: ?array @@ -75,6 +76,7 @@ final class ArrayLoader implements LoaderInterface * @param array{ * handler: Handler, * name: ?string, + * title: ?string, * description: ?string, * icons: ?Icon[], * meta: ?array @@ -98,6 +100,36 @@ public function load(RegistryInterface $registry): void // Register Tools foreach ($this->tools as $data) { try { + if ($data['handler'] instanceof RunTimeHandlerInterface) { + if (null === $data['name']) { + throw new ConfigurationException(\sprintf('Runtime tool handler %s is missing a name; the Builder requires an explicit name for runtime handlers.', $data['handler']::class)); + } + if (null === $data['description']) { + throw new ConfigurationException(\sprintf('Runtime tool handler %s is missing a description; the Builder requires an explicit description for runtime handlers.', $data['handler']::class)); + } + + $inputSchema = $data['inputSchema'] ?? $data['handler']->getInputSchema(); + if (null === $inputSchema) { + throw new ConfigurationException(\sprintf('Runtime tool handler %s did not provide an input schema (neither via the inputSchema kwarg nor via getInputSchema()).', $data['handler']::class)); + } + $outputSchema = $data['outputSchema'] ?? $data['handler']->getOutputSchema(); + + $tool = new Tool( + name: $data['name'], + inputSchema: $inputSchema, + description: $data['description'], + annotations: $data['annotations'] ?? null, + icons: $data['icons'] ?? null, + meta: $data['meta'] ?? null, + outputSchema: $outputSchema, + ); + $registry->registerTool($tool, $data['handler'], true); + + $handlerDesc = $this->getHandlerDescription($data['handler']); + $this->logger->debug("Registered manual runtime tool {$data['name']} from handler {$handlerDesc}"); + continue; + } + $reflection = HandlerResolver::resolve($data['handler']); if ($reflection instanceof \ReflectionFunction) { @@ -130,7 +162,7 @@ public function load(RegistryInterface $registry): void } catch (\Throwable $e) { $this->logger->error( 'Failed to register manual tool', - ['handler' => $data['handler'], 'name' => $data['name'], 'exception' => $e], + ['handler' => $this->getHandlerDescription($data['handler']), 'name' => $data['name'], 'exception' => $e], ); throw new ConfigurationException("Error registering manual tool '{$data['name']}': {$e->getMessage()}", 0, $e); } @@ -139,6 +171,31 @@ public function load(RegistryInterface $registry): void // Register Resources foreach ($this->resources as $data) { try { + if ($data['handler'] instanceof RunTimeHandlerInterface) { + if (null === $data['name']) { + throw new ConfigurationException(\sprintf('Runtime resource handler %s is missing a name; the Builder requires an explicit name for runtime handlers.', $data['handler']::class)); + } + if (null === $data['description']) { + throw new ConfigurationException(\sprintf('Runtime resource handler %s is missing a description; the Builder requires an explicit description for runtime handlers.', $data['handler']::class)); + } + + $resource = new Resource( + uri: $data['uri'], + name: $data['name'], + description: $data['description'], + mimeType: $data['mimeType'] ?? null, + annotations: $data['annotations'] ?? null, + size: $data['size'] ?? null, + icons: $data['icons'] ?? null, + meta: $data['meta'] ?? null, + ); + $registry->registerResource($resource, $data['handler'], true); + + $handlerDesc = $this->getHandlerDescription($data['handler']); + $this->logger->debug("Registered manual runtime resource {$data['name']} from handler {$handlerDesc}"); + continue; + } + $reflection = HandlerResolver::resolve($data['handler']); if ($reflection instanceof \ReflectionFunction) { @@ -170,7 +227,7 @@ public function load(RegistryInterface $registry): void } catch (\Throwable $e) { $this->logger->error( 'Failed to register manual resource', - ['handler' => $data['handler'], 'uri' => $data['uri'], 'exception' => $e], + ['handler' => $this->getHandlerDescription($data['handler']), 'uri' => $data['uri'], 'exception' => $e], ); throw new ConfigurationException("Error registering manual resource '{$data['uri']}': {$e->getMessage()}", 0, $e); } @@ -179,6 +236,30 @@ public function load(RegistryInterface $registry): void // Register Templates foreach ($this->resourceTemplates as $data) { try { + if ($data['handler'] instanceof RunTimeHandlerInterface) { + if (null === $data['name']) { + throw new ConfigurationException(\sprintf('Runtime resource template handler %s is missing a name; the Builder requires an explicit name for runtime handlers.', $data['handler']::class)); + } + if (null === $data['description']) { + throw new ConfigurationException(\sprintf('Runtime resource template handler %s is missing a description; the Builder requires an explicit description for runtime handlers.', $data['handler']::class)); + } + + $template = new ResourceTemplate( + uriTemplate: $data['uriTemplate'], + name: $data['name'], + description: $data['description'], + mimeType: $data['mimeType'] ?? null, + annotations: $data['annotations'] ?? null, + meta: $data['meta'] ?? null, + ); + $completionProviders = $data['handler']->getCompletionProviders() ?? []; + $registry->registerResourceTemplate($template, $data['handler'], $completionProviders, true); + + $handlerDesc = $this->getHandlerDescription($data['handler']); + $this->logger->debug("Registered manual runtime template {$data['name']} from handler {$handlerDesc}"); + continue; + } + $reflection = HandlerResolver::resolve($data['handler']); if ($reflection instanceof \ReflectionFunction) { @@ -209,7 +290,7 @@ public function load(RegistryInterface $registry): void } catch (\Throwable $e) { $this->logger->error( 'Failed to register manual template', - ['handler' => $data['handler'], 'uriTemplate' => $data['uriTemplate'], 'exception' => $e], + ['handler' => $this->getHandlerDescription($data['handler']), 'uriTemplate' => $data['uriTemplate'], 'exception' => $e], ); throw new ConfigurationException("Error registering manual resource template '{$data['uriTemplate']}': {$e->getMessage()}", 0, $e); } @@ -218,6 +299,32 @@ public function load(RegistryInterface $registry): void // Register Prompts foreach ($this->prompts as $data) { try { + if ($data['handler'] instanceof RunTimeHandlerInterface) { + if (null === $data['name']) { + throw new ConfigurationException(\sprintf('Runtime prompt handler %s is missing a name; the Builder requires an explicit name for runtime handlers.', $data['handler']::class)); + } + if (null === $data['description']) { + throw new ConfigurationException(\sprintf('Runtime prompt handler %s is missing a description; the Builder requires an explicit description for runtime handlers.', $data['handler']::class)); + } + + $arguments = $data['handler']->getPromptArguments() ?? []; + $completionProviders = $data['handler']->getCompletionProviders() ?? []; + + $prompt = new Prompt( + name: $data['name'], + title: $data['title'] ?? null, + description: $data['description'], + arguments: $arguments, + icons: $data['icons'] ?? null, + meta: $data['meta'] ?? null + ); + $registry->registerPrompt($prompt, $data['handler'], $completionProviders, true); + + $handlerDesc = $this->getHandlerDescription($data['handler']); + $this->logger->debug("Registered manual runtime prompt {$data['name']} from handler {$handlerDesc}"); + continue; + } + $reflection = HandlerResolver::resolve($data['handler']); if ($reflection instanceof \ReflectionFunction) { @@ -267,7 +374,7 @@ public function load(RegistryInterface $registry): void } catch (\Throwable $e) { $this->logger->error( 'Failed to register manual prompt', - ['handler' => $data['handler'], 'name' => $data['name'], 'exception' => $e], + ['handler' => $this->getHandlerDescription($data['handler']), 'name' => $data['name'], 'exception' => $e], ); throw new ConfigurationException("Error registering manual prompt '{$data['name']}': {$e->getMessage()}", 0, $e); } diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 86e9176f..a6bad345 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -17,6 +17,7 @@ use Mcp\Capability\Discovery\SchemaGeneratorInterface; use Mcp\Capability\Registry; use Mcp\Capability\Registry\Container; +use Mcp\Capability\Registry\ElementReference; use Mcp\Capability\Registry\Loader\ArrayLoader; use Mcp\Capability\Registry\Loader\DiscoveryLoader; use Mcp\Capability\Registry\Loader\LoaderInterface; @@ -34,6 +35,7 @@ use Mcp\Server; use Mcp\Server\Handler\Notification\NotificationHandlerInterface; use Mcp\Server\Handler\Request\RequestHandlerInterface; +use Mcp\Server\Handler\RunTimeHandlerInterface; use Mcp\Server\Resource\SessionSubscriptionManager; use Mcp\Server\Resource\SubscriptionManagerInterface; use Mcp\Server\Session\InMemorySessionStore; @@ -48,6 +50,8 @@ use Symfony\Component\Finder\Finder; /** + * @phpstan-import-type Handler from ElementReference + * * @author Kyrian Obikwelu */ final class Builder @@ -94,10 +98,11 @@ final class Builder /** * @var array{ - * handler: \Closure|array{0: object|string, 1: string}|string, + * handler: Handler, * name: ?string, * description: ?string, * annotations: ?ToolAnnotations, + * inputSchema: ?array, * icons: ?Icon[], * meta: ?array, * outputSchema: ?array, @@ -107,7 +112,7 @@ final class Builder /** * @var array{ - * handler: \Closure|array{0: object|string, 1: string}|string, + * handler: Handler, * uri: string, * name: ?string, * description: ?string, @@ -122,7 +127,7 @@ final class Builder /** * @var array{ - * handler: \Closure|array{0: object|string, 1: string}|string, + * handler: Handler, * uriTemplate: string, * name: ?string, * description: ?string, @@ -135,8 +140,9 @@ final class Builder /** * @var array{ - * handler: \Closure|array{0: object|string, 1: string}|string, + * handler: Handler, * name: ?string, + * title: ?string, * description: ?string, * icons: ?Icon[], * meta: ?array @@ -369,14 +375,14 @@ public function setProtocolVersion(ProtocolVersion $protocolVersion): self /** * Manually registers a tool handler. * - * @param callable|array{0: object|string, 1: string}|string $handler - * @param array|null $inputSchema - * @param ?Icon[] $icons - * @param array|null $meta - * @param array|null $outputSchema + * @param callable|array{0: object|string, 1: string}|string|RunTimeHandlerInterface $handler + * @param array|null $inputSchema + * @param ?Icon[] $icons + * @param array|null $meta + * @param array|null $outputSchema */ public function addTool( - callable|array|string $handler, + callable|array|string|RunTimeHandlerInterface $handler, ?string $name = null, ?string $description = null, ?ToolAnnotations $annotations = null, @@ -402,12 +408,12 @@ public function addTool( /** * Manually registers a resource handler. * - * @param \Closure|array{0: object|string, 1: string}|string $handler - * @param ?Icon[] $icons - * @param array|null $meta + * @param \Closure|array{0: object|string, 1: string}|string|RunTimeHandlerInterface $handler + * @param ?Icon[] $icons + * @param array|null $meta */ public function addResource( - \Closure|array|string $handler, + \Closure|array|string|RunTimeHandlerInterface $handler, string $uri, ?string $name = null, ?string $description = null, @@ -435,11 +441,11 @@ public function addResource( /** * Manually registers a resource template handler. * - * @param \Closure|array{0: object|string, 1: string}|string $handler - * @param array|null $meta + * @param \Closure|array{0: object|string, 1: string}|string|RunTimeHandlerInterface $handler + * @param array|null $meta */ public function addResourceTemplate( - \Closure|array|string $handler, + \Closure|array|string|RunTimeHandlerInterface $handler, string $uriTemplate, ?string $name = null, ?string $description = null, @@ -463,12 +469,12 @@ public function addResourceTemplate( /** * Manually registers a prompt handler. * - * @param \Closure|array{0: object|string, 1: string}|string $handler - * @param ?Icon[] $icons - * @param array|null $meta + * @param \Closure|array{0: object|string, 1: string}|string|RunTimeHandlerInterface $handler + * @param ?Icon[] $icons + * @param array|null $meta */ public function addPrompt( - \Closure|array|string $handler, + \Closure|array|string|RunTimeHandlerInterface $handler, ?string $name = null, ?string $title = null, ?string $description = null, From 95984e560b0eb0e81db819e5f0484e76dfbf4a60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateu=20Aguil=C3=B3=20Bosch?= Date: Sun, 26 Apr 2026 08:12:57 +0200 Subject: [PATCH 04/12] test(server): cover runtime handler Builder path Add ArrayLoaderRunTimeHandlerTest covering: - successful tool registration with handler-supplied input schema, with kwarg input schema (precedence over the handler), and the same precedence rule for output schema - ConfigurationException for tools missing name, description, or any input schema source - successful resource registration via Builder kwargs and the matching missing-name failure mode - successful resource template registration with completion providers sourced from the handler and the missing-description failure mode - successful prompt registration with arguments and completion providers sourced from the handler, plus the missing-name failure mode - a trait-only fixture verifying the four metadata accessors return null by default Update existing ReferenceHandlerTest anonymous classes to use RunTimeHandlerTrait so they satisfy the widened interface. --- .../Loader/ArrayLoaderRunTimeHandlerTest.php | 335 ++++++++++++++++++ .../Registry/ReferenceHandlerTest.php | 5 + 2 files changed, 340 insertions(+) create mode 100644 tests/Unit/Capability/Registry/Loader/ArrayLoaderRunTimeHandlerTest.php diff --git a/tests/Unit/Capability/Registry/Loader/ArrayLoaderRunTimeHandlerTest.php b/tests/Unit/Capability/Registry/Loader/ArrayLoaderRunTimeHandlerTest.php new file mode 100644 index 00000000..28639881 --- /dev/null +++ b/tests/Unit/Capability/Registry/Loader/ArrayLoaderRunTimeHandlerTest.php @@ -0,0 +1,335 @@ +assertNull($handler->getInputSchema()); + $this->assertNull($handler->getOutputSchema()); + $this->assertNull($handler->getPromptArguments()); + $this->assertNull($handler->getCompletionProviders()); + } + + public function testAddToolUsesInputSchemaFromHandlerWhenNoKwarg(): void + { + $handler = new SchemaToolHandler(); + + $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->addTool( + handler: $handler, + name: 'demo', + description: 'Demo tool', + )); + + $reference = $registry->getTool('demo'); + $this->assertSame('demo', $reference->tool->name); + $this->assertSame('Demo tool', $reference->tool->description); + $this->assertSame($handler->getInputSchema(), $reference->tool->inputSchema); + } + + public function testAddToolPrefersInputSchemaKwargOverHandler(): void + { + $handler = new SchemaToolHandler(); + $kwargSchema = ['type' => 'object', 'properties' => ['y' => ['type' => 'integer']]]; + + $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->addTool( + handler: $handler, + name: 'demo', + description: 'Demo tool', + inputSchema: $kwargSchema, + )); + + $this->assertSame($kwargSchema, $registry->getTool('demo')->tool->inputSchema); + } + + public function testAddToolPrefersOutputSchemaKwargOverHandler(): void + { + $handler = new OutputSchemaToolHandler(); + $kwargOutput = ['type' => 'object', 'properties' => ['from' => ['const' => 'kwarg']]]; + + $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->addTool( + handler: $handler, + name: 'demo', + description: 'Demo tool', + outputSchema: $kwargOutput, + )); + + $this->assertSame($kwargOutput, $registry->getTool('demo')->tool->outputSchema); + } + + public function testAddToolUsesOutputSchemaFromHandlerWhenNoKwarg(): void + { + $handler = new OutputSchemaToolHandler(); + + $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->addTool( + handler: $handler, + name: 'demo', + description: 'Demo tool', + )); + + $this->assertSame($handler->getOutputSchema(), $registry->getTool('demo')->tool->outputSchema); + } + + public function testAddToolWithoutNameRaisesConfigurationException(): void + { + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessageMatches('/'.preg_quote(SchemaToolHandler::class, '/').'/'); + + $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->addTool( + handler: new SchemaToolHandler(), + description: 'no name', + )); + } + + public function testAddToolWithoutDescriptionRaisesConfigurationException(): void + { + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessageMatches('/'.preg_quote(SchemaToolHandler::class, '/').'/'); + + $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->addTool( + handler: new SchemaToolHandler(), + name: 'demo', + )); + } + + public function testAddToolWithoutAnyInputSchemaRaisesConfigurationException(): void + { + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessageMatches('/'.preg_quote(TraitOnlyRuntimeHandler::class, '/').'/'); + + $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->addTool( + handler: new TraitOnlyRuntimeHandler(), + name: 'demo', + description: 'no schema source', + )); + } + + public function testAddResourceRegistersRuntimeHandler(): void + { + $handler = new TraitOnlyRuntimeHandler(); + + $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->addResource( + handler: $handler, + uri: 'config://app/settings', + name: 'app_settings', + description: 'App settings', + mimeType: 'application/json', + )); + + $reference = $registry->getResource('config://app/settings', false); + $this->assertSame('app_settings', $reference->resource->name); + $this->assertSame('App settings', $reference->resource->description); + $this->assertSame('application/json', $reference->resource->mimeType); + } + + public function testAddResourceWithoutNameRaisesConfigurationException(): void + { + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessageMatches('/'.preg_quote(TraitOnlyRuntimeHandler::class, '/').'/'); + + $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->addResource( + handler: new TraitOnlyRuntimeHandler(), + uri: 'config://x', + description: 'no name', + )); + } + + public function testAddResourceTemplateRegistersRuntimeHandlerWithCompletionProviders(): void + { + $handler = new ResourceTemplateRuntimeHandler(); + + $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->addResourceTemplate( + handler: $handler, + uriTemplate: 'user://{userId}/profile', + name: 'user_profile', + description: 'User profile by ID', + mimeType: 'application/json', + )); + + $reference = $registry->getResourceTemplate('user://{userId}/profile'); + $this->assertSame('user_profile', $reference->resourceTemplate->name); + $this->assertSame('application/json', $reference->resourceTemplate->mimeType); + $this->assertArrayHasKey('userId', $reference->completionProviders); + $this->assertInstanceOf(ListCompletionProvider::class, $reference->completionProviders['userId']); + } + + public function testAddResourceTemplateWithoutDescriptionRaisesConfigurationException(): void + { + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessageMatches('/'.preg_quote(TraitOnlyRuntimeHandler::class, '/').'/'); + + $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->addResourceTemplate( + handler: new TraitOnlyRuntimeHandler(), + uriTemplate: 'user://{userId}', + name: 'user', + )); + } + + public function testAddPromptRegistersRuntimeHandlerWithArgumentsFromHandler(): void + { + $handler = new PromptRuntimeHandler(); + + $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->addPrompt( + handler: $handler, + name: 'ask', + description: 'Ask the assistant a question', + )); + + $reference = $registry->getPrompt('ask'); + $this->assertSame('ask', $reference->prompt->name); + $this->assertEquals($handler->getPromptArguments(), $reference->prompt->arguments); + $this->assertArrayHasKey('q', $reference->completionProviders); + $this->assertInstanceOf(ListCompletionProvider::class, $reference->completionProviders['q']); + } + + public function testAddPromptWithoutNameRaisesConfigurationException(): void + { + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessageMatches('/'.preg_quote(PromptRuntimeHandler::class, '/').'/'); + + $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->addPrompt( + handler: new PromptRuntimeHandler(), + description: 'no name', + )); + } + + /** + * @param callable(Server\Builder): Server\Builder $configure + */ + private function buildAndGetRegistry(callable $configure): \Mcp\Capability\RegistryInterface + { + $registry = new \Mcp\Capability\Registry(); + $builder = Server::builder() + ->setServerInfo('test', '1.0.0') + ->setRegistry($registry); + $configure($builder)->build(); + + return $registry; + } +} + +final class TraitOnlyRuntimeHandler implements RunTimeHandlerInterface +{ + use RunTimeHandlerTrait; + + public function filterArguments(array $arguments): array + { + return []; + } + + public function execute(array $arguments, ClientGateway $gateway): mixed + { + return null; + } +} + +final class SchemaToolHandler implements RunTimeHandlerInterface +{ + use RunTimeHandlerTrait; + + public function getInputSchema(): array + { + return ['type' => 'object', 'properties' => ['x' => ['type' => 'string']]]; + } + + public function filterArguments(array $arguments): array + { + return $arguments; + } + + public function execute(array $arguments, ClientGateway $gateway): mixed + { + return ['ok' => true]; + } +} + +final class OutputSchemaToolHandler implements RunTimeHandlerInterface +{ + use RunTimeHandlerTrait; + + public function getInputSchema(): array + { + return ['type' => 'object']; + } + + public function getOutputSchema(): array + { + return ['type' => 'object', 'properties' => ['from' => ['const' => 'handler']]]; + } + + public function filterArguments(array $arguments): array + { + return $arguments; + } + + public function execute(array $arguments, ClientGateway $gateway): mixed + { + return ['from' => 'handler']; + } +} + +final class ResourceTemplateRuntimeHandler implements RunTimeHandlerInterface +{ + use RunTimeHandlerTrait; + + public function getCompletionProviders(): array + { + return ['userId' => new ListCompletionProvider(['alice', 'bob'])]; + } + + public function filterArguments(array $arguments): array + { + return $arguments; + } + + public function execute(array $arguments, ClientGateway $gateway): mixed + { + return ['ok' => true]; + } +} + +final class PromptRuntimeHandler implements RunTimeHandlerInterface +{ + use RunTimeHandlerTrait; + + public function getPromptArguments(): array + { + return [new PromptArgument('q', 'The question', true)]; + } + + public function getCompletionProviders(): array + { + return ['q' => new ListCompletionProvider(['hello', 'world'])]; + } + + public function filterArguments(array $arguments): array + { + return $arguments; + } + + public function execute(array $arguments, ClientGateway $gateway): mixed + { + return []; + } +} diff --git a/tests/Unit/Capability/Registry/ReferenceHandlerTest.php b/tests/Unit/Capability/Registry/ReferenceHandlerTest.php index 4d32e0ba..6abd29af 100644 --- a/tests/Unit/Capability/Registry/ReferenceHandlerTest.php +++ b/tests/Unit/Capability/Registry/ReferenceHandlerTest.php @@ -16,6 +16,7 @@ use Mcp\Exception\InvalidArgumentException; use Mcp\Server\ClientGateway; use Mcp\Server\Handler\RunTimeHandlerInterface; +use Mcp\Server\Handler\RunTimeHandlerTrait; use Mcp\Server\Session\SessionInterface; use PHPUnit\Framework\TestCase; use Symfony\Component\Uid\Uuid; @@ -28,6 +29,8 @@ public function testHandleDispatchesToRunTimeHandlerAndForwardsClientGateway(): $session->method('getId')->willReturn(Uuid::v7()); $runtimeHandler = new class implements RunTimeHandlerInterface { + use RunTimeHandlerTrait; + /** @var array|null */ public ?array $filteredFrom = null; /** @var array|null */ @@ -74,6 +77,8 @@ public function testRunTimeHandlerTakesPriorityOverInvokeAndCallableDetection(): $session->method('getId')->willReturn(Uuid::v7()); $runtimeHandler = new class implements RunTimeHandlerInterface { + use RunTimeHandlerTrait; + public bool $executed = false; public function __invoke(): string From 867a40c64e31b1e06cf8ba384fab69a8dc81a302 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateu=20Aguil=C3=B3=20Bosch?= Date: Sun, 26 Apr 2026 09:38:59 +0200 Subject: [PATCH 05/12] refactor(server): split runtime handler interface Splits RunTimeHandlerInterface into a minimal base contract (only execute()) plus element-specific subtypes: RunTimeToolHandlerInterface, RunTimePromptHandlerInterface, and RunTimeResourceTemplateHandlerInterface. Each subtype declares only the metadata accessors relevant to its element kind, so a tool handler no longer has to no-op out prompt or template methods. Drops the now-redundant RunTimeHandlerTrait and removes filterArguments; execute() receives the full argument map directly. Updates ArrayLoader, ReferenceHandler, and Builder type signatures to use the per-element interfaces, and rewrites the Runtime Handlers documentation accordingly. --- docs/mcp-elements.md | 9 +- docs/server-builder.md | 87 +++++-------------- .../Registry/Loader/ArrayLoader.php | 9 +- src/Capability/Registry/ReferenceHandler.php | 5 +- src/Server/Builder.php | 29 ++++--- .../Handler/RunTimeHandlerInterface.php | 69 +++------------ src/Server/Handler/RunTimeHandlerTrait.php | 53 ----------- .../Handler/RunTimePromptHandlerInterface.php | 37 ++++++++ ...unTimeResourceTemplateHandlerInterface.php | 28 ++++++ .../Handler/RunTimeToolHandlerInterface.php | 38 ++++++++ .../Loader/ArrayLoaderRunTimeHandlerTest.php | 82 +++++++---------- .../Registry/ReferenceHandlerTest.php | 22 +---- 12 files changed, 195 insertions(+), 273 deletions(-) delete mode 100644 src/Server/Handler/RunTimeHandlerTrait.php create mode 100644 src/Server/Handler/RunTimePromptHandlerInterface.php create mode 100644 src/Server/Handler/RunTimeResourceTemplateHandlerInterface.php create mode 100644 src/Server/Handler/RunTimeToolHandlerInterface.php diff --git a/docs/mcp-elements.md b/docs/mcp-elements.md index d50efe6a..741c54f5 100644 --- a/docs/mcp-elements.md +++ b/docs/mcp-elements.md @@ -42,12 +42,9 @@ 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 where the input shape is not known at -compile time and reflection-based discovery is therefore impossible (for -example, Drupal-style integrations bridging configuration entities into -MCP elements), see the [Runtime Handlers](server-builder.md#runtime-handlers) -subsection in the Server Builder docs — it covers the -`RunTimeHandlerInterface` contract and the `RunTimeHandlerTrait` shortcut. +For runtime, config-driven elements whose shape is not known at compile time +(e.g. bridging configuration entities into MCP elements), see [Runtime +Handlers](server-builder.md#runtime-handlers) in the Server Builder docs. ## Tools diff --git a/docs/server-builder.md b/docs/server-builder.md index 3d139f9f..45a18647 100644 --- a/docs/server-builder.md +++ b/docs/server-builder.md @@ -363,60 +363,29 @@ For more details on MCP elements, handlers, and attribute-based discovery, see [ ### Runtime Handlers -The handler types listed above all rely on PHP reflection to derive a tool's -input schema, a prompt's arguments, completion providers, and so on. That -works whenever your element is backed by a PHP function or method whose -signature is known at compile time. - -Some integrations need to expose MCP elements whose shape is **not** known at -compile time. The canonical example is a Drupal-style integration that bridges -configuration entities into MCP elements: the input schema, prompt arguments, -and even the element name come from configuration that the SDK cannot -reflect on. For these cases, implement -`Mcp\Server\Handler\RunTimeHandlerInterface` and pass the instance to the -Builder the same way you would pass any other handler. - -A runtime handler defines its behavior through two methods that the registry -calls at request time: - -- `filterArguments(array $arguments): array` narrows the generic argument map - the registry constructs (which includes reserved keys such as `_session` - and `_request`) down to the keys your handler cares about. -- `execute(array $arguments, ClientGateway $gateway): mixed` runs the element - and returns its result. The `ClientGateway` lets you send notifications, - request sampling, etc. - -Because reflection cannot describe the element, the interface also exposes -four nullable metadata accessors: - -- `getInputSchema(): ?array` — JSON Schema for a tool's input. Return `null` - when the handler does not back a tool, or when the Builder caller supplies - the schema via the `inputSchema:` keyword (the kwarg takes precedence). -- `getOutputSchema(): ?array` — JSON Schema for a tool's output. Return - `null` when no output schema applies; the Builder's `outputSchema:` kwarg - takes precedence when supplied. -- `getPromptArguments(): ?array` — list of `PromptArgument` instances for a - prompt-backed runtime handler. Return `null` when the handler does not - back a prompt. There is no `arguments:` kwarg on `addPrompt()`; runtime - prompts source their arguments from this method only. -- `getCompletionProviders(): ?array` — map of `argumentName => class-string|object` - for prompts and resource templates. Return `null` when no completion - providers apply. - -Implementing four nullable accessors on top of the two behavior methods is -boilerplate-heavy for handlers that only back a single element kind. The -companion trait `Mcp\Server\Handler\RunTimeHandlerTrait` returns `null` from -all four accessors so you only override the ones relevant to your element. +When an element's shape is not known at compile time (e.g. config-driven +integrations), reflection-based discovery does not apply. Implement an +element-specific runtime interface instead and pass the instance to the +Builder. + +| Element kind | Interface | +|------------------|------------------------------------------| +| Tool | `RunTimeToolHandlerInterface` | +| Prompt | `RunTimePromptHandlerInterface` | +| Resource template| `RunTimeResourceTemplateHandlerInterface`| +| Resource | `RunTimeHandlerInterface` | + +Each interface declares only the metadata it needs (input/output schema for +tools, prompt arguments and completion providers for prompts, completion +providers for resource templates). All extend the base +`RunTimeHandlerInterface`, which requires only `execute()`. ```php use Mcp\Server\ClientGateway; -use Mcp\Server\Handler\RunTimeHandlerInterface; -use Mcp\Server\Handler\RunTimeHandlerTrait; +use Mcp\Server\Handler\RunTimeToolHandlerInterface; -final class WeatherToolHandler implements RunTimeHandlerInterface +final class WeatherToolHandler implements RunTimeToolHandlerInterface { - use RunTimeHandlerTrait; - public function getInputSchema(): ?array { return [ @@ -426,9 +395,9 @@ final class WeatherToolHandler implements RunTimeHandlerInterface ]; } - public function filterArguments(array $arguments): array + public function getOutputSchema(): ?array { - return ['city' => $arguments['city'] ?? '']; + return null; } public function execute(array $arguments, ClientGateway $gateway): mixed @@ -446,18 +415,10 @@ $server = Server::builder() ->build(); ``` -**Required parameters:** `name` and `description` MUST be passed explicitly -to the Builder call when registering a runtime handler. Reflection-based -fallbacks do not apply because there is no PHP signature to reflect on. -Omitting either raises `Mcp\Exception\ConfigurationException` at registration -time. For tool runtime handlers, an input schema is also required: the -loader prefers the `inputSchema:` kwarg when supplied and otherwise calls -`getInputSchema()` — if both yield `null`, registration raises -`ConfigurationException`. - -The same pattern applies to `addResource()`, `addResourceTemplate()`, and -`addPrompt()`. Override only the metadata accessors relevant to the element -your handler backs. +`name` and `description` are required when registering a runtime handler. +For tools, an input schema is also required (via the `inputSchema:` kwarg or +`getInputSchema()`). Missing values raise `ConfigurationException` at +registration time. ## Service Dependencies diff --git a/src/Capability/Registry/Loader/ArrayLoader.php b/src/Capability/Registry/Loader/ArrayLoader.php index 9cc8259e..c7c47312 100644 --- a/src/Capability/Registry/Loader/ArrayLoader.php +++ b/src/Capability/Registry/Loader/ArrayLoader.php @@ -32,6 +32,9 @@ use Mcp\Schema\ToolAnnotations; use Mcp\Server\Handler; use Mcp\Server\Handler\RunTimeHandlerInterface; +use Mcp\Server\Handler\RunTimePromptHandlerInterface; +use Mcp\Server\Handler\RunTimeResourceTemplateHandlerInterface; +use Mcp\Server\Handler\RunTimeToolHandlerInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -100,7 +103,7 @@ public function load(RegistryInterface $registry): void // Register Tools foreach ($this->tools as $data) { try { - if ($data['handler'] instanceof RunTimeHandlerInterface) { + if ($data['handler'] instanceof RunTimeToolHandlerInterface) { if (null === $data['name']) { throw new ConfigurationException(\sprintf('Runtime tool handler %s is missing a name; the Builder requires an explicit name for runtime handlers.', $data['handler']::class)); } @@ -236,7 +239,7 @@ public function load(RegistryInterface $registry): void // Register Templates foreach ($this->resourceTemplates as $data) { try { - if ($data['handler'] instanceof RunTimeHandlerInterface) { + if ($data['handler'] instanceof RunTimeResourceTemplateHandlerInterface) { if (null === $data['name']) { throw new ConfigurationException(\sprintf('Runtime resource template handler %s is missing a name; the Builder requires an explicit name for runtime handlers.', $data['handler']::class)); } @@ -299,7 +302,7 @@ public function load(RegistryInterface $registry): void // Register Prompts foreach ($this->prompts as $data) { try { - if ($data['handler'] instanceof RunTimeHandlerInterface) { + if ($data['handler'] instanceof RunTimePromptHandlerInterface) { if (null === $data['name']) { throw new ConfigurationException(\sprintf('Runtime prompt handler %s is missing a name; the Builder requires an explicit name for runtime handlers.', $data['handler']::class)); } diff --git a/src/Capability/Registry/ReferenceHandler.php b/src/Capability/Registry/ReferenceHandler.php index 497f8693..bd837390 100644 --- a/src/Capability/Registry/ReferenceHandler.php +++ b/src/Capability/Registry/ReferenceHandler.php @@ -37,10 +37,7 @@ public function handle(ElementReference $reference, array $arguments): mixed $session = $arguments['_session']; if ($reference->handler instanceof RunTimeHandlerInterface) { - return $reference->handler->execute( - $reference->handler->filterArguments($arguments), - new ClientGateway($session), - ); + return $reference->handler->execute($arguments, new ClientGateway($session)); } if (\is_string($reference->handler)) { diff --git a/src/Server/Builder.php b/src/Server/Builder.php index a6bad345..eaefcce1 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -36,6 +36,9 @@ use Mcp\Server\Handler\Notification\NotificationHandlerInterface; use Mcp\Server\Handler\Request\RequestHandlerInterface; use Mcp\Server\Handler\RunTimeHandlerInterface; +use Mcp\Server\Handler\RunTimePromptHandlerInterface; +use Mcp\Server\Handler\RunTimeResourceTemplateHandlerInterface; +use Mcp\Server\Handler\RunTimeToolHandlerInterface; use Mcp\Server\Resource\SessionSubscriptionManager; use Mcp\Server\Resource\SubscriptionManagerInterface; use Mcp\Server\Session\InMemorySessionStore; @@ -375,14 +378,14 @@ public function setProtocolVersion(ProtocolVersion $protocolVersion): self /** * Manually registers a tool handler. * - * @param callable|array{0: object|string, 1: string}|string|RunTimeHandlerInterface $handler - * @param array|null $inputSchema - * @param ?Icon[] $icons - * @param array|null $meta - * @param array|null $outputSchema + * @param callable|array{0: object|string, 1: string}|string|RunTimeToolHandlerInterface $handler + * @param array|null $inputSchema + * @param ?Icon[] $icons + * @param array|null $meta + * @param array|null $outputSchema */ public function addTool( - callable|array|string|RunTimeHandlerInterface $handler, + callable|array|string|RunTimeToolHandlerInterface $handler, ?string $name = null, ?string $description = null, ?ToolAnnotations $annotations = null, @@ -441,11 +444,11 @@ public function addResource( /** * Manually registers a resource template handler. * - * @param \Closure|array{0: object|string, 1: string}|string|RunTimeHandlerInterface $handler - * @param array|null $meta + * @param \Closure|array{0: object|string, 1: string}|string|RunTimeResourceTemplateHandlerInterface $handler + * @param array|null $meta */ public function addResourceTemplate( - \Closure|array|string|RunTimeHandlerInterface $handler, + \Closure|array|string|RunTimeResourceTemplateHandlerInterface $handler, string $uriTemplate, ?string $name = null, ?string $description = null, @@ -469,12 +472,12 @@ public function addResourceTemplate( /** * Manually registers a prompt handler. * - * @param \Closure|array{0: object|string, 1: string}|string|RunTimeHandlerInterface $handler - * @param ?Icon[] $icons - * @param array|null $meta + * @param \Closure|array{0: object|string, 1: string}|string|RunTimePromptHandlerInterface $handler + * @param ?Icon[] $icons + * @param array|null $meta */ public function addPrompt( - \Closure|array|string|RunTimeHandlerInterface $handler, + \Closure|array|string|RunTimePromptHandlerInterface $handler, ?string $name = null, ?string $title = null, ?string $description = null, diff --git a/src/Server/Handler/RunTimeHandlerInterface.php b/src/Server/Handler/RunTimeHandlerInterface.php index 0b59e9a6..37acdfc7 100644 --- a/src/Server/Handler/RunTimeHandlerInterface.php +++ b/src/Server/Handler/RunTimeHandlerInterface.php @@ -14,30 +14,21 @@ use Mcp\Server\ClientGateway; /** - * Contract for handlers that resolve their own arguments and execute at runtime. + * Base contract for handlers that execute at runtime. * * Unlike string/array/Closure handlers, a runtime handler is a stateful object - * registered with a reference. The reference handler delegates argument - * filtering and execution to it, and provides a {@see ClientGateway} so the - * handler can communicate with the client (notifications, sampling, etc.). + * registered with a reference. The reference handler invokes {@see self::execute()} + * with the full argument map (including reserved keys such as `_session` and + * `_request`) and a {@see ClientGateway} for client-side callbacks. + * + * Element-specific subtypes ({@see RunTimeToolHandlerInterface}, + * {@see RunTimePromptHandlerInterface}, {@see RunTimeResourceTemplateHandlerInterface}) + * declare only the metadata accessors relevant to their element kind. + * Resources have no extra metadata and may implement this base interface + * directly. */ interface RunTimeHandlerInterface { - /** - * Filters out arguments that the handler does not care about. - * - * The reference handler builds a generic argument map (including reserved - * keys such as `_session` and `_request`); this method narrows it down to - * what {@see self::execute()} expects. - * - * @param array $arguments arguments as constructed by the reference handler - * - * @return array the arguments the handler cares about - * - * @see \Mcp\Capability\Registry\ReferenceHandler::handle() - */ - public function filterArguments(array $arguments): array; - /** * Executes the handler and returns its result. * @@ -47,44 +38,4 @@ public function filterArguments(array $arguments): array; * @return mixed the handler result */ public function execute(array $arguments, ClientGateway $gateway): mixed; - - /** - * Returns the JSON Schema describing tool inputs. - * - * Returns null when this handler does not back a tool, or when the - * Builder caller supplies the schema via the `inputSchema:` keyword. - * - * @return array|null - */ - public function getInputSchema(): ?array; - - /** - * Returns the JSON Schema describing tool outputs. - * - * Returns null when no output schema applies (the field is itself optional - * on Tool), or when the Builder caller supplies the schema via the - * `outputSchema:` keyword. - * - * @return array|null - */ - public function getOutputSchema(): ?array; - - /** - * Returns the prompt arguments for prompt-backed runtime handlers. - * - * Returns null when this handler does not back a prompt. - * - * @return list<\Mcp\Schema\PromptArgument>|null - */ - public function getPromptArguments(): ?array; - - /** - * Returns the completion providers for prompts and resource templates. - * - * Map of argument name => provider class-string or provider instance. - * Returns null when no completion providers apply. - * - * @return array|null - */ - public function getCompletionProviders(): ?array; } diff --git a/src/Server/Handler/RunTimeHandlerTrait.php b/src/Server/Handler/RunTimeHandlerTrait.php deleted file mode 100644 index 0e306017..00000000 --- a/src/Server/Handler/RunTimeHandlerTrait.php +++ /dev/null @@ -1,53 +0,0 @@ -|null - */ - public function getInputSchema(): ?array - { - return null; - } - - /** - * @return array|null - */ - public function getOutputSchema(): ?array - { - return null; - } - - /** - * @return list<\Mcp\Schema\PromptArgument>|null - */ - public function getPromptArguments(): ?array - { - return null; - } - - /** - * @return array|null - */ - public function getCompletionProviders(): ?array - { - return null; - } -} diff --git a/src/Server/Handler/RunTimePromptHandlerInterface.php b/src/Server/Handler/RunTimePromptHandlerInterface.php new file mode 100644 index 00000000..581bbec2 --- /dev/null +++ b/src/Server/Handler/RunTimePromptHandlerInterface.php @@ -0,0 +1,37 @@ +|null + */ + public function getPromptArguments(): ?array; + + /** + * Returns the completion providers for the prompt arguments. + * + * Map of argument name => provider class-string or provider instance. + * Returns null when no completion providers apply. + * + * @return array|null + */ + public function getCompletionProviders(): ?array; +} diff --git a/src/Server/Handler/RunTimeResourceTemplateHandlerInterface.php b/src/Server/Handler/RunTimeResourceTemplateHandlerInterface.php new file mode 100644 index 00000000..d1cee30d --- /dev/null +++ b/src/Server/Handler/RunTimeResourceTemplateHandlerInterface.php @@ -0,0 +1,28 @@ + provider class-string or provider instance. + * Returns null when no completion providers apply. + * + * @return array|null + */ + public function getCompletionProviders(): ?array; +} diff --git a/src/Server/Handler/RunTimeToolHandlerInterface.php b/src/Server/Handler/RunTimeToolHandlerInterface.php new file mode 100644 index 00000000..cdeef762 --- /dev/null +++ b/src/Server/Handler/RunTimeToolHandlerInterface.php @@ -0,0 +1,38 @@ +|null + */ + public function getInputSchema(): ?array; + + /** + * Returns the JSON Schema describing tool outputs. + * + * Returns null when no output schema applies, or when the Builder caller + * supplies the schema via the `outputSchema:` keyword. + * + * @return array|null + */ + public function getOutputSchema(): ?array; +} diff --git a/tests/Unit/Capability/Registry/Loader/ArrayLoaderRunTimeHandlerTest.php b/tests/Unit/Capability/Registry/Loader/ArrayLoaderRunTimeHandlerTest.php index 28639881..f8f459a9 100644 --- a/tests/Unit/Capability/Registry/Loader/ArrayLoaderRunTimeHandlerTest.php +++ b/tests/Unit/Capability/Registry/Loader/ArrayLoaderRunTimeHandlerTest.php @@ -17,21 +17,13 @@ use Mcp\Server; use Mcp\Server\ClientGateway; use Mcp\Server\Handler\RunTimeHandlerInterface; -use Mcp\Server\Handler\RunTimeHandlerTrait; +use Mcp\Server\Handler\RunTimePromptHandlerInterface; +use Mcp\Server\Handler\RunTimeResourceTemplateHandlerInterface; +use Mcp\Server\Handler\RunTimeToolHandlerInterface; use PHPUnit\Framework\TestCase; final class ArrayLoaderRunTimeHandlerTest extends TestCase { - public function testTraitOnlyHandlerReturnsNullFromAllMetadataAccessors(): void - { - $handler = new TraitOnlyRuntimeHandler(); - - $this->assertNull($handler->getInputSchema()); - $this->assertNull($handler->getOutputSchema()); - $this->assertNull($handler->getPromptArguments()); - $this->assertNull($handler->getCompletionProviders()); - } - public function testAddToolUsesInputSchemaFromHandlerWhenNoKwarg(): void { $handler = new SchemaToolHandler(); @@ -116,10 +108,10 @@ public function testAddToolWithoutDescriptionRaisesConfigurationException(): voi public function testAddToolWithoutAnyInputSchemaRaisesConfigurationException(): void { $this->expectException(ConfigurationException::class); - $this->expectExceptionMessageMatches('/'.preg_quote(TraitOnlyRuntimeHandler::class, '/').'/'); + $this->expectExceptionMessageMatches('/'.preg_quote(NullSchemaToolHandler::class, '/').'/'); $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->addTool( - handler: new TraitOnlyRuntimeHandler(), + handler: new NullSchemaToolHandler(), name: 'demo', description: 'no schema source', )); @@ -127,7 +119,7 @@ public function testAddToolWithoutAnyInputSchemaRaisesConfigurationException(): public function testAddResourceRegistersRuntimeHandler(): void { - $handler = new TraitOnlyRuntimeHandler(); + $handler = new BareResourceHandler(); $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->addResource( handler: $handler, @@ -146,10 +138,10 @@ public function testAddResourceRegistersRuntimeHandler(): void public function testAddResourceWithoutNameRaisesConfigurationException(): void { $this->expectException(ConfigurationException::class); - $this->expectExceptionMessageMatches('/'.preg_quote(TraitOnlyRuntimeHandler::class, '/').'/'); + $this->expectExceptionMessageMatches('/'.preg_quote(BareResourceHandler::class, '/').'/'); $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->addResource( - handler: new TraitOnlyRuntimeHandler(), + handler: new BareResourceHandler(), uri: 'config://x', description: 'no name', )); @@ -177,10 +169,10 @@ public function testAddResourceTemplateRegistersRuntimeHandlerWithCompletionProv public function testAddResourceTemplateWithoutDescriptionRaisesConfigurationException(): void { $this->expectException(ConfigurationException::class); - $this->expectExceptionMessageMatches('/'.preg_quote(TraitOnlyRuntimeHandler::class, '/').'/'); + $this->expectExceptionMessageMatches('/'.preg_quote(ResourceTemplateRuntimeHandler::class, '/').'/'); $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->addResourceTemplate( - handler: new TraitOnlyRuntimeHandler(), + handler: new ResourceTemplateRuntimeHandler(), uriTemplate: 'user://{userId}', name: 'user', )); @@ -229,13 +221,24 @@ private function buildAndGetRegistry(callable $configure): \Mcp\Capability\Regis } } -final class TraitOnlyRuntimeHandler implements RunTimeHandlerInterface +final class BareResourceHandler implements RunTimeHandlerInterface { - use RunTimeHandlerTrait; + public function execute(array $arguments, ClientGateway $gateway): mixed + { + return null; + } +} - public function filterArguments(array $arguments): array +final class NullSchemaToolHandler implements RunTimeToolHandlerInterface +{ + public function getInputSchema(): ?array { - return []; + return null; + } + + public function getOutputSchema(): ?array + { + return null; } public function execute(array $arguments, ClientGateway $gateway): mixed @@ -244,18 +247,16 @@ public function execute(array $arguments, ClientGateway $gateway): mixed } } -final class SchemaToolHandler implements RunTimeHandlerInterface +final class SchemaToolHandler implements RunTimeToolHandlerInterface { - use RunTimeHandlerTrait; - public function getInputSchema(): array { return ['type' => 'object', 'properties' => ['x' => ['type' => 'string']]]; } - public function filterArguments(array $arguments): array + public function getOutputSchema(): ?array { - return $arguments; + return null; } public function execute(array $arguments, ClientGateway $gateway): mixed @@ -264,10 +265,8 @@ public function execute(array $arguments, ClientGateway $gateway): mixed } } -final class OutputSchemaToolHandler implements RunTimeHandlerInterface +final class OutputSchemaToolHandler implements RunTimeToolHandlerInterface { - use RunTimeHandlerTrait; - public function getInputSchema(): array { return ['type' => 'object']; @@ -278,41 +277,27 @@ public function getOutputSchema(): array return ['type' => 'object', 'properties' => ['from' => ['const' => 'handler']]]; } - public function filterArguments(array $arguments): array - { - return $arguments; - } - public function execute(array $arguments, ClientGateway $gateway): mixed { return ['from' => 'handler']; } } -final class ResourceTemplateRuntimeHandler implements RunTimeHandlerInterface +final class ResourceTemplateRuntimeHandler implements RunTimeResourceTemplateHandlerInterface { - use RunTimeHandlerTrait; - public function getCompletionProviders(): array { return ['userId' => new ListCompletionProvider(['alice', 'bob'])]; } - public function filterArguments(array $arguments): array - { - return $arguments; - } - public function execute(array $arguments, ClientGateway $gateway): mixed { return ['ok' => true]; } } -final class PromptRuntimeHandler implements RunTimeHandlerInterface +final class PromptRuntimeHandler implements RunTimePromptHandlerInterface { - use RunTimeHandlerTrait; - public function getPromptArguments(): array { return [new PromptArgument('q', 'The question', true)]; @@ -323,11 +308,6 @@ public function getCompletionProviders(): array return ['q' => new ListCompletionProvider(['hello', 'world'])]; } - public function filterArguments(array $arguments): array - { - return $arguments; - } - public function execute(array $arguments, ClientGateway $gateway): mixed { return []; diff --git a/tests/Unit/Capability/Registry/ReferenceHandlerTest.php b/tests/Unit/Capability/Registry/ReferenceHandlerTest.php index 6abd29af..c6f8cf80 100644 --- a/tests/Unit/Capability/Registry/ReferenceHandlerTest.php +++ b/tests/Unit/Capability/Registry/ReferenceHandlerTest.php @@ -16,7 +16,6 @@ use Mcp\Exception\InvalidArgumentException; use Mcp\Server\ClientGateway; use Mcp\Server\Handler\RunTimeHandlerInterface; -use Mcp\Server\Handler\RunTimeHandlerTrait; use Mcp\Server\Session\SessionInterface; use PHPUnit\Framework\TestCase; use Symfony\Component\Uid\Uuid; @@ -29,21 +28,10 @@ public function testHandleDispatchesToRunTimeHandlerAndForwardsClientGateway(): $session->method('getId')->willReturn(Uuid::v7()); $runtimeHandler = new class implements RunTimeHandlerInterface { - use RunTimeHandlerTrait; - - /** @var array|null */ - public ?array $filteredFrom = null; /** @var array|null */ public ?array $executedWith = null; public ?ClientGateway $receivedGateway = null; - public function filterArguments(array $arguments): array - { - $this->filteredFrom = $arguments; - - return ['kept' => $arguments['kept'] ?? null]; - } - public function execute(array $arguments, ClientGateway $gateway): mixed { $this->executedWith = $arguments; @@ -65,9 +53,8 @@ public function execute(array $arguments, ClientGateway $gateway): mixed $this->assertSame('runtime-result', $result); $this->assertSame( ['_session' => $session, 'kept' => 'value', 'dropped' => 'noise'], - $runtimeHandler->filteredFrom, + $runtimeHandler->executedWith, ); - $this->assertSame(['kept' => 'value'], $runtimeHandler->executedWith); $this->assertInstanceOf(ClientGateway::class, $runtimeHandler->receivedGateway); } @@ -77,8 +64,6 @@ public function testRunTimeHandlerTakesPriorityOverInvokeAndCallableDetection(): $session->method('getId')->willReturn(Uuid::v7()); $runtimeHandler = new class implements RunTimeHandlerInterface { - use RunTimeHandlerTrait; - public bool $executed = false; public function __invoke(): string @@ -86,11 +71,6 @@ public function __invoke(): string throw new \LogicException('__invoke must not be called when RunTimeHandlerInterface is implemented'); } - public function filterArguments(array $arguments): array - { - return []; - } - public function execute(array $arguments, ClientGateway $gateway): mixed { $this->executed = true; From d10719cb60c7f08e1fa393ecb285da117d4874a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateu=20Aguil=C3=B3=20Bosch?= Date: Wed, 29 Apr 2026 09:32:56 +0200 Subject: [PATCH 06/12] refactor(server): rename runtime handler interfaces to Runtime casing Renames RunTimeHandlerInterface and its element-specific subtypes to Runtime casing, adds a dedicated RuntimeResourceHandlerInterface so addResource() rejects mismatched runtime handlers at the type level, and strips _session and _request from arguments forwarded to runtime handlers. Hardens ArrayLoader to re-throw ConfigurationException without wrapping, and extracts inline test fixtures into tests/Fixtures/Runtime/. --- docs/server-builder.md | 20 ++-- src/Capability/Registry.php | 10 +- src/Capability/Registry/ElementReference.php | 6 +- .../Registry/Loader/ArrayLoader.php | 41 ++++--- src/Capability/Registry/PromptReference.php | 4 +- src/Capability/Registry/ReferenceHandler.php | 9 +- src/Capability/Registry/ResourceReference.php | 4 +- .../Registry/ResourceTemplateReference.php | 4 +- src/Capability/Registry/ToolReference.php | 4 +- src/Capability/RegistryInterface.php | 10 +- src/Server/Builder.php | 28 ++--- ...erface.php => RuntimeHandlerInterface.php} | 21 +++- ....php => RuntimePromptHandlerInterface.php} | 4 +- .../RuntimeResourceHandlerInterface.php | 25 ++++ ...ntimeResourceTemplateHandlerInterface.php} | 4 +- ...ce.php => RuntimeToolHandlerInterface.php} | 8 +- .../Fixtures/Runtime/BareResourceHandler.php | 23 ++++ .../Runtime/NullSchemaToolHandler.php | 33 ++++++ .../Runtime/OutputSchemaToolHandler.php | 33 ++++++ .../Fixtures/Runtime/PromptRuntimeHandler.php | 35 ++++++ .../ResourceTemplateRuntimeHandler.php | 29 +++++ tests/Fixtures/Runtime/SchemaToolHandler.php | 33 ++++++ ....php => ArrayLoaderRuntimeHandlerTest.php} | 107 ++---------------- .../Registry/ReferenceHandlerTest.php | 18 +-- 24 files changed, 333 insertions(+), 180 deletions(-) rename src/Server/Handler/{RunTimeHandlerInterface.php => RuntimeHandlerInterface.php} (55%) rename src/Server/Handler/{RunTimePromptHandlerInterface.php => RuntimePromptHandlerInterface.php} (87%) create mode 100644 src/Server/Handler/RuntimeResourceHandlerInterface.php rename src/Server/Handler/{RunTimeResourceTemplateHandlerInterface.php => RuntimeResourceTemplateHandlerInterface.php} (84%) rename src/Server/Handler/{RunTimeToolHandlerInterface.php => RuntimeToolHandlerInterface.php} (75%) create mode 100644 tests/Fixtures/Runtime/BareResourceHandler.php create mode 100644 tests/Fixtures/Runtime/NullSchemaToolHandler.php create mode 100644 tests/Fixtures/Runtime/OutputSchemaToolHandler.php create mode 100644 tests/Fixtures/Runtime/PromptRuntimeHandler.php create mode 100644 tests/Fixtures/Runtime/ResourceTemplateRuntimeHandler.php create mode 100644 tests/Fixtures/Runtime/SchemaToolHandler.php rename tests/Unit/Capability/Registry/Loader/{ArrayLoaderRunTimeHandlerTest.php => ArrayLoaderRuntimeHandlerTest.php} (76%) diff --git a/docs/server-builder.md b/docs/server-builder.md index 45a18647..8d6f5e9d 100644 --- a/docs/server-builder.md +++ b/docs/server-builder.md @@ -370,21 +370,21 @@ Builder. | Element kind | Interface | |------------------|------------------------------------------| -| Tool | `RunTimeToolHandlerInterface` | -| Prompt | `RunTimePromptHandlerInterface` | -| Resource template| `RunTimeResourceTemplateHandlerInterface`| -| Resource | `RunTimeHandlerInterface` | +| Tool | `RuntimeToolHandlerInterface` | +| Prompt | `RuntimePromptHandlerInterface` | +| Resource template| `RuntimeResourceTemplateHandlerInterface`| +| Resource | `RuntimeHandlerInterface` | Each interface declares only the metadata it needs (input/output schema for tools, prompt arguments and completion providers for prompts, completion providers for resource templates). All extend the base -`RunTimeHandlerInterface`, which requires only `execute()`. +`RuntimeHandlerInterface`, which requires only `execute()`. ```php use Mcp\Server\ClientGateway; -use Mcp\Server\Handler\RunTimeToolHandlerInterface; +use Mcp\Server\Handler\RuntimeToolHandlerInterface; -final class WeatherToolHandler implements RunTimeToolHandlerInterface +final class WeatherToolHandler implements RuntimeToolHandlerInterface { public function getInputSchema(): ?array { @@ -416,9 +416,9 @@ $server = Server::builder() ``` `name` and `description` are required when registering a runtime handler. -For tools, an input schema is also required (via the `inputSchema:` kwarg or -`getInputSchema()`). Missing values raise `ConfigurationException` at -registration time. +For tools, an input schema is also required (via the `inputSchema:` named +argument or `getInputSchema()`). Missing values raise `ConfigurationException` +at registration time. ## Service Dependencies diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index 4f1b9e7c..02a418a4 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -30,7 +30,7 @@ use Mcp\Schema\Resource; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\Tool; -use Mcp\Server\Handler\RunTimeHandlerInterface; +use Mcp\Server\Handler\RuntimeHandlerInterface; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -69,7 +69,7 @@ public function __construct( ) { } - public function registerTool(Tool $tool, callable|array|string|RunTimeHandlerInterface $handler, bool $isManual = false): void + public function registerTool(Tool $tool, callable|array|string|RuntimeHandlerInterface $handler, bool $isManual = false): void { $toolName = $tool->name; $existing = $this->tools[$toolName] ?? null; @@ -93,7 +93,7 @@ public function registerTool(Tool $tool, callable|array|string|RunTimeHandlerInt $this->eventDispatcher?->dispatch(new ToolListChangedEvent()); } - public function registerResource(Resource $resource, callable|array|string|RunTimeHandlerInterface $handler, bool $isManual = false): void + public function registerResource(Resource $resource, callable|array|string|RuntimeHandlerInterface $handler, bool $isManual = false): void { $uri = $resource->uri; $existing = $this->resources[$uri] ?? null; @@ -113,7 +113,7 @@ public function registerResource(Resource $resource, callable|array|string|RunTi public function registerResourceTemplate( ResourceTemplate $template, - callable|array|string|RunTimeHandlerInterface $handler, + callable|array|string|RuntimeHandlerInterface $handler, array $completionProviders = [], bool $isManual = false, ): void { @@ -140,7 +140,7 @@ public function registerResourceTemplate( public function registerPrompt( Prompt $prompt, - callable|array|string|RunTimeHandlerInterface $handler, + callable|array|string|RuntimeHandlerInterface $handler, array $completionProviders = [], bool $isManual = false, ): void { diff --git a/src/Capability/Registry/ElementReference.php b/src/Capability/Registry/ElementReference.php index 3c4f1e0e..9d5c0f90 100644 --- a/src/Capability/Registry/ElementReference.php +++ b/src/Capability/Registry/ElementReference.php @@ -11,10 +11,10 @@ namespace Mcp\Capability\Registry; -use Mcp\Server\Handler\RunTimeHandlerInterface; +use Mcp\Server\Handler\RuntimeHandlerInterface; /** - * @phpstan-type Handler \Closure|array{0: object|string, 1: string}|string|RunTimeHandlerInterface + * @phpstan-type Handler \Closure|array{0: object|string, 1: string}|string|RuntimeHandlerInterface * * @author Kyrian Obikwelu */ @@ -24,7 +24,7 @@ class ElementReference * @param Handler $handler */ public function __construct( - public readonly \Closure|array|string|RunTimeHandlerInterface $handler, + public readonly \Closure|array|string|RuntimeHandlerInterface $handler, public readonly bool $isManual = false, ) { } diff --git a/src/Capability/Registry/Loader/ArrayLoader.php b/src/Capability/Registry/Loader/ArrayLoader.php index c7c47312..ebb3def6 100644 --- a/src/Capability/Registry/Loader/ArrayLoader.php +++ b/src/Capability/Registry/Loader/ArrayLoader.php @@ -31,10 +31,11 @@ use Mcp\Schema\Tool; use Mcp\Schema\ToolAnnotations; use Mcp\Server\Handler; -use Mcp\Server\Handler\RunTimeHandlerInterface; -use Mcp\Server\Handler\RunTimePromptHandlerInterface; -use Mcp\Server\Handler\RunTimeResourceTemplateHandlerInterface; -use Mcp\Server\Handler\RunTimeToolHandlerInterface; +use Mcp\Server\Handler\RuntimeHandlerInterface; +use Mcp\Server\Handler\RuntimePromptHandlerInterface; +use Mcp\Server\Handler\RuntimeResourceHandlerInterface; +use Mcp\Server\Handler\RuntimeResourceTemplateHandlerInterface; +use Mcp\Server\Handler\RuntimeToolHandlerInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -103,7 +104,7 @@ public function load(RegistryInterface $registry): void // Register Tools foreach ($this->tools as $data) { try { - if ($data['handler'] instanceof RunTimeToolHandlerInterface) { + if ($data['handler'] instanceof RuntimeToolHandlerInterface) { if (null === $data['name']) { throw new ConfigurationException(\sprintf('Runtime tool handler %s is missing a name; the Builder requires an explicit name for runtime handlers.', $data['handler']::class)); } @@ -113,7 +114,7 @@ public function load(RegistryInterface $registry): void $inputSchema = $data['inputSchema'] ?? $data['handler']->getInputSchema(); if (null === $inputSchema) { - throw new ConfigurationException(\sprintf('Runtime tool handler %s did not provide an input schema (neither via the inputSchema kwarg nor via getInputSchema()).', $data['handler']::class)); + throw new ConfigurationException(\sprintf('Runtime tool handler %s did not provide an input schema (neither via the inputSchema named argument nor via getInputSchema()).', $data['handler']::class)); } $outputSchema = $data['outputSchema'] ?? $data['handler']->getOutputSchema(); @@ -167,14 +168,18 @@ public function load(RegistryInterface $registry): void 'Failed to register manual tool', ['handler' => $this->getHandlerDescription($data['handler']), 'name' => $data['name'], 'exception' => $e], ); - throw new ConfigurationException("Error registering manual tool '{$data['name']}': {$e->getMessage()}", 0, $e); + if ($e instanceof ConfigurationException) { + throw $e; + } + $nameForMessage = $data['name'] ?? ''; + throw new ConfigurationException("Error registering manual tool '{$nameForMessage}': {$e->getMessage()}", 0, $e); } } // Register Resources foreach ($this->resources as $data) { try { - if ($data['handler'] instanceof RunTimeHandlerInterface) { + if ($data['handler'] instanceof RuntimeResourceHandlerInterface) { if (null === $data['name']) { throw new ConfigurationException(\sprintf('Runtime resource handler %s is missing a name; the Builder requires an explicit name for runtime handlers.', $data['handler']::class)); } @@ -232,6 +237,9 @@ public function load(RegistryInterface $registry): void 'Failed to register manual resource', ['handler' => $this->getHandlerDescription($data['handler']), 'uri' => $data['uri'], 'exception' => $e], ); + if ($e instanceof ConfigurationException) { + throw $e; + } throw new ConfigurationException("Error registering manual resource '{$data['uri']}': {$e->getMessage()}", 0, $e); } } @@ -239,7 +247,7 @@ public function load(RegistryInterface $registry): void // Register Templates foreach ($this->resourceTemplates as $data) { try { - if ($data['handler'] instanceof RunTimeResourceTemplateHandlerInterface) { + if ($data['handler'] instanceof RuntimeResourceTemplateHandlerInterface) { if (null === $data['name']) { throw new ConfigurationException(\sprintf('Runtime resource template handler %s is missing a name; the Builder requires an explicit name for runtime handlers.', $data['handler']::class)); } @@ -295,6 +303,9 @@ public function load(RegistryInterface $registry): void 'Failed to register manual template', ['handler' => $this->getHandlerDescription($data['handler']), 'uriTemplate' => $data['uriTemplate'], 'exception' => $e], ); + if ($e instanceof ConfigurationException) { + throw $e; + } throw new ConfigurationException("Error registering manual resource template '{$data['uriTemplate']}': {$e->getMessage()}", 0, $e); } } @@ -302,7 +313,7 @@ public function load(RegistryInterface $registry): void // Register Prompts foreach ($this->prompts as $data) { try { - if ($data['handler'] instanceof RunTimePromptHandlerInterface) { + if ($data['handler'] instanceof RuntimePromptHandlerInterface) { if (null === $data['name']) { throw new ConfigurationException(\sprintf('Runtime prompt handler %s is missing a name; the Builder requires an explicit name for runtime handlers.', $data['handler']::class)); } @@ -379,7 +390,11 @@ public function load(RegistryInterface $registry): void 'Failed to register manual prompt', ['handler' => $this->getHandlerDescription($data['handler']), 'name' => $data['name'], 'exception' => $e], ); - throw new ConfigurationException("Error registering manual prompt '{$data['name']}': {$e->getMessage()}", 0, $e); + if ($e instanceof ConfigurationException) { + throw $e; + } + $nameForMessage = $data['name'] ?? ''; + throw new ConfigurationException("Error registering manual prompt '{$nameForMessage}': {$e->getMessage()}", 0, $e); } } @@ -389,13 +404,13 @@ public function load(RegistryInterface $registry): void /** * @param Handler $handler */ - private function getHandlerDescription(\Closure|array|string|RunTimeHandlerInterface $handler): string + private function getHandlerDescription(\Closure|array|string|RuntimeHandlerInterface $handler): string { if ($handler instanceof \Closure) { return 'Closure'; } - if ($handler instanceof RunTimeHandlerInterface) { + if ($handler instanceof RuntimeHandlerInterface) { return $handler::class; } diff --git a/src/Capability/Registry/PromptReference.php b/src/Capability/Registry/PromptReference.php index 6758e1c0..21fcf08f 100644 --- a/src/Capability/Registry/PromptReference.php +++ b/src/Capability/Registry/PromptReference.php @@ -14,7 +14,7 @@ use Mcp\Capability\Formatter\PromptResultFormatter; use Mcp\Schema\Content\PromptMessage; use Mcp\Schema\Prompt; -use Mcp\Server\Handler\RunTimeHandlerInterface; +use Mcp\Server\Handler\RuntimeHandlerInterface; /** * @phpstan-import-type Handler from ElementReference @@ -29,7 +29,7 @@ class PromptReference extends ElementReference */ public function __construct( public readonly Prompt $prompt, - \Closure|array|string|RunTimeHandlerInterface $handler, + \Closure|array|string|RuntimeHandlerInterface $handler, bool $isManual = false, public readonly array $completionProviders = [], ) { diff --git a/src/Capability/Registry/ReferenceHandler.php b/src/Capability/Registry/ReferenceHandler.php index bd837390..6ab7b6d2 100644 --- a/src/Capability/Registry/ReferenceHandler.php +++ b/src/Capability/Registry/ReferenceHandler.php @@ -14,7 +14,7 @@ use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\RegistryException; use Mcp\Server\ClientGateway; -use Mcp\Server\Handler\RunTimeHandlerInterface; +use Mcp\Server\Handler\RuntimeHandlerInterface; use Mcp\Server\RequestContext; use Mcp\Server\Session\SessionInterface; use Psr\Container\ContainerInterface; @@ -36,8 +36,11 @@ public function handle(ElementReference $reference, array $arguments): mixed { $session = $arguments['_session']; - if ($reference->handler instanceof RunTimeHandlerInterface) { - return $reference->handler->execute($arguments, new ClientGateway($session)); + if ($reference->handler instanceof RuntimeHandlerInterface) { + return $reference->handler->execute( + array_diff_key($arguments, array_flip(['_session', '_request'])), + new ClientGateway($session), + ); } if (\is_string($reference->handler)) { diff --git a/src/Capability/Registry/ResourceReference.php b/src/Capability/Registry/ResourceReference.php index 62a6fe15..e15c34a8 100644 --- a/src/Capability/Registry/ResourceReference.php +++ b/src/Capability/Registry/ResourceReference.php @@ -14,7 +14,7 @@ use Mcp\Capability\Formatter\ResourceResultFormatter; use Mcp\Schema\Content\ResourceContents; use Mcp\Schema\Resource; -use Mcp\Server\Handler\RunTimeHandlerInterface; +use Mcp\Server\Handler\RuntimeHandlerInterface; /** * @phpstan-import-type Handler from ElementReference @@ -28,7 +28,7 @@ class ResourceReference extends ElementReference */ public function __construct( public readonly Resource $resource, - callable|array|string|RunTimeHandlerInterface $handler, + callable|array|string|RuntimeHandlerInterface $handler, bool $isManual = false, ) { parent::__construct($handler, $isManual); diff --git a/src/Capability/Registry/ResourceTemplateReference.php b/src/Capability/Registry/ResourceTemplateReference.php index 8f5446ac..19997516 100644 --- a/src/Capability/Registry/ResourceTemplateReference.php +++ b/src/Capability/Registry/ResourceTemplateReference.php @@ -14,7 +14,7 @@ use Mcp\Capability\Formatter\ResourceResultFormatter; use Mcp\Schema\Content\ResourceContents; use Mcp\Schema\ResourceTemplate; -use Mcp\Server\Handler\RunTimeHandlerInterface; +use Mcp\Server\Handler\RuntimeHandlerInterface; /** * @phpstan-import-type Handler from ElementReference @@ -36,7 +36,7 @@ class ResourceTemplateReference extends ElementReference */ public function __construct( public readonly ResourceTemplate $resourceTemplate, - callable|array|string|RunTimeHandlerInterface $handler, + callable|array|string|RuntimeHandlerInterface $handler, bool $isManual = false, public readonly array $completionProviders = [], ) { diff --git a/src/Capability/Registry/ToolReference.php b/src/Capability/Registry/ToolReference.php index a0a09b6d..a0243130 100644 --- a/src/Capability/Registry/ToolReference.php +++ b/src/Capability/Registry/ToolReference.php @@ -14,7 +14,7 @@ use Mcp\Capability\Formatter\ToolResultFormatter; use Mcp\Schema\Content\Content; use Mcp\Schema\Tool; -use Mcp\Server\Handler\RunTimeHandlerInterface; +use Mcp\Server\Handler\RuntimeHandlerInterface; /** * @phpstan-import-type Handler from ElementReference @@ -28,7 +28,7 @@ class ToolReference extends ElementReference */ public function __construct( public readonly Tool $tool, - callable|array|string|RunTimeHandlerInterface $handler, + callable|array|string|RuntimeHandlerInterface $handler, bool $isManual = false, ) { parent::__construct($handler, $isManual); diff --git a/src/Capability/RegistryInterface.php b/src/Capability/RegistryInterface.php index f59add70..704887ea 100644 --- a/src/Capability/RegistryInterface.php +++ b/src/Capability/RegistryInterface.php @@ -25,7 +25,7 @@ use Mcp\Schema\Resource; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\Tool; -use Mcp\Server\Handler\RunTimeHandlerInterface; +use Mcp\Server\Handler\RuntimeHandlerInterface; /** * @phpstan-import-type Handler from ElementReference @@ -40,14 +40,14 @@ interface RegistryInterface * * @param Handler $handler */ - public function registerTool(Tool $tool, callable|array|string|RunTimeHandlerInterface $handler, bool $isManual = false): void; + public function registerTool(Tool $tool, callable|array|string|RuntimeHandlerInterface $handler, bool $isManual = false): void; /** * Registers a resource with its handler. * * @param Handler $handler */ - public function registerResource(Resource $resource, callable|array|string|RunTimeHandlerInterface $handler, bool $isManual = false): void; + public function registerResource(Resource $resource, callable|array|string|RuntimeHandlerInterface $handler, bool $isManual = false): void; /** * Registers a resource template with its handler and completion providers. @@ -57,7 +57,7 @@ public function registerResource(Resource $resource, callable|array|string|RunTi */ public function registerResourceTemplate( ResourceTemplate $template, - callable|array|string|RunTimeHandlerInterface $handler, + callable|array|string|RuntimeHandlerInterface $handler, array $completionProviders = [], bool $isManual = false, ): void; @@ -70,7 +70,7 @@ public function registerResourceTemplate( */ public function registerPrompt( Prompt $prompt, - callable|array|string|RunTimeHandlerInterface $handler, + callable|array|string|RuntimeHandlerInterface $handler, array $completionProviders = [], bool $isManual = false, ): void; diff --git a/src/Server/Builder.php b/src/Server/Builder.php index eaefcce1..99303070 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -35,10 +35,10 @@ use Mcp\Server; use Mcp\Server\Handler\Notification\NotificationHandlerInterface; use Mcp\Server\Handler\Request\RequestHandlerInterface; -use Mcp\Server\Handler\RunTimeHandlerInterface; -use Mcp\Server\Handler\RunTimePromptHandlerInterface; -use Mcp\Server\Handler\RunTimeResourceTemplateHandlerInterface; -use Mcp\Server\Handler\RunTimeToolHandlerInterface; +use Mcp\Server\Handler\RuntimePromptHandlerInterface; +use Mcp\Server\Handler\RuntimeResourceHandlerInterface; +use Mcp\Server\Handler\RuntimeResourceTemplateHandlerInterface; +use Mcp\Server\Handler\RuntimeToolHandlerInterface; use Mcp\Server\Resource\SessionSubscriptionManager; use Mcp\Server\Resource\SubscriptionManagerInterface; use Mcp\Server\Session\InMemorySessionStore; @@ -378,14 +378,14 @@ public function setProtocolVersion(ProtocolVersion $protocolVersion): self /** * Manually registers a tool handler. * - * @param callable|array{0: object|string, 1: string}|string|RunTimeToolHandlerInterface $handler + * @param callable|array{0: object|string, 1: string}|string|RuntimeToolHandlerInterface $handler * @param array|null $inputSchema * @param ?Icon[] $icons * @param array|null $meta * @param array|null $outputSchema */ public function addTool( - callable|array|string|RunTimeToolHandlerInterface $handler, + callable|array|string|RuntimeToolHandlerInterface $handler, ?string $name = null, ?string $description = null, ?ToolAnnotations $annotations = null, @@ -411,12 +411,12 @@ public function addTool( /** * Manually registers a resource handler. * - * @param \Closure|array{0: object|string, 1: string}|string|RunTimeHandlerInterface $handler - * @param ?Icon[] $icons - * @param array|null $meta + * @param \Closure|array{0: object|string, 1: string}|string|RuntimeResourceHandlerInterface $handler + * @param ?Icon[] $icons + * @param array|null $meta */ public function addResource( - \Closure|array|string|RunTimeHandlerInterface $handler, + \Closure|array|string|RuntimeResourceHandlerInterface $handler, string $uri, ?string $name = null, ?string $description = null, @@ -444,11 +444,11 @@ public function addResource( /** * Manually registers a resource template handler. * - * @param \Closure|array{0: object|string, 1: string}|string|RunTimeResourceTemplateHandlerInterface $handler + * @param \Closure|array{0: object|string, 1: string}|string|RuntimeResourceTemplateHandlerInterface $handler * @param array|null $meta */ public function addResourceTemplate( - \Closure|array|string|RunTimeResourceTemplateHandlerInterface $handler, + \Closure|array|string|RuntimeResourceTemplateHandlerInterface $handler, string $uriTemplate, ?string $name = null, ?string $description = null, @@ -472,12 +472,12 @@ public function addResourceTemplate( /** * Manually registers a prompt handler. * - * @param \Closure|array{0: object|string, 1: string}|string|RunTimePromptHandlerInterface $handler + * @param \Closure|array{0: object|string, 1: string}|string|RuntimePromptHandlerInterface $handler * @param ?Icon[] $icons * @param array|null $meta */ public function addPrompt( - \Closure|array|string|RunTimePromptHandlerInterface $handler, + \Closure|array|string|RuntimePromptHandlerInterface $handler, ?string $name = null, ?string $title = null, ?string $description = null, diff --git a/src/Server/Handler/RunTimeHandlerInterface.php b/src/Server/Handler/RuntimeHandlerInterface.php similarity index 55% rename from src/Server/Handler/RunTimeHandlerInterface.php rename to src/Server/Handler/RuntimeHandlerInterface.php index 37acdfc7..b85c98fb 100644 --- a/src/Server/Handler/RunTimeHandlerInterface.php +++ b/src/Server/Handler/RuntimeHandlerInterface.php @@ -21,13 +21,22 @@ * with the full argument map (including reserved keys such as `_session` and * `_request`) and a {@see ClientGateway} for client-side callbacks. * - * Element-specific subtypes ({@see RunTimeToolHandlerInterface}, - * {@see RunTimePromptHandlerInterface}, {@see RunTimeResourceTemplateHandlerInterface}) - * declare only the metadata accessors relevant to their element kind. - * Resources have no extra metadata and may implement this base interface - * directly. + * Element-specific subtypes ({@see RuntimeToolHandlerInterface}, + * {@see RuntimePromptHandlerInterface}, {@see RuntimeResourceTemplateHandlerInterface}, + * {@see RuntimeResourceHandlerInterface}) declare only the metadata accessors + * relevant to their element kind. Implementing the base interface alone is + * supported for backwards-compatibility but new handlers should pick the + * element-specific subtype that matches how they are registered. + * + * Note: arguments are forwarded to {@see self::execute()} as received from the + * JSON-RPC request, without the type casting performed for reflection-based + * handlers (string-to-int, string-to-bool, etc.). Runtime handlers are + * responsible for validating and casting their own inputs, typically against + * the schema they advertise. + * + * @author Mateu Aguiló Bosch */ -interface RunTimeHandlerInterface +interface RuntimeHandlerInterface { /** * Executes the handler and returns its result. diff --git a/src/Server/Handler/RunTimePromptHandlerInterface.php b/src/Server/Handler/RuntimePromptHandlerInterface.php similarity index 87% rename from src/Server/Handler/RunTimePromptHandlerInterface.php rename to src/Server/Handler/RuntimePromptHandlerInterface.php index 581bbec2..01fee276 100644 --- a/src/Server/Handler/RunTimePromptHandlerInterface.php +++ b/src/Server/Handler/RuntimePromptHandlerInterface.php @@ -13,8 +13,10 @@ /** * Runtime handler that backs an MCP prompt. + * + * @author Mateu Aguiló Bosch */ -interface RunTimePromptHandlerInterface extends RunTimeHandlerInterface +interface RuntimePromptHandlerInterface extends RuntimeHandlerInterface { /** * Returns the prompt arguments for this handler. diff --git a/src/Server/Handler/RuntimeResourceHandlerInterface.php b/src/Server/Handler/RuntimeResourceHandlerInterface.php new file mode 100644 index 00000000..564b371f --- /dev/null +++ b/src/Server/Handler/RuntimeResourceHandlerInterface.php @@ -0,0 +1,25 @@ + + */ +interface RuntimeResourceHandlerInterface extends RuntimeHandlerInterface +{ +} diff --git a/src/Server/Handler/RunTimeResourceTemplateHandlerInterface.php b/src/Server/Handler/RuntimeResourceTemplateHandlerInterface.php similarity index 84% rename from src/Server/Handler/RunTimeResourceTemplateHandlerInterface.php rename to src/Server/Handler/RuntimeResourceTemplateHandlerInterface.php index d1cee30d..9315f029 100644 --- a/src/Server/Handler/RunTimeResourceTemplateHandlerInterface.php +++ b/src/Server/Handler/RuntimeResourceTemplateHandlerInterface.php @@ -13,8 +13,10 @@ /** * Runtime handler that backs an MCP resource template. + * + * @author Mateu Aguiló Bosch */ -interface RunTimeResourceTemplateHandlerInterface extends RunTimeHandlerInterface +interface RuntimeResourceTemplateHandlerInterface extends RuntimeHandlerInterface { /** * Returns the completion providers for the URI template variables. diff --git a/src/Server/Handler/RunTimeToolHandlerInterface.php b/src/Server/Handler/RuntimeToolHandlerInterface.php similarity index 75% rename from src/Server/Handler/RunTimeToolHandlerInterface.php rename to src/Server/Handler/RuntimeToolHandlerInterface.php index cdeef762..268ca065 100644 --- a/src/Server/Handler/RunTimeToolHandlerInterface.php +++ b/src/Server/Handler/RuntimeToolHandlerInterface.php @@ -13,14 +13,16 @@ /** * Runtime handler that backs an MCP tool. + * + * @author Mateu Aguiló Bosch */ -interface RunTimeToolHandlerInterface extends RunTimeHandlerInterface +interface RuntimeToolHandlerInterface extends RuntimeHandlerInterface { /** * Returns the JSON Schema describing tool inputs. * * Returns null when the Builder caller supplies the schema via the - * `inputSchema:` keyword (the kwarg takes precedence). + * `inputSchema:` named argument (the named argument takes precedence). * * @return array|null */ @@ -30,7 +32,7 @@ public function getInputSchema(): ?array; * Returns the JSON Schema describing tool outputs. * * Returns null when no output schema applies, or when the Builder caller - * supplies the schema via the `outputSchema:` keyword. + * supplies the schema via the `outputSchema:` named argument. * * @return array|null */ diff --git a/tests/Fixtures/Runtime/BareResourceHandler.php b/tests/Fixtures/Runtime/BareResourceHandler.php new file mode 100644 index 00000000..4cf3e5a2 --- /dev/null +++ b/tests/Fixtures/Runtime/BareResourceHandler.php @@ -0,0 +1,23 @@ + 'object']; + } + + public function getOutputSchema(): array + { + return ['type' => 'object', 'properties' => ['from' => ['const' => 'handler']]]; + } + + public function execute(array $arguments, ClientGateway $gateway): mixed + { + return ['from' => 'handler']; + } +} diff --git a/tests/Fixtures/Runtime/PromptRuntimeHandler.php b/tests/Fixtures/Runtime/PromptRuntimeHandler.php new file mode 100644 index 00000000..3a562450 --- /dev/null +++ b/tests/Fixtures/Runtime/PromptRuntimeHandler.php @@ -0,0 +1,35 @@ + new ListCompletionProvider(['hello', 'world'])]; + } + + public function execute(array $arguments, ClientGateway $gateway): mixed + { + return []; + } +} diff --git a/tests/Fixtures/Runtime/ResourceTemplateRuntimeHandler.php b/tests/Fixtures/Runtime/ResourceTemplateRuntimeHandler.php new file mode 100644 index 00000000..9c019db5 --- /dev/null +++ b/tests/Fixtures/Runtime/ResourceTemplateRuntimeHandler.php @@ -0,0 +1,29 @@ + new ListCompletionProvider(['alice', 'bob'])]; + } + + public function execute(array $arguments, ClientGateway $gateway): mixed + { + return ['ok' => true]; + } +} diff --git a/tests/Fixtures/Runtime/SchemaToolHandler.php b/tests/Fixtures/Runtime/SchemaToolHandler.php new file mode 100644 index 00000000..80e3d4f5 --- /dev/null +++ b/tests/Fixtures/Runtime/SchemaToolHandler.php @@ -0,0 +1,33 @@ + 'object', 'properties' => ['x' => ['type' => 'string']]]; + } + + public function getOutputSchema(): ?array + { + return null; + } + + public function execute(array $arguments, ClientGateway $gateway): mixed + { + return ['ok' => true]; + } +} diff --git a/tests/Unit/Capability/Registry/Loader/ArrayLoaderRunTimeHandlerTest.php b/tests/Unit/Capability/Registry/Loader/ArrayLoaderRuntimeHandlerTest.php similarity index 76% rename from tests/Unit/Capability/Registry/Loader/ArrayLoaderRunTimeHandlerTest.php rename to tests/Unit/Capability/Registry/Loader/ArrayLoaderRuntimeHandlerTest.php index f8f459a9..7e5ef6c2 100644 --- a/tests/Unit/Capability/Registry/Loader/ArrayLoaderRunTimeHandlerTest.php +++ b/tests/Unit/Capability/Registry/Loader/ArrayLoaderRuntimeHandlerTest.php @@ -13,16 +13,16 @@ use Mcp\Capability\Completion\ListCompletionProvider; use Mcp\Exception\ConfigurationException; -use Mcp\Schema\PromptArgument; use Mcp\Server; -use Mcp\Server\ClientGateway; -use Mcp\Server\Handler\RunTimeHandlerInterface; -use Mcp\Server\Handler\RunTimePromptHandlerInterface; -use Mcp\Server\Handler\RunTimeResourceTemplateHandlerInterface; -use Mcp\Server\Handler\RunTimeToolHandlerInterface; +use Mcp\Tests\Fixtures\Runtime\BareResourceHandler; +use Mcp\Tests\Fixtures\Runtime\NullSchemaToolHandler; +use Mcp\Tests\Fixtures\Runtime\OutputSchemaToolHandler; +use Mcp\Tests\Fixtures\Runtime\PromptRuntimeHandler; +use Mcp\Tests\Fixtures\Runtime\ResourceTemplateRuntimeHandler; +use Mcp\Tests\Fixtures\Runtime\SchemaToolHandler; use PHPUnit\Framework\TestCase; -final class ArrayLoaderRunTimeHandlerTest extends TestCase +final class ArrayLoaderRuntimeHandlerTest extends TestCase { public function testAddToolUsesInputSchemaFromHandlerWhenNoKwarg(): void { @@ -220,96 +220,3 @@ private function buildAndGetRegistry(callable $configure): \Mcp\Capability\Regis return $registry; } } - -final class BareResourceHandler implements RunTimeHandlerInterface -{ - public function execute(array $arguments, ClientGateway $gateway): mixed - { - return null; - } -} - -final class NullSchemaToolHandler implements RunTimeToolHandlerInterface -{ - public function getInputSchema(): ?array - { - return null; - } - - public function getOutputSchema(): ?array - { - return null; - } - - public function execute(array $arguments, ClientGateway $gateway): mixed - { - return null; - } -} - -final class SchemaToolHandler implements RunTimeToolHandlerInterface -{ - public function getInputSchema(): array - { - return ['type' => 'object', 'properties' => ['x' => ['type' => 'string']]]; - } - - public function getOutputSchema(): ?array - { - return null; - } - - public function execute(array $arguments, ClientGateway $gateway): mixed - { - return ['ok' => true]; - } -} - -final class OutputSchemaToolHandler implements RunTimeToolHandlerInterface -{ - public function getInputSchema(): array - { - return ['type' => 'object']; - } - - public function getOutputSchema(): array - { - return ['type' => 'object', 'properties' => ['from' => ['const' => 'handler']]]; - } - - public function execute(array $arguments, ClientGateway $gateway): mixed - { - return ['from' => 'handler']; - } -} - -final class ResourceTemplateRuntimeHandler implements RunTimeResourceTemplateHandlerInterface -{ - public function getCompletionProviders(): array - { - return ['userId' => new ListCompletionProvider(['alice', 'bob'])]; - } - - public function execute(array $arguments, ClientGateway $gateway): mixed - { - return ['ok' => true]; - } -} - -final class PromptRuntimeHandler implements RunTimePromptHandlerInterface -{ - public function getPromptArguments(): array - { - return [new PromptArgument('q', 'The question', true)]; - } - - public function getCompletionProviders(): array - { - return ['q' => new ListCompletionProvider(['hello', 'world'])]; - } - - public function execute(array $arguments, ClientGateway $gateway): mixed - { - return []; - } -} diff --git a/tests/Unit/Capability/Registry/ReferenceHandlerTest.php b/tests/Unit/Capability/Registry/ReferenceHandlerTest.php index c6f8cf80..56ccb44b 100644 --- a/tests/Unit/Capability/Registry/ReferenceHandlerTest.php +++ b/tests/Unit/Capability/Registry/ReferenceHandlerTest.php @@ -15,19 +15,19 @@ use Mcp\Capability\Registry\ReferenceHandler; use Mcp\Exception\InvalidArgumentException; use Mcp\Server\ClientGateway; -use Mcp\Server\Handler\RunTimeHandlerInterface; +use Mcp\Server\Handler\RuntimeHandlerInterface; use Mcp\Server\Session\SessionInterface; use PHPUnit\Framework\TestCase; use Symfony\Component\Uid\Uuid; final class ReferenceHandlerTest extends TestCase { - public function testHandleDispatchesToRunTimeHandlerAndForwardsClientGateway(): void + public function testHandleDispatchesToRuntimeHandlerAndForwardsClientGateway(): void { $session = $this->createMock(SessionInterface::class); $session->method('getId')->willReturn(Uuid::v7()); - $runtimeHandler = new class implements RunTimeHandlerInterface { + $runtimeHandler = new class implements RuntimeHandlerInterface { /** @var array|null */ public ?array $executedWith = null; public ?ClientGateway $receivedGateway = null; @@ -44,31 +44,33 @@ public function execute(array $arguments, ClientGateway $gateway): mixed $reference = new ElementReference($runtimeHandler, true); $referenceHandler = new ReferenceHandler(); + $request = new \stdClass(); $result = $referenceHandler->handle($reference, [ '_session' => $session, + '_request' => $request, 'kept' => 'value', - 'dropped' => 'noise', + 'other' => 'value2', ]); $this->assertSame('runtime-result', $result); $this->assertSame( - ['_session' => $session, 'kept' => 'value', 'dropped' => 'noise'], + ['kept' => 'value', 'other' => 'value2'], $runtimeHandler->executedWith, ); $this->assertInstanceOf(ClientGateway::class, $runtimeHandler->receivedGateway); } - public function testRunTimeHandlerTakesPriorityOverInvokeAndCallableDetection(): void + public function testRuntimeHandlerTakesPriorityOverInvokeAndCallableDetection(): void { $session = $this->createMock(SessionInterface::class); $session->method('getId')->willReturn(Uuid::v7()); - $runtimeHandler = new class implements RunTimeHandlerInterface { + $runtimeHandler = new class implements RuntimeHandlerInterface { public bool $executed = false; public function __invoke(): string { - throw new \LogicException('__invoke must not be called when RunTimeHandlerInterface is implemented'); + throw new \LogicException('__invoke must not be called when RuntimeHandlerInterface is implemented'); } public function execute(array $arguments, ClientGateway $gateway): mixed From 6488049b6798ef19c6f168d425127c5f99768919 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateu=20Aguil=C3=B3=20Bosch?= Date: Wed, 29 Apr 2026 10:04:46 +0200 Subject: [PATCH 07/12] chore: please the linting gods --- src/Capability/Registry/Loader/ArrayLoader.php | 1 + src/Server/Builder.php | 12 ++++++------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/Capability/Registry/Loader/ArrayLoader.php b/src/Capability/Registry/Loader/ArrayLoader.php index e47b32cc..37696bed 100644 --- a/src/Capability/Registry/Loader/ArrayLoader.php +++ b/src/Capability/Registry/Loader/ArrayLoader.php @@ -121,6 +121,7 @@ public function load(RegistryInterface $registry): void $tool = new Tool( name: $data['name'], + title: $data['title'] ?? null, inputSchema: $inputSchema, description: $data['description'], annotations: $data['annotations'] ?? null, diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 3ede7c12..10fe73a4 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -379,12 +379,12 @@ public function setProtocolVersion(ProtocolVersion $protocolVersion): self /** * Manually registers a tool handler. * - * @param Handler $handler - * @param ?string $title Optional human-readable title for display in UI - * @param array|null $inputSchema - * @param ?Icon[] $icons - * @param array|null $meta - * @param array|null $outputSchema + * @param \Closure|array{0: object|string, 1: string}|string|RuntimeToolHandlerInterface $handler + * @param ?string $title Optional human-readable title for display in UI + * @param array|null $inputSchema + * @param ?Icon[] $icons + * @param array|null $meta + * @param array|null $outputSchema */ public function addTool( callable|array|string|RuntimeToolHandlerInterface $handler, From 866b85bff28fcb90c2ec2ec944f617f2c2b2cbe1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateu=20Aguil=C3=B3=20Bosch?= Date: Wed, 29 Apr 2026 10:10:41 +0200 Subject: [PATCH 08/12] test(server): use Uuid::v4 for symfony/uid 5.4 compatibility Uuid::v7() was added in symfony/uid 6.2 and breaks the Symfony 5.4 matrix job. Match the convention used elsewhere in the suite. --- tests/Unit/Capability/Registry/ReferenceHandlerTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Unit/Capability/Registry/ReferenceHandlerTest.php b/tests/Unit/Capability/Registry/ReferenceHandlerTest.php index 56ccb44b..6ef5071c 100644 --- a/tests/Unit/Capability/Registry/ReferenceHandlerTest.php +++ b/tests/Unit/Capability/Registry/ReferenceHandlerTest.php @@ -25,7 +25,7 @@ final class ReferenceHandlerTest extends TestCase public function testHandleDispatchesToRuntimeHandlerAndForwardsClientGateway(): void { $session = $this->createMock(SessionInterface::class); - $session->method('getId')->willReturn(Uuid::v7()); + $session->method('getId')->willReturn(Uuid::v4()); $runtimeHandler = new class implements RuntimeHandlerInterface { /** @var array|null */ @@ -63,7 +63,7 @@ public function execute(array $arguments, ClientGateway $gateway): mixed public function testRuntimeHandlerTakesPriorityOverInvokeAndCallableDetection(): void { $session = $this->createMock(SessionInterface::class); - $session->method('getId')->willReturn(Uuid::v7()); + $session->method('getId')->willReturn(Uuid::v4()); $runtimeHandler = new class implements RuntimeHandlerInterface { public bool $executed = false; From 5c0524066bfe708c88fd5ae11448f9fa6329a4b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateu=20Aguil=C3=B3=20Bosch?= Date: Thu, 30 Apr 2026 08:01:52 +0200 Subject: [PATCH 09/12] refactor(server): split ArrayLoader registration Extract per-element preparation into dedicated private prepare* methods for tools, resources, templates, and prompts. Factor the shared required-field assertion and reflected name/description resolution into helpers. --- .../Registry/Loader/ArrayLoader.php | 446 ++++++++++-------- 1 file changed, 247 insertions(+), 199 deletions(-) diff --git a/src/Capability/Registry/Loader/ArrayLoader.php b/src/Capability/Registry/Loader/ArrayLoader.php index 37696bed..1e33a73f 100644 --- a/src/Capability/Registry/Loader/ArrayLoader.php +++ b/src/Capability/Registry/Loader/ArrayLoader.php @@ -105,67 +105,25 @@ public function load(RegistryInterface $registry): void // Register Tools foreach ($this->tools as $data) { try { - if ($data['handler'] instanceof RuntimeToolHandlerInterface) { - if (null === $data['name']) { - throw new ConfigurationException(\sprintf('Runtime tool handler %s is missing a name; the Builder requires an explicit name for runtime handlers.', $data['handler']::class)); - } - if (null === $data['description']) { - throw new ConfigurationException(\sprintf('Runtime tool handler %s is missing a description; the Builder requires an explicit description for runtime handlers.', $data['handler']::class)); - } - - $inputSchema = $data['inputSchema'] ?? $data['handler']->getInputSchema(); - if (null === $inputSchema) { - throw new ConfigurationException(\sprintf('Runtime tool handler %s did not provide an input schema (neither via the inputSchema named argument nor via getInputSchema()).', $data['handler']::class)); - } - $outputSchema = $data['outputSchema'] ?? $data['handler']->getOutputSchema(); - - $tool = new Tool( - name: $data['name'], - title: $data['title'] ?? null, - inputSchema: $inputSchema, - description: $data['description'], - annotations: $data['annotations'] ?? null, - icons: $data['icons'] ?? null, - meta: $data['meta'] ?? null, - outputSchema: $outputSchema, - ); - $registry->registerTool($tool, $data['handler'], true); - - $handlerDesc = $this->getHandlerDescription($data['handler']); - $this->logger->debug("Registered manual runtime tool {$data['name']} from handler {$handlerDesc}"); - continue; - } - - $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'] ?? ('__invoke' === $methodName ? $classShortName : $methodName); - $description = $data['description'] ?? $docBlockParser->getDescription($docBlock) ?? null; - } - - $inputSchema = $data['inputSchema'] ?? $schemaGenerator->generate($reflection); + $handler = $data['handler']; + $prepared = $handler instanceof RuntimeToolHandlerInterface + ? $this->prepareRuntimeTool($data, $handler) + : $this->prepareReflectedTool($data, $handler, $schemaGenerator, $docBlockParser); $tool = new Tool( - name: $name, + name: $prepared['name'], title: $data['title'] ?? null, - inputSchema: $inputSchema, - description: $description, + inputSchema: $prepared['inputSchema'], + description: $prepared['description'], annotations: $data['annotations'] ?? null, icons: $data['icons'] ?? null, meta: $data['meta'] ?? null, - outputSchema: $data['outputSchema'] ?? null, + outputSchema: $prepared['outputSchema'], ); $registry->registerTool($tool, $data['handler'], true); $handlerDesc = $this->getHandlerDescription($data['handler']); - $this->logger->debug("Registered manual tool {$name} from handler {$handlerDesc}"); + $this->logger->debug("Registered manual {$prepared['kind']} {$prepared['name']} from handler {$handlerDesc}"); } catch (\Throwable $e) { $this->logger->error( 'Failed to register manual tool', @@ -182,49 +140,15 @@ public function load(RegistryInterface $registry): void // Register Resources foreach ($this->resources as $data) { try { - if ($data['handler'] instanceof RuntimeResourceHandlerInterface) { - if (null === $data['name']) { - throw new ConfigurationException(\sprintf('Runtime resource handler %s is missing a name; the Builder requires an explicit name for runtime handlers.', $data['handler']::class)); - } - if (null === $data['description']) { - throw new ConfigurationException(\sprintf('Runtime resource handler %s is missing a description; the Builder requires an explicit description for runtime handlers.', $data['handler']::class)); - } - - $resource = new Resource( - uri: $data['uri'], - name: $data['name'], - description: $data['description'], - mimeType: $data['mimeType'] ?? null, - annotations: $data['annotations'] ?? null, - size: $data['size'] ?? null, - icons: $data['icons'] ?? null, - meta: $data['meta'] ?? null, - ); - $registry->registerResource($resource, $data['handler'], true); - - $handlerDesc = $this->getHandlerDescription($data['handler']); - $this->logger->debug("Registered manual runtime resource {$data['name']} from handler {$handlerDesc}"); - continue; - } - - $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'] ?? ('__invoke' === $methodName ? $classShortName : $methodName); - $description = $data['description'] ?? $docBlockParser->getDescription($docBlock) ?? null; - } + $handler = $data['handler']; + $prepared = $handler instanceof RuntimeResourceHandlerInterface + ? $this->prepareRuntimeResource($data, $handler) + : $this->prepareReflectedResource($data, $handler, $docBlockParser); $resource = new Resource( uri: $data['uri'], - name: $name, - description: $description, + name: $prepared['name'], + description: $prepared['description'], mimeType: $data['mimeType'] ?? null, annotations: $data['annotations'] ?? null, size: $data['size'] ?? null, @@ -234,7 +158,7 @@ public function load(RegistryInterface $registry): void $registry->registerResource($resource, $data['handler'], true); $handlerDesc = $this->getHandlerDescription($data['handler']); - $this->logger->debug("Registered manual resource {$name} from handler {$handlerDesc}"); + $this->logger->debug("Registered manual {$prepared['kind']} {$prepared['name']} from handler {$handlerDesc}"); } catch (\Throwable $e) { $this->logger->error( 'Failed to register manual resource', @@ -250,57 +174,23 @@ public function load(RegistryInterface $registry): void // Register Templates foreach ($this->resourceTemplates as $data) { try { - if ($data['handler'] instanceof RuntimeResourceTemplateHandlerInterface) { - if (null === $data['name']) { - throw new ConfigurationException(\sprintf('Runtime resource template handler %s is missing a name; the Builder requires an explicit name for runtime handlers.', $data['handler']::class)); - } - if (null === $data['description']) { - throw new ConfigurationException(\sprintf('Runtime resource template handler %s is missing a description; the Builder requires an explicit description for runtime handlers.', $data['handler']::class)); - } - - $template = new ResourceTemplate( - uriTemplate: $data['uriTemplate'], - name: $data['name'], - description: $data['description'], - mimeType: $data['mimeType'] ?? null, - annotations: $data['annotations'] ?? null, - meta: $data['meta'] ?? null, - ); - $completionProviders = $data['handler']->getCompletionProviders() ?? []; - $registry->registerResourceTemplate($template, $data['handler'], $completionProviders, true); - - $handlerDesc = $this->getHandlerDescription($data['handler']); - $this->logger->debug("Registered manual runtime template {$data['name']} from handler {$handlerDesc}"); - continue; - } - - $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'] ?? ('__invoke' === $methodName ? $classShortName : $methodName); - $description = $data['description'] ?? $docBlockParser->getDescription($docBlock) ?? null; - } + $handler = $data['handler']; + $prepared = $handler instanceof RuntimeResourceTemplateHandlerInterface + ? $this->prepareRuntimeResourceTemplate($data, $handler) + : $this->prepareReflectedResourceTemplate($data, $handler, $docBlockParser); $template = new ResourceTemplate( uriTemplate: $data['uriTemplate'], - name: $name, - description: $description, + name: $prepared['name'], + description: $prepared['description'], mimeType: $data['mimeType'] ?? null, annotations: $data['annotations'] ?? null, meta: $data['meta'] ?? null, ); - $completionProviders = $this->getCompletionProviders($reflection); - $registry->registerResourceTemplate($template, $data['handler'], $completionProviders, true); + $registry->registerResourceTemplate($template, $data['handler'], $prepared['completionProviders'], true); $handlerDesc = $this->getHandlerDescription($data['handler']); - $this->logger->debug("Registered manual template {$name} from handler {$handlerDesc}"); + $this->logger->debug("Registered manual {$prepared['kind']} {$prepared['name']} from handler {$handlerDesc}"); } catch (\Throwable $e) { $this->logger->error( 'Failed to register manual template', @@ -316,78 +206,23 @@ public function load(RegistryInterface $registry): void // Register Prompts foreach ($this->prompts as $data) { try { - if ($data['handler'] instanceof RuntimePromptHandlerInterface) { - if (null === $data['name']) { - throw new ConfigurationException(\sprintf('Runtime prompt handler %s is missing a name; the Builder requires an explicit name for runtime handlers.', $data['handler']::class)); - } - if (null === $data['description']) { - throw new ConfigurationException(\sprintf('Runtime prompt handler %s is missing a description; the Builder requires an explicit description for runtime handlers.', $data['handler']::class)); - } - - $arguments = $data['handler']->getPromptArguments() ?? []; - $completionProviders = $data['handler']->getCompletionProviders() ?? []; - - $prompt = new Prompt( - name: $data['name'], - title: $data['title'] ?? null, - description: $data['description'], - arguments: $arguments, - icons: $data['icons'] ?? null, - meta: $data['meta'] ?? null - ); - $registry->registerPrompt($prompt, $data['handler'], $completionProviders, true); - - $handlerDesc = $this->getHandlerDescription($data['handler']); - $this->logger->debug("Registered manual runtime prompt {$data['name']} from handler {$handlerDesc}"); - continue; - } - - $reflection = HandlerResolver::resolve($data['handler']); + $handler = $data['handler']; + $prepared = $handler instanceof RuntimePromptHandlerInterface + ? $this->prepareRuntimePrompt($data, $handler) + : $this->prepareReflectedPrompt($data, $handler, $docBlockParser); - 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'] ?? ('__invoke' === $methodName ? $classShortName : $methodName); - $description = $data['description'] ?? $docBlockParser->getDescription($docBlock) ?? null; - } - - $arguments = []; - $paramTags = $reflection instanceof \ReflectionMethod ? $docBlockParser->getParamTags( - $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null), - ) : []; - foreach ($reflection->getParameters() as $param) { - $reflectionType = $param->getType(); - - // Basic DI check (heuristic) - if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) { - continue; - } - - $paramTag = $paramTags['$'.$param->getName()] ?? null; - $arguments[] = new PromptArgument( - $param->getName(), - $paramTag ? trim((string) $paramTag->getDescription()) : null, - !$param->isOptional() && !$param->isDefaultValueAvailable(), - ); - } $prompt = new Prompt( - name: $name, + name: $prepared['name'], title: $data['title'] ?? null, - description: $description, - arguments: $arguments, + description: $prepared['description'], + arguments: $prepared['arguments'], icons: $data['icons'] ?? null, meta: $data['meta'] ?? null ); - $completionProviders = $this->getCompletionProviders($reflection); - $registry->registerPrompt($prompt, $data['handler'], $completionProviders, true); + $registry->registerPrompt($prompt, $data['handler'], $prepared['completionProviders'], true); $handlerDesc = $this->getHandlerDescription($data['handler']); - $this->logger->debug("Registered manual prompt {$name} from handler {$handlerDesc}"); + $this->logger->debug("Registered manual {$prepared['kind']} {$prepared['name']} from handler {$handlerDesc}"); } catch (\Throwable $e) { $this->logger->error( 'Failed to register manual prompt', @@ -404,6 +239,219 @@ public function load(RegistryInterface $registry): void $this->logger->debug('Manual element registration complete.'); } + /** + * @param array $data + * + * @return array{name: string, description: string, inputSchema: array, outputSchema: ?array, kind: string} + */ + private function prepareRuntimeTool(array $data, RuntimeToolHandlerInterface $handler): array + { + $this->assertRuntimeRequiredFields($data, $handler, 'tool'); + + $inputSchema = $data['inputSchema'] ?? $handler->getInputSchema(); + if (null === $inputSchema) { + throw new ConfigurationException(\sprintf('Runtime tool handler %s did not provide an input schema (neither via the inputSchema named argument nor via getInputSchema()).', $handler::class)); + } + + return [ + 'name' => $data['name'], + 'description' => $data['description'], + 'inputSchema' => $inputSchema, + 'outputSchema' => $data['outputSchema'] ?? $handler->getOutputSchema(), + 'kind' => 'runtime tool', + ]; + } + + /** + * @param array $data + * @param \Closure|array{0: object|string, 1: string}|string $handler + * + * @return array{name: string, description: ?string, inputSchema: array, outputSchema: ?array, kind: string} + */ + private function prepareReflectedTool(array $data, \Closure|array|string $handler, SchemaGeneratorInterface $schemaGenerator, DocBlockParser $docBlockParser): array + { + $reflection = HandlerResolver::resolve($handler); + $meta = $this->resolveReflectedNameAndDescription($data, $handler, $reflection, $docBlockParser, 'closure_tool_'); + + return [ + 'name' => $meta['name'], + 'description' => $meta['description'], + 'inputSchema' => $data['inputSchema'] ?? $schemaGenerator->generate($reflection), + 'outputSchema' => $data['outputSchema'] ?? null, + 'kind' => 'tool', + ]; + } + + /** + * @param array $data + * + * @return array{name: string, description: string, kind: string} + */ + private function prepareRuntimeResource(array $data, RuntimeResourceHandlerInterface $handler): array + { + $this->assertRuntimeRequiredFields($data, $handler, 'resource'); + + return [ + 'name' => $data['name'], + 'description' => $data['description'], + 'kind' => 'runtime resource', + ]; + } + + /** + * @param array $data + * @param \Closure|array{0: object|string, 1: string}|string $handler + * + * @return array{name: string, description: ?string, kind: string} + */ + private function prepareReflectedResource(array $data, \Closure|array|string $handler, DocBlockParser $docBlockParser): array + { + $reflection = HandlerResolver::resolve($handler); + $meta = $this->resolveReflectedNameAndDescription($data, $handler, $reflection, $docBlockParser, 'closure_resource_'); + + return [ + 'name' => $meta['name'], + 'description' => $meta['description'], + 'kind' => 'resource', + ]; + } + + /** + * @param array $data + * + * @return array{name: string, description: string, completionProviders: array, kind: string} + */ + private function prepareRuntimeResourceTemplate(array $data, RuntimeResourceTemplateHandlerInterface $handler): array + { + $this->assertRuntimeRequiredFields($data, $handler, 'resource template'); + + return [ + 'name' => $data['name'], + 'description' => $data['description'], + 'completionProviders' => $handler->getCompletionProviders() ?? [], + 'kind' => 'runtime template', + ]; + } + + /** + * @param array $data + * @param \Closure|array{0: object|string, 1: string}|string $handler + * + * @return array{name: string, description: ?string, completionProviders: array, kind: string} + */ + private function prepareReflectedResourceTemplate(array $data, \Closure|array|string $handler, DocBlockParser $docBlockParser): array + { + $reflection = HandlerResolver::resolve($handler); + $meta = $this->resolveReflectedNameAndDescription($data, $handler, $reflection, $docBlockParser, 'closure_template_'); + + return [ + 'name' => $meta['name'], + 'description' => $meta['description'], + 'completionProviders' => $this->getCompletionProviders($reflection), + 'kind' => 'template', + ]; + } + + /** + * @param array $data + * + * @return array{name: string, description: string, arguments: PromptArgument[], completionProviders: array, kind: string} + */ + private function prepareRuntimePrompt(array $data, RuntimePromptHandlerInterface $handler): array + { + $this->assertRuntimeRequiredFields($data, $handler, 'prompt'); + + return [ + 'name' => $data['name'], + 'description' => $data['description'], + 'arguments' => $handler->getPromptArguments() ?? [], + 'completionProviders' => $handler->getCompletionProviders() ?? [], + 'kind' => 'runtime prompt', + ]; + } + + /** + * @param array $data + * @param \Closure|array{0: object|string, 1: string}|string $handler + * + * @return array{name: string, description: ?string, arguments: PromptArgument[], completionProviders: array, kind: string} + */ + private function prepareReflectedPrompt(array $data, \Closure|array|string $handler, DocBlockParser $docBlockParser): array + { + $reflection = HandlerResolver::resolve($handler); + $meta = $this->resolveReflectedNameAndDescription($data, $handler, $reflection, $docBlockParser, 'closure_prompt_'); + + $arguments = []; + $paramTags = $reflection instanceof \ReflectionMethod ? $docBlockParser->getParamTags( + $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null), + ) : []; + foreach ($reflection->getParameters() as $param) { + $reflectionType = $param->getType(); + + // Basic DI check (heuristic) + if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) { + continue; + } + + $paramTag = $paramTags['$'.$param->getName()] ?? null; + $arguments[] = new PromptArgument( + $param->getName(), + $paramTag ? trim((string) $paramTag->getDescription()) : null, + !$param->isOptional() && !$param->isDefaultValueAvailable(), + ); + } + + return [ + 'name' => $meta['name'], + 'description' => $meta['description'], + 'arguments' => $arguments, + 'completionProviders' => $this->getCompletionProviders($reflection), + 'kind' => 'prompt', + ]; + } + + /** + * @param array $data + */ + private function assertRuntimeRequiredFields(array $data, RuntimeHandlerInterface $handler, string $kindLabel): void + { + foreach (['name', 'description'] as $field) { + if (null === $data[$field]) { + throw new ConfigurationException(\sprintf('Runtime %s handler %s is missing a %s; the Builder requires an explicit %s for runtime handlers.', $kindLabel, $handler::class, $field, $field)); + } + } + } + + /** + * @param array $data + * @param \Closure|array{0: object|string, 1: string}|string $handler + * + * @return array{name: string, description: ?string} + */ + private function resolveReflectedNameAndDescription( + array $data, + \Closure|array|string $handler, + \ReflectionFunction|\ReflectionMethod $reflection, + DocBlockParser $docBlockParser, + string $closurePrefix, + ): array { + if ($reflection instanceof \ReflectionFunction) { + return [ + 'name' => $data['name'] ?? $closurePrefix.spl_object_id($handler), + 'description' => $data['description'] ?? null, + ]; + } + + $classShortName = $reflection->getDeclaringClass()->getShortName(); + $methodName = $reflection->getName(); + $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null); + + return [ + 'name' => $data['name'] ?? ('__invoke' === $methodName ? $classShortName : $methodName), + 'description' => $data['description'] ?? $docBlockParser->getDescription($docBlock) ?? null, + ]; + } + /** * @param Handler $handler */ @@ -429,7 +477,7 @@ private function getHandlerDescription(\Closure|array|string|RuntimeHandlerInter } /** - * @return array + * @return array */ private function getCompletionProviders(\ReflectionMethod|\ReflectionFunction $reflection): array { From 2725b327a7e0cbe3cb58f9c8ef8b5f0be0946171 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateu=20Aguil=C3=B3=20Bosch?= Date: Thu, 30 Apr 2026 08:23:51 +0200 Subject: [PATCH 10/12] refactor(server): non-null runtime handler types Drop nullable return types on RuntimeToolHandlerInterface:: getInputSchema(), RuntimePromptHandlerInterface:: getPromptArguments()/getCompletionProviders(), and RuntimeResourceTemplateHandlerInterface:: getCompletionProviders() in favor of plain arrays. ArrayLoader no longer needs the null-coalescing fallbacks, and NullSchemaToolHandler plus its ConfigurationException test become redundant. --- .../Registry/Loader/ArrayLoader.php | 13 +++----- .../Handler/RuntimeHandlerInterface.php | 23 +++---------- .../Handler/RuntimePromptHandlerInterface.php | 14 ++++---- ...untimeResourceTemplateHandlerInterface.php | 6 ++-- .../Handler/RuntimeToolHandlerInterface.php | 8 ++--- .../Runtime/NullSchemaToolHandler.php | 33 ------------------- .../Loader/ArrayLoaderRuntimeHandlerTest.php | 13 -------- 7 files changed, 22 insertions(+), 88 deletions(-) delete mode 100644 tests/Fixtures/Runtime/NullSchemaToolHandler.php diff --git a/src/Capability/Registry/Loader/ArrayLoader.php b/src/Capability/Registry/Loader/ArrayLoader.php index 1e33a73f..e1be9d90 100644 --- a/src/Capability/Registry/Loader/ArrayLoader.php +++ b/src/Capability/Registry/Loader/ArrayLoader.php @@ -248,15 +248,10 @@ private function prepareRuntimeTool(array $data, RuntimeToolHandlerInterface $ha { $this->assertRuntimeRequiredFields($data, $handler, 'tool'); - $inputSchema = $data['inputSchema'] ?? $handler->getInputSchema(); - if (null === $inputSchema) { - throw new ConfigurationException(\sprintf('Runtime tool handler %s did not provide an input schema (neither via the inputSchema named argument nor via getInputSchema()).', $handler::class)); - } - return [ 'name' => $data['name'], 'description' => $data['description'], - 'inputSchema' => $inputSchema, + 'inputSchema' => $data['inputSchema'] ?? $handler->getInputSchema(), 'outputSchema' => $data['outputSchema'] ?? $handler->getOutputSchema(), 'kind' => 'runtime tool', ]; @@ -328,7 +323,7 @@ private function prepareRuntimeResourceTemplate(array $data, RuntimeResourceTemp return [ 'name' => $data['name'], 'description' => $data['description'], - 'completionProviders' => $handler->getCompletionProviders() ?? [], + 'completionProviders' => $handler->getCompletionProviders(), 'kind' => 'runtime template', ]; } @@ -364,8 +359,8 @@ private function prepareRuntimePrompt(array $data, RuntimePromptHandlerInterface return [ 'name' => $data['name'], 'description' => $data['description'], - 'arguments' => $handler->getPromptArguments() ?? [], - 'completionProviders' => $handler->getCompletionProviders() ?? [], + 'arguments' => $handler->getPromptArguments(), + 'completionProviders' => $handler->getCompletionProviders(), 'kind' => 'runtime prompt', ]; } diff --git a/src/Server/Handler/RuntimeHandlerInterface.php b/src/Server/Handler/RuntimeHandlerInterface.php index b85c98fb..8d0ac000 100644 --- a/src/Server/Handler/RuntimeHandlerInterface.php +++ b/src/Server/Handler/RuntimeHandlerInterface.php @@ -14,25 +14,12 @@ use Mcp\Server\ClientGateway; /** - * Base contract for handlers that execute at runtime. + * Base contract for runtime handlers — stateful objects invoked per request. * - * Unlike string/array/Closure handlers, a runtime handler is a stateful object - * registered with a reference. The reference handler invokes {@see self::execute()} - * with the full argument map (including reserved keys such as `_session` and - * `_request`) and a {@see ClientGateway} for client-side callbacks. - * - * Element-specific subtypes ({@see RuntimeToolHandlerInterface}, - * {@see RuntimePromptHandlerInterface}, {@see RuntimeResourceTemplateHandlerInterface}, - * {@see RuntimeResourceHandlerInterface}) declare only the metadata accessors - * relevant to their element kind. Implementing the base interface alone is - * supported for backwards-compatibility but new handlers should pick the - * element-specific subtype that matches how they are registered. - * - * Note: arguments are forwarded to {@see self::execute()} as received from the - * JSON-RPC request, without the type casting performed for reflection-based - * handlers (string-to-int, string-to-bool, etc.). Runtime handlers are - * responsible for validating and casting their own inputs, typically against - * the schema they advertise. + * New handlers should implement the element-specific subtype matching how they + * are registered. Arguments are forwarded as received from JSON-RPC, without + * the casting performed for reflection-based handlers; implementations must + * validate and cast their own inputs. * * @author Mateu Aguiló Bosch */ diff --git a/src/Server/Handler/RuntimePromptHandlerInterface.php b/src/Server/Handler/RuntimePromptHandlerInterface.php index 01fee276..493aa194 100644 --- a/src/Server/Handler/RuntimePromptHandlerInterface.php +++ b/src/Server/Handler/RuntimePromptHandlerInterface.php @@ -19,21 +19,19 @@ interface RuntimePromptHandlerInterface extends RuntimeHandlerInterface { /** - * Returns the prompt arguments for this handler. + * Returns the prompt arguments for this handler, or an empty array when the prompt takes no arguments. * - * Returns null when the prompt takes no arguments. - * - * @return list<\Mcp\Schema\PromptArgument>|null + * @return list<\Mcp\Schema\PromptArgument> */ - public function getPromptArguments(): ?array; + public function getPromptArguments(): array; /** * Returns the completion providers for the prompt arguments. * * Map of argument name => provider class-string or provider instance. - * Returns null when no completion providers apply. + * Returns an empty array when no completion providers apply. * - * @return array|null + * @return array */ - public function getCompletionProviders(): ?array; + public function getCompletionProviders(): array; } diff --git a/src/Server/Handler/RuntimeResourceTemplateHandlerInterface.php b/src/Server/Handler/RuntimeResourceTemplateHandlerInterface.php index 9315f029..e753a01b 100644 --- a/src/Server/Handler/RuntimeResourceTemplateHandlerInterface.php +++ b/src/Server/Handler/RuntimeResourceTemplateHandlerInterface.php @@ -22,9 +22,9 @@ interface RuntimeResourceTemplateHandlerInterface extends RuntimeHandlerInterfac * Returns the completion providers for the URI template variables. * * Map of variable name => provider class-string or provider instance. - * Returns null when no completion providers apply. + * Returns an empty array when no completion providers apply. * - * @return array|null + * @return array */ - public function getCompletionProviders(): ?array; + public function getCompletionProviders(): array; } diff --git a/src/Server/Handler/RuntimeToolHandlerInterface.php b/src/Server/Handler/RuntimeToolHandlerInterface.php index 268ca065..d815085b 100644 --- a/src/Server/Handler/RuntimeToolHandlerInterface.php +++ b/src/Server/Handler/RuntimeToolHandlerInterface.php @@ -21,12 +21,12 @@ interface RuntimeToolHandlerInterface extends RuntimeHandlerInterface /** * Returns the JSON Schema describing tool inputs. * - * Returns null when the Builder caller supplies the schema via the - * `inputSchema:` named argument (the named argument takes precedence). + * The Builder's `inputSchema:` named argument, when supplied, takes precedence + * over this value. * - * @return array|null + * @return array */ - public function getInputSchema(): ?array; + public function getInputSchema(): array; /** * Returns the JSON Schema describing tool outputs. diff --git a/tests/Fixtures/Runtime/NullSchemaToolHandler.php b/tests/Fixtures/Runtime/NullSchemaToolHandler.php deleted file mode 100644 index d840d065..00000000 --- a/tests/Fixtures/Runtime/NullSchemaToolHandler.php +++ /dev/null @@ -1,33 +0,0 @@ -expectException(ConfigurationException::class); - $this->expectExceptionMessageMatches('/'.preg_quote(NullSchemaToolHandler::class, '/').'/'); - - $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->addTool( - handler: new NullSchemaToolHandler(), - name: 'demo', - description: 'no schema source', - )); - } - public function testAddResourceRegistersRuntimeHandler(): void { $handler = new BareResourceHandler(); From 219cfdf2c69e1d4154f511f5c9f5cf88ff2eaba4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateu=20Aguil=C3=B3=20Bosch?= Date: Fri, 1 May 2026 06:36:15 +0200 Subject: [PATCH 11/12] doc: update docs/server-builder.md Co-authored-by: Christopher Hertel --- docs/server-builder.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/server-builder.md b/docs/server-builder.md index 1f855068..0bd6a662 100644 --- a/docs/server-builder.md +++ b/docs/server-builder.md @@ -374,7 +374,7 @@ Builder. | Tool | `RuntimeToolHandlerInterface` | | Prompt | `RuntimePromptHandlerInterface` | | Resource template| `RuntimeResourceTemplateHandlerInterface`| -| Resource | `RuntimeHandlerInterface` | +| Resource | `RuntimeResourceHandlerInterface` | Each interface declares only the metadata it needs (input/output schema for tools, prompt arguments and completion providers for prompts, completion From 1193528a6ae4a04ba9040af45d74227b5be7aa81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateu=20Aguil=C3=B3=20Bosch?= Date: Fri, 1 May 2026 07:21:11 +0200 Subject: [PATCH 12/12] feat: peer revie feedback Introduce a new `Builder::add(...)` pattern for a more generic runtime handler. --- docs/mcp-elements.md | 5 +- docs/server-builder.md | 83 ++-- src/Capability/Registry.php | 10 +- src/Capability/Registry/ElementReference.php | 7 +- .../Registry/Loader/ArrayLoader.php | 386 +++++------------- .../Registry/Loader/ExplicitElementLoader.php | 65 +++ src/Capability/Registry/PromptReference.php | 4 +- src/Capability/Registry/ReferenceHandler.php | 56 ++- src/Capability/Registry/ResourceReference.php | 4 +- .../Registry/ResourceTemplateReference.php | 4 +- src/Capability/Registry/ToolReference.php | 4 +- src/Capability/RegistryInterface.php | 10 +- src/Server/Builder.php | 110 +++-- ...erface.php => ElementHandlerInterface.php} | 10 +- src/Server/Handler/PromptHandlerInterface.php | 28 ++ .../Handler/ResourceHandlerInterface.php | 25 ++ .../ResourceTemplateHandlerInterface.php | 28 ++ .../Handler/RuntimeHandlerInterface.php | 37 -- .../Handler/RuntimePromptHandlerInterface.php | 37 -- ...untimeResourceTemplateHandlerInterface.php | 30 -- .../Handler/RuntimeToolHandlerInterface.php | 40 -- src/Server/Handler/ToolHandlerInterface.php | 28 ++ .../Fixtures/Runtime/BareResourceHandler.php | 23 -- .../Runtime/OutputSchemaToolHandler.php | 33 -- .../Fixtures/Runtime/PromptRuntimeHandler.php | 35 -- .../ResourceTemplateRuntimeHandler.php | 29 -- tests/Fixtures/Runtime/SchemaToolHandler.php | 33 -- .../Loader/ArrayLoaderRuntimeHandlerTest.php | 209 ---------- .../Loader/ExplicitElementLoaderTest.php | 222 ++++++++++ .../Registry/ReferenceHandlerTest.php | 60 ++- 30 files changed, 742 insertions(+), 913 deletions(-) create mode 100644 src/Capability/Registry/Loader/ExplicitElementLoader.php rename src/Server/Handler/{RuntimeResourceHandlerInterface.php => ElementHandlerInterface.php} (51%) create mode 100644 src/Server/Handler/PromptHandlerInterface.php create mode 100644 src/Server/Handler/ResourceHandlerInterface.php create mode 100644 src/Server/Handler/ResourceTemplateHandlerInterface.php delete mode 100644 src/Server/Handler/RuntimeHandlerInterface.php delete mode 100644 src/Server/Handler/RuntimePromptHandlerInterface.php delete mode 100644 src/Server/Handler/RuntimeResourceTemplateHandlerInterface.php delete mode 100644 src/Server/Handler/RuntimeToolHandlerInterface.php create mode 100644 src/Server/Handler/ToolHandlerInterface.php delete mode 100644 tests/Fixtures/Runtime/BareResourceHandler.php delete mode 100644 tests/Fixtures/Runtime/OutputSchemaToolHandler.php delete mode 100644 tests/Fixtures/Runtime/PromptRuntimeHandler.php delete mode 100644 tests/Fixtures/Runtime/ResourceTemplateRuntimeHandler.php delete mode 100644 tests/Fixtures/Runtime/SchemaToolHandler.php delete mode 100644 tests/Unit/Capability/Registry/Loader/ArrayLoaderRuntimeHandlerTest.php create mode 100644 tests/Unit/Capability/Registry/Loader/ExplicitElementLoaderTest.php diff --git a/docs/mcp-elements.md b/docs/mcp-elements.md index 741c54f5..c19036bc 100644 --- a/docs/mcp-elements.md +++ b/docs/mcp-elements.md @@ -43,8 +43,9 @@ 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 [Runtime -Handlers](server-builder.md#runtime-handlers) in the Server Builder docs. +(e.g. bridging configuration entities into MCP elements), see [Explicit +element registration](server-builder.md#explicit-element-registration) in the +Server Builder docs. ## Tools diff --git a/docs/server-builder.md b/docs/server-builder.md index 0bd6a662..485cb13d 100644 --- a/docs/server-builder.md +++ b/docs/server-builder.md @@ -362,64 +362,66 @@ the handler's method name and docblock. For more details on MCP elements, handlers, and attribute-based discovery, see [MCP Elements](mcp-elements.md). -### Runtime Handlers +### Explicit element registration -When an element's shape is not known at compile time (e.g. config-driven -integrations), reflection-based discovery does not apply. Implement an -element-specific runtime interface instead and pass the instance to the -Builder. +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 | Interface | -|------------------|------------------------------------------| -| Tool | `RuntimeToolHandlerInterface` | -| Prompt | `RuntimePromptHandlerInterface` | -| Resource template| `RuntimeResourceTemplateHandlerInterface`| -| Resource | `RuntimeResourceHandlerInterface` | +| 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 interface declares only the metadata it needs (input/output schema for -tools, prompt arguments and completion providers for prompts, completion -providers for resource templates). All extend the base -`RuntimeHandlerInterface`, which requires only `execute()`. +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\RuntimeToolHandlerInterface; +use Mcp\Server\Handler\ToolHandlerInterface; -final class WeatherToolHandler implements RuntimeToolHandlerInterface +final class WeatherHandler implements ToolHandlerInterface { - public function getInputSchema(): ?array - { - return [ - 'type' => 'object', - 'properties' => ['city' => ['type' => 'string']], - 'required' => ['city'], - ]; - } - - public function getOutputSchema(): ?array - { - return null; - } - 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() - ->addTool( - handler: new WeatherToolHandler(), - name: 'get_weather', - description: 'Returns the current weather for a city.', - ) + ->add($tool, new WeatherHandler()) ->build(); ``` -`name` and `description` are required when registering a runtime handler. -For tools, an input schema is also required (via the `inputSchema:` named -argument or `getInputSchema()`). Missing values raise `ConfigurationException` -at registration time. +`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 @@ -642,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 02a418a4..822d43e1 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -30,7 +30,7 @@ use Mcp\Schema\Resource; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\Tool; -use Mcp\Server\Handler\RuntimeHandlerInterface; +use Mcp\Server\Handler\ElementHandlerInterface; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -69,7 +69,7 @@ public function __construct( ) { } - public function registerTool(Tool $tool, callable|array|string|RuntimeHandlerInterface $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; @@ -93,7 +93,7 @@ public function registerTool(Tool $tool, callable|array|string|RuntimeHandlerInt $this->eventDispatcher?->dispatch(new ToolListChangedEvent()); } - public function registerResource(Resource $resource, callable|array|string|RuntimeHandlerInterface $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; @@ -113,7 +113,7 @@ public function registerResource(Resource $resource, callable|array|string|Runti public function registerResourceTemplate( ResourceTemplate $template, - callable|array|string|RuntimeHandlerInterface $handler, + callable|array|string|ElementHandlerInterface $handler, array $completionProviders = [], bool $isManual = false, ): void { @@ -140,7 +140,7 @@ public function registerResourceTemplate( public function registerPrompt( Prompt $prompt, - callable|array|string|RuntimeHandlerInterface $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 9d5c0f90..803ec765 100644 --- a/src/Capability/Registry/ElementReference.php +++ b/src/Capability/Registry/ElementReference.php @@ -11,10 +11,11 @@ namespace Mcp\Capability\Registry; -use Mcp\Server\Handler\RuntimeHandlerInterface; +use Mcp\Server\Handler\ElementHandlerInterface; /** - * @phpstan-type Handler \Closure|array{0: object|string, 1: string}|string|RuntimeHandlerInterface + * @phpstan-type CallableHandler \Closure|array{0: object|string, 1: string}|string + * @phpstan-type Handler CallableHandler|ElementHandlerInterface * * @author Kyrian Obikwelu */ @@ -24,7 +25,7 @@ class ElementReference * @param Handler $handler */ public function __construct( - public readonly \Closure|array|string|RuntimeHandlerInterface $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 e1be9d90..e9f578f1 100644 --- a/src/Capability/Registry/Loader/ArrayLoader.php +++ b/src/Capability/Registry/Loader/ArrayLoader.php @@ -31,35 +31,29 @@ use Mcp\Schema\Tool; use Mcp\Schema\ToolAnnotations; use Mcp\Server\Handler; -use Mcp\Server\Handler\RuntimeHandlerInterface; -use Mcp\Server\Handler\RuntimePromptHandlerInterface; -use Mcp\Server\Handler\RuntimeResourceHandlerInterface; -use Mcp\Server\Handler\RuntimeResourceTemplateHandlerInterface; -use Mcp\Server\Handler\RuntimeToolHandlerInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; /** * @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, * annotations: ?ToolAnnotations, - * inputSchema: ?array, * icons: ?Icon[], * meta: ?array, * outputSchema: ?array * }[] $tools * @param array{ - * handler: Handler, + * handler: CallableHandler, * uri: string, * name: ?string, * description: ?string, @@ -70,7 +64,7 @@ final class ArrayLoader implements LoaderInterface * meta: ?array * }[] $resources * @param array{ - * handler: Handler, + * handler: CallableHandler, * uriTemplate: string, * name: ?string, * description: ?string, @@ -79,9 +73,8 @@ final class ArrayLoader implements LoaderInterface * meta: ?array * }[] $resourceTemplates * @param array{ - * handler: Handler, + * handler: CallableHandler, * name: ?string, - * title: ?string, * description: ?string, * icons: ?Icon[], * meta: ?array @@ -105,50 +98,66 @@ public function load(RegistryInterface $registry): void // Register Tools foreach ($this->tools as $data) { try { - $handler = $data['handler']; - $prepared = $handler instanceof RuntimeToolHandlerInterface - ? $this->prepareRuntimeTool($data, $handler) - : $this->prepareReflectedTool($data, $handler, $schemaGenerator, $docBlockParser); + $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'] ?? ('__invoke' === $methodName ? $classShortName : $methodName); + $description = $data['description'] ?? $docBlockParser->getDescription($docBlock) ?? null; + } + + $inputSchema = $data['inputSchema'] ?? $schemaGenerator->generate($reflection); $tool = new Tool( - name: $prepared['name'], + name: $name, title: $data['title'] ?? null, - inputSchema: $prepared['inputSchema'], - description: $prepared['description'], + inputSchema: $inputSchema, + description: $description, annotations: $data['annotations'] ?? null, icons: $data['icons'] ?? null, meta: $data['meta'] ?? null, - outputSchema: $prepared['outputSchema'], + outputSchema: $data['outputSchema'] ?? null, ); $registry->registerTool($tool, $data['handler'], true); $handlerDesc = $this->getHandlerDescription($data['handler']); - $this->logger->debug("Registered manual {$prepared['kind']} {$prepared['name']} from handler {$handlerDesc}"); + $this->logger->debug("Registered manual tool {$name} from handler {$handlerDesc}"); } catch (\Throwable $e) { $this->logger->error( 'Failed to register manual tool', - ['handler' => $this->getHandlerDescription($data['handler']), 'name' => $data['name'], 'exception' => $e], + ['handler' => $data['handler'], 'name' => $data['name'], 'exception' => $e], ); - if ($e instanceof ConfigurationException) { - throw $e; - } - $nameForMessage = $data['name'] ?? ''; - throw new ConfigurationException("Error registering manual tool '{$nameForMessage}': {$e->getMessage()}", 0, $e); + throw new ConfigurationException("Error registering manual tool '{$data['name']}': {$e->getMessage()}", 0, $e); } } // Register Resources foreach ($this->resources as $data) { try { - $handler = $data['handler']; - $prepared = $handler instanceof RuntimeResourceHandlerInterface - ? $this->prepareRuntimeResource($data, $handler) - : $this->prepareReflectedResource($data, $handler, $docBlockParser); + $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'] ?? ('__invoke' === $methodName ? $classShortName : $methodName); + $description = $data['description'] ?? $docBlockParser->getDescription($docBlock) ?? null; + } $resource = new Resource( uri: $data['uri'], - name: $prepared['name'], - description: $prepared['description'], + name: $name, + description: $description, mimeType: $data['mimeType'] ?? null, annotations: $data['annotations'] ?? null, size: $data['size'] ?? null, @@ -158,15 +167,12 @@ public function load(RegistryInterface $registry): void $registry->registerResource($resource, $data['handler'], true); $handlerDesc = $this->getHandlerDescription($data['handler']); - $this->logger->debug("Registered manual {$prepared['kind']} {$prepared['name']} from handler {$handlerDesc}"); + $this->logger->debug("Registered manual resource {$name} from handler {$handlerDesc}"); } catch (\Throwable $e) { $this->logger->error( 'Failed to register manual resource', - ['handler' => $this->getHandlerDescription($data['handler']), 'uri' => $data['uri'], 'exception' => $e], + ['handler' => $data['handler'], 'uri' => $data['uri'], 'exception' => $e], ); - if ($e instanceof ConfigurationException) { - throw $e; - } throw new ConfigurationException("Error registering manual resource '{$data['uri']}': {$e->getMessage()}", 0, $e); } } @@ -174,31 +180,38 @@ public function load(RegistryInterface $registry): void // Register Templates foreach ($this->resourceTemplates as $data) { try { - $handler = $data['handler']; - $prepared = $handler instanceof RuntimeResourceTemplateHandlerInterface - ? $this->prepareRuntimeResourceTemplate($data, $handler) - : $this->prepareReflectedResourceTemplate($data, $handler, $docBlockParser); + $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'] ?? ('__invoke' === $methodName ? $classShortName : $methodName); + $description = $data['description'] ?? $docBlockParser->getDescription($docBlock) ?? null; + } $template = new ResourceTemplate( uriTemplate: $data['uriTemplate'], - name: $prepared['name'], - description: $prepared['description'], + name: $name, + description: $description, mimeType: $data['mimeType'] ?? null, annotations: $data['annotations'] ?? null, meta: $data['meta'] ?? null, ); - $registry->registerResourceTemplate($template, $data['handler'], $prepared['completionProviders'], true); + $completionProviders = $this->getCompletionProviders($reflection); + $registry->registerResourceTemplate($template, $data['handler'], $completionProviders, true); $handlerDesc = $this->getHandlerDescription($data['handler']); - $this->logger->debug("Registered manual {$prepared['kind']} {$prepared['name']} from handler {$handlerDesc}"); + $this->logger->debug("Registered manual template {$name} from handler {$handlerDesc}"); } catch (\Throwable $e) { $this->logger->error( 'Failed to register manual template', - ['handler' => $this->getHandlerDescription($data['handler']), 'uriTemplate' => $data['uriTemplate'], 'exception' => $e], + ['handler' => $data['handler'], 'uriTemplate' => $data['uriTemplate'], 'exception' => $e], ); - if ($e instanceof ConfigurationException) { - throw $e; - } throw new ConfigurationException("Error registering manual resource template '{$data['uriTemplate']}': {$e->getMessage()}", 0, $e); } } @@ -206,33 +219,58 @@ public function load(RegistryInterface $registry): void // Register Prompts foreach ($this->prompts as $data) { try { - $handler = $data['handler']; - $prepared = $handler instanceof RuntimePromptHandlerInterface - ? $this->prepareRuntimePrompt($data, $handler) - : $this->prepareReflectedPrompt($data, $handler, $docBlockParser); + $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'] ?? ('__invoke' === $methodName ? $classShortName : $methodName); + $description = $data['description'] ?? $docBlockParser->getDescription($docBlock) ?? null; + } + $arguments = []; + $paramTags = $reflection instanceof \ReflectionMethod ? $docBlockParser->getParamTags( + $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null), + ) : []; + foreach ($reflection->getParameters() as $param) { + $reflectionType = $param->getType(); + + // Basic DI check (heuristic) + if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) { + continue; + } + + $paramTag = $paramTags['$'.$param->getName()] ?? null; + $arguments[] = new PromptArgument( + $param->getName(), + $paramTag ? trim((string) $paramTag->getDescription()) : null, + !$param->isOptional() && !$param->isDefaultValueAvailable(), + ); + } $prompt = new Prompt( - name: $prepared['name'], + name: $name, title: $data['title'] ?? null, - description: $prepared['description'], - arguments: $prepared['arguments'], + description: $description, + arguments: $arguments, icons: $data['icons'] ?? null, meta: $data['meta'] ?? null ); - $registry->registerPrompt($prompt, $data['handler'], $prepared['completionProviders'], true); + $completionProviders = $this->getCompletionProviders($reflection); + $registry->registerPrompt($prompt, $data['handler'], $completionProviders, true); $handlerDesc = $this->getHandlerDescription($data['handler']); - $this->logger->debug("Registered manual {$prepared['kind']} {$prepared['name']} from handler {$handlerDesc}"); + $this->logger->debug("Registered manual prompt {$name} from handler {$handlerDesc}"); } catch (\Throwable $e) { $this->logger->error( 'Failed to register manual prompt', - ['handler' => $this->getHandlerDescription($data['handler']), 'name' => $data['name'], 'exception' => $e], + ['handler' => $data['handler'], 'name' => $data['name'], 'exception' => $e], ); - if ($e instanceof ConfigurationException) { - throw $e; - } - $nameForMessage = $data['name'] ?? ''; - throw new ConfigurationException("Error registering manual prompt '{$nameForMessage}': {$e->getMessage()}", 0, $e); + throw new ConfigurationException("Error registering manual prompt '{$data['name']}': {$e->getMessage()}", 0, $e); } } @@ -240,226 +278,14 @@ public function load(RegistryInterface $registry): void } /** - * @param array $data - * - * @return array{name: string, description: string, inputSchema: array, outputSchema: ?array, kind: string} - */ - private function prepareRuntimeTool(array $data, RuntimeToolHandlerInterface $handler): array - { - $this->assertRuntimeRequiredFields($data, $handler, 'tool'); - - return [ - 'name' => $data['name'], - 'description' => $data['description'], - 'inputSchema' => $data['inputSchema'] ?? $handler->getInputSchema(), - 'outputSchema' => $data['outputSchema'] ?? $handler->getOutputSchema(), - 'kind' => 'runtime tool', - ]; - } - - /** - * @param array $data - * @param \Closure|array{0: object|string, 1: string}|string $handler - * - * @return array{name: string, description: ?string, inputSchema: array, outputSchema: ?array, kind: string} - */ - private function prepareReflectedTool(array $data, \Closure|array|string $handler, SchemaGeneratorInterface $schemaGenerator, DocBlockParser $docBlockParser): array - { - $reflection = HandlerResolver::resolve($handler); - $meta = $this->resolveReflectedNameAndDescription($data, $handler, $reflection, $docBlockParser, 'closure_tool_'); - - return [ - 'name' => $meta['name'], - 'description' => $meta['description'], - 'inputSchema' => $data['inputSchema'] ?? $schemaGenerator->generate($reflection), - 'outputSchema' => $data['outputSchema'] ?? null, - 'kind' => 'tool', - ]; - } - - /** - * @param array $data - * - * @return array{name: string, description: string, kind: string} - */ - private function prepareRuntimeResource(array $data, RuntimeResourceHandlerInterface $handler): array - { - $this->assertRuntimeRequiredFields($data, $handler, 'resource'); - - return [ - 'name' => $data['name'], - 'description' => $data['description'], - 'kind' => 'runtime resource', - ]; - } - - /** - * @param array $data - * @param \Closure|array{0: object|string, 1: string}|string $handler - * - * @return array{name: string, description: ?string, kind: string} - */ - private function prepareReflectedResource(array $data, \Closure|array|string $handler, DocBlockParser $docBlockParser): array - { - $reflection = HandlerResolver::resolve($handler); - $meta = $this->resolveReflectedNameAndDescription($data, $handler, $reflection, $docBlockParser, 'closure_resource_'); - - return [ - 'name' => $meta['name'], - 'description' => $meta['description'], - 'kind' => 'resource', - ]; - } - - /** - * @param array $data - * - * @return array{name: string, description: string, completionProviders: array, kind: string} - */ - private function prepareRuntimeResourceTemplate(array $data, RuntimeResourceTemplateHandlerInterface $handler): array - { - $this->assertRuntimeRequiredFields($data, $handler, 'resource template'); - - return [ - 'name' => $data['name'], - 'description' => $data['description'], - 'completionProviders' => $handler->getCompletionProviders(), - 'kind' => 'runtime template', - ]; - } - - /** - * @param array $data - * @param \Closure|array{0: object|string, 1: string}|string $handler - * - * @return array{name: string, description: ?string, completionProviders: array, kind: string} + * @param CallableHandler $handler */ - private function prepareReflectedResourceTemplate(array $data, \Closure|array|string $handler, DocBlockParser $docBlockParser): array - { - $reflection = HandlerResolver::resolve($handler); - $meta = $this->resolveReflectedNameAndDescription($data, $handler, $reflection, $docBlockParser, 'closure_template_'); - - return [ - 'name' => $meta['name'], - 'description' => $meta['description'], - 'completionProviders' => $this->getCompletionProviders($reflection), - 'kind' => 'template', - ]; - } - - /** - * @param array $data - * - * @return array{name: string, description: string, arguments: PromptArgument[], completionProviders: array, kind: string} - */ - private function prepareRuntimePrompt(array $data, RuntimePromptHandlerInterface $handler): array - { - $this->assertRuntimeRequiredFields($data, $handler, 'prompt'); - - return [ - 'name' => $data['name'], - 'description' => $data['description'], - 'arguments' => $handler->getPromptArguments(), - 'completionProviders' => $handler->getCompletionProviders(), - 'kind' => 'runtime prompt', - ]; - } - - /** - * @param array $data - * @param \Closure|array{0: object|string, 1: string}|string $handler - * - * @return array{name: string, description: ?string, arguments: PromptArgument[], completionProviders: array, kind: string} - */ - private function prepareReflectedPrompt(array $data, \Closure|array|string $handler, DocBlockParser $docBlockParser): array - { - $reflection = HandlerResolver::resolve($handler); - $meta = $this->resolveReflectedNameAndDescription($data, $handler, $reflection, $docBlockParser, 'closure_prompt_'); - - $arguments = []; - $paramTags = $reflection instanceof \ReflectionMethod ? $docBlockParser->getParamTags( - $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null), - ) : []; - foreach ($reflection->getParameters() as $param) { - $reflectionType = $param->getType(); - - // Basic DI check (heuristic) - if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) { - continue; - } - - $paramTag = $paramTags['$'.$param->getName()] ?? null; - $arguments[] = new PromptArgument( - $param->getName(), - $paramTag ? trim((string) $paramTag->getDescription()) : null, - !$param->isOptional() && !$param->isDefaultValueAvailable(), - ); - } - - return [ - 'name' => $meta['name'], - 'description' => $meta['description'], - 'arguments' => $arguments, - 'completionProviders' => $this->getCompletionProviders($reflection), - 'kind' => 'prompt', - ]; - } - - /** - * @param array $data - */ - private function assertRuntimeRequiredFields(array $data, RuntimeHandlerInterface $handler, string $kindLabel): void - { - foreach (['name', 'description'] as $field) { - if (null === $data[$field]) { - throw new ConfigurationException(\sprintf('Runtime %s handler %s is missing a %s; the Builder requires an explicit %s for runtime handlers.', $kindLabel, $handler::class, $field, $field)); - } - } - } - - /** - * @param array $data - * @param \Closure|array{0: object|string, 1: string}|string $handler - * - * @return array{name: string, description: ?string} - */ - private function resolveReflectedNameAndDescription( - array $data, - \Closure|array|string $handler, - \ReflectionFunction|\ReflectionMethod $reflection, - DocBlockParser $docBlockParser, - string $closurePrefix, - ): array { - if ($reflection instanceof \ReflectionFunction) { - return [ - 'name' => $data['name'] ?? $closurePrefix.spl_object_id($handler), - 'description' => $data['description'] ?? null, - ]; - } - - $classShortName = $reflection->getDeclaringClass()->getShortName(); - $methodName = $reflection->getName(); - $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null); - - return [ - 'name' => $data['name'] ?? ('__invoke' === $methodName ? $classShortName : $methodName), - 'description' => $data['description'] ?? $docBlockParser->getDescription($docBlock) ?? null, - ]; - } - - /** - * @param Handler $handler - */ - private function getHandlerDescription(\Closure|array|string|RuntimeHandlerInterface $handler): string + private function getHandlerDescription(\Closure|array|string $handler): string { if ($handler instanceof \Closure) { return 'Closure'; } - if ($handler instanceof RuntimeHandlerInterface) { - return $handler::class; - } - if (\is_array($handler)) { return \sprintf( '%s::%s', @@ -472,7 +298,7 @@ private function getHandlerDescription(\Closure|array|string|RuntimeHandlerInter } /** - * @return array + * @return array */ private function getCompletionProviders(\ReflectionMethod|\ReflectionFunction $reflection): array { 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 21fcf08f..955df1bd 100644 --- a/src/Capability/Registry/PromptReference.php +++ b/src/Capability/Registry/PromptReference.php @@ -14,7 +14,7 @@ use Mcp\Capability\Formatter\PromptResultFormatter; use Mcp\Schema\Content\PromptMessage; use Mcp\Schema\Prompt; -use Mcp\Server\Handler\RuntimeHandlerInterface; +use Mcp\Server\Handler\ElementHandlerInterface; /** * @phpstan-import-type Handler from ElementReference @@ -29,7 +29,7 @@ class PromptReference extends ElementReference */ public function __construct( public readonly Prompt $prompt, - \Closure|array|string|RuntimeHandlerInterface $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 6ab7b6d2..7032edf3 100644 --- a/src/Capability/Registry/ReferenceHandler.php +++ b/src/Capability/Registry/ReferenceHandler.php @@ -14,7 +14,11 @@ use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\RegistryException; use Mcp\Server\ClientGateway; -use Mcp\Server\Handler\RuntimeHandlerInterface; +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; @@ -35,40 +39,54 @@ public function __construct( public function handle(ElementReference $reference, array $arguments): mixed { $session = $arguments['_session']; - - if ($reference->handler instanceof RuntimeHandlerInterface) { - return $reference->handler->execute( - array_diff_key($arguments, array_flip(['_session', '_request'])), - new ClientGateway($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 e15c34a8..0bfbb6f3 100644 --- a/src/Capability/Registry/ResourceReference.php +++ b/src/Capability/Registry/ResourceReference.php @@ -14,7 +14,7 @@ use Mcp\Capability\Formatter\ResourceResultFormatter; use Mcp\Schema\Content\ResourceContents; use Mcp\Schema\Resource; -use Mcp\Server\Handler\RuntimeHandlerInterface; +use Mcp\Server\Handler\ElementHandlerInterface; /** * @phpstan-import-type Handler from ElementReference @@ -28,7 +28,7 @@ class ResourceReference extends ElementReference */ public function __construct( public readonly Resource $resource, - callable|array|string|RuntimeHandlerInterface $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 19997516..f38e7533 100644 --- a/src/Capability/Registry/ResourceTemplateReference.php +++ b/src/Capability/Registry/ResourceTemplateReference.php @@ -14,7 +14,7 @@ use Mcp\Capability\Formatter\ResourceResultFormatter; use Mcp\Schema\Content\ResourceContents; use Mcp\Schema\ResourceTemplate; -use Mcp\Server\Handler\RuntimeHandlerInterface; +use Mcp\Server\Handler\ElementHandlerInterface; /** * @phpstan-import-type Handler from ElementReference @@ -36,7 +36,7 @@ class ResourceTemplateReference extends ElementReference */ public function __construct( public readonly ResourceTemplate $resourceTemplate, - callable|array|string|RuntimeHandlerInterface $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 a0243130..649cd18e 100644 --- a/src/Capability/Registry/ToolReference.php +++ b/src/Capability/Registry/ToolReference.php @@ -14,7 +14,7 @@ use Mcp\Capability\Formatter\ToolResultFormatter; use Mcp\Schema\Content\Content; use Mcp\Schema\Tool; -use Mcp\Server\Handler\RuntimeHandlerInterface; +use Mcp\Server\Handler\ElementHandlerInterface; /** * @phpstan-import-type Handler from ElementReference @@ -28,7 +28,7 @@ class ToolReference extends ElementReference */ public function __construct( public readonly Tool $tool, - callable|array|string|RuntimeHandlerInterface $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 704887ea..783c1429 100644 --- a/src/Capability/RegistryInterface.php +++ b/src/Capability/RegistryInterface.php @@ -25,7 +25,7 @@ use Mcp\Schema\Resource; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\Tool; -use Mcp\Server\Handler\RuntimeHandlerInterface; +use Mcp\Server\Handler\ElementHandlerInterface; /** * @phpstan-import-type Handler from ElementReference @@ -40,14 +40,14 @@ interface RegistryInterface * * @param Handler $handler */ - public function registerTool(Tool $tool, callable|array|string|RuntimeHandlerInterface $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|RuntimeHandlerInterface $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. @@ -57,7 +57,7 @@ public function registerResource(Resource $resource, callable|array|string|Runti */ public function registerResourceTemplate( ResourceTemplate $template, - callable|array|string|RuntimeHandlerInterface $handler, + callable|array|string|ElementHandlerInterface $handler, array $completionProviders = [], bool $isManual = false, ): void; @@ -70,7 +70,7 @@ public function registerResourceTemplate( */ public function registerPrompt( Prompt $prompt, - callable|array|string|RuntimeHandlerInterface $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 10fe73a4..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,15 +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\RuntimePromptHandlerInterface; -use Mcp\Server\Handler\RuntimeResourceHandlerInterface; -use Mcp\Server\Handler\RuntimeResourceTemplateHandlerInterface; -use Mcp\Server\Handler\RuntimeToolHandlerInterface; +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; @@ -53,6 +59,7 @@ use Symfony\Component\Finder\Finder; /** + * @phpstan-import-type CallableHandler from ElementReference * @phpstan-import-type Handler from ElementReference * * @author Kyrian Obikwelu @@ -101,7 +108,7 @@ final class Builder /** * @var array{ - * handler: Handler, + * handler: CallableHandler, * name: ?string, * title: ?string, * description: ?string, @@ -116,7 +123,7 @@ final class Builder /** * @var array{ - * handler: Handler, + * handler: CallableHandler, * uri: string, * name: ?string, * description: ?string, @@ -131,7 +138,7 @@ final class Builder /** * @var array{ - * handler: Handler, + * handler: CallableHandler, * uriTemplate: string, * name: ?string, * description: ?string, @@ -144,7 +151,7 @@ final class Builder /** * @var array{ - * handler: Handler, + * handler: CallableHandler, * name: ?string, * title: ?string, * description: ?string, @@ -154,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; /** @@ -379,15 +406,15 @@ public function setProtocolVersion(ProtocolVersion $protocolVersion): self /** * Manually registers a tool handler. * - * @param \Closure|array{0: object|string, 1: string}|string|RuntimeToolHandlerInterface $handler - * @param ?string $title Optional human-readable title for display in UI - * @param array|null $inputSchema - * @param ?Icon[] $icons - * @param array|null $meta - * @param array|null $outputSchema + * @param CallableHandler $handler + * @param ?string $title Optional human-readable title for display in UI + * @param array|null $inputSchema + * @param ?Icon[] $icons + * @param array|null $meta + * @param array|null $outputSchema */ public function addTool( - callable|array|string|RuntimeToolHandlerInterface $handler, + callable|array|string $handler, ?string $name = null, ?string $title = null, ?string $description = null, @@ -415,12 +442,12 @@ public function addTool( /** * Manually registers a resource handler. * - * @param \Closure|array{0: object|string, 1: string}|string|RuntimeResourceHandlerInterface $handler - * @param ?Icon[] $icons - * @param array|null $meta + * @param CallableHandler $handler + * @param ?Icon[] $icons + * @param array|null $meta */ public function addResource( - \Closure|array|string|RuntimeResourceHandlerInterface $handler, + \Closure|array|string $handler, string $uri, ?string $name = null, ?string $description = null, @@ -448,11 +475,11 @@ public function addResource( /** * Manually registers a resource template handler. * - * @param \Closure|array{0: object|string, 1: string}|string|RuntimeResourceTemplateHandlerInterface $handler - * @param array|null $meta + * @param CallableHandler $handler + * @param array|null $meta */ public function addResourceTemplate( - \Closure|array|string|RuntimeResourceTemplateHandlerInterface $handler, + \Closure|array|string $handler, string $uriTemplate, ?string $name = null, ?string $description = null, @@ -476,12 +503,12 @@ public function addResourceTemplate( /** * Manually registers a prompt handler. * - * @param \Closure|array{0: object|string, 1: string}|string|RuntimePromptHandlerInterface $handler - * @param ?Icon[] $icons - * @param array|null $meta + * @param CallableHandler $handler + * @param ?Icon[] $icons + * @param array|null $meta */ public function addPrompt( - \Closure|array|string|RuntimePromptHandlerInterface $handler, + \Closure|array|string $handler, ?string $name = null, ?string $title = null, ?string $description = null, @@ -493,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. */ @@ -526,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/RuntimeResourceHandlerInterface.php b/src/Server/Handler/ElementHandlerInterface.php similarity index 51% rename from src/Server/Handler/RuntimeResourceHandlerInterface.php rename to src/Server/Handler/ElementHandlerInterface.php index 564b371f..6b08955d 100644 --- a/src/Server/Handler/RuntimeResourceHandlerInterface.php +++ b/src/Server/Handler/ElementHandlerInterface.php @@ -12,14 +12,12 @@ namespace Mcp\Server\Handler; /** - * Runtime handler that backs an MCP resource. - * - * Resources have no extra metadata beyond the base contract; this interface - * exists so {@see \Mcp\Server\Builder::addResource()} can reject runtime - * handlers intended for other element kinds at the type level. + * Marker contract shared by all explicit element handler interfaces (tool, + * resource, resource template, prompt). Used as a single type hint where any + * element handler is acceptable. * * @author Mateu Aguiló Bosch */ -interface RuntimeResourceHandlerInterface extends RuntimeHandlerInterface +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/RuntimeHandlerInterface.php b/src/Server/Handler/RuntimeHandlerInterface.php deleted file mode 100644 index 8d0ac000..00000000 --- a/src/Server/Handler/RuntimeHandlerInterface.php +++ /dev/null @@ -1,37 +0,0 @@ - - */ -interface RuntimeHandlerInterface -{ - /** - * Executes the handler and returns its result. - * - * @param array $arguments the handler arguments as key-value pairs - * @param ClientGateway $gateway client gateway for handlers that support callbacks - * - * @return mixed the handler result - */ - public function execute(array $arguments, ClientGateway $gateway): mixed; -} diff --git a/src/Server/Handler/RuntimePromptHandlerInterface.php b/src/Server/Handler/RuntimePromptHandlerInterface.php deleted file mode 100644 index 493aa194..00000000 --- a/src/Server/Handler/RuntimePromptHandlerInterface.php +++ /dev/null @@ -1,37 +0,0 @@ - - */ -interface RuntimePromptHandlerInterface extends RuntimeHandlerInterface -{ - /** - * Returns the prompt arguments for this handler, or an empty array when the prompt takes no arguments. - * - * @return list<\Mcp\Schema\PromptArgument> - */ - public function getPromptArguments(): array; - - /** - * Returns the completion providers for the prompt arguments. - * - * Map of argument name => provider class-string or provider instance. - * Returns an empty array when no completion providers apply. - * - * @return array - */ - public function getCompletionProviders(): array; -} diff --git a/src/Server/Handler/RuntimeResourceTemplateHandlerInterface.php b/src/Server/Handler/RuntimeResourceTemplateHandlerInterface.php deleted file mode 100644 index e753a01b..00000000 --- a/src/Server/Handler/RuntimeResourceTemplateHandlerInterface.php +++ /dev/null @@ -1,30 +0,0 @@ - - */ -interface RuntimeResourceTemplateHandlerInterface extends RuntimeHandlerInterface -{ - /** - * Returns the completion providers for the URI template variables. - * - * Map of variable name => provider class-string or provider instance. - * Returns an empty array when no completion providers apply. - * - * @return array - */ - public function getCompletionProviders(): array; -} diff --git a/src/Server/Handler/RuntimeToolHandlerInterface.php b/src/Server/Handler/RuntimeToolHandlerInterface.php deleted file mode 100644 index d815085b..00000000 --- a/src/Server/Handler/RuntimeToolHandlerInterface.php +++ /dev/null @@ -1,40 +0,0 @@ - - */ -interface RuntimeToolHandlerInterface extends RuntimeHandlerInterface -{ - /** - * Returns the JSON Schema describing tool inputs. - * - * The Builder's `inputSchema:` named argument, when supplied, takes precedence - * over this value. - * - * @return array - */ - public function getInputSchema(): array; - - /** - * Returns the JSON Schema describing tool outputs. - * - * Returns null when no output schema applies, or when the Builder caller - * supplies the schema via the `outputSchema:` named argument. - * - * @return array|null - */ - public function getOutputSchema(): ?array; -} 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/Fixtures/Runtime/BareResourceHandler.php b/tests/Fixtures/Runtime/BareResourceHandler.php deleted file mode 100644 index 4cf3e5a2..00000000 --- a/tests/Fixtures/Runtime/BareResourceHandler.php +++ /dev/null @@ -1,23 +0,0 @@ - 'object']; - } - - public function getOutputSchema(): array - { - return ['type' => 'object', 'properties' => ['from' => ['const' => 'handler']]]; - } - - public function execute(array $arguments, ClientGateway $gateway): mixed - { - return ['from' => 'handler']; - } -} diff --git a/tests/Fixtures/Runtime/PromptRuntimeHandler.php b/tests/Fixtures/Runtime/PromptRuntimeHandler.php deleted file mode 100644 index 3a562450..00000000 --- a/tests/Fixtures/Runtime/PromptRuntimeHandler.php +++ /dev/null @@ -1,35 +0,0 @@ - new ListCompletionProvider(['hello', 'world'])]; - } - - public function execute(array $arguments, ClientGateway $gateway): mixed - { - return []; - } -} diff --git a/tests/Fixtures/Runtime/ResourceTemplateRuntimeHandler.php b/tests/Fixtures/Runtime/ResourceTemplateRuntimeHandler.php deleted file mode 100644 index 9c019db5..00000000 --- a/tests/Fixtures/Runtime/ResourceTemplateRuntimeHandler.php +++ /dev/null @@ -1,29 +0,0 @@ - new ListCompletionProvider(['alice', 'bob'])]; - } - - public function execute(array $arguments, ClientGateway $gateway): mixed - { - return ['ok' => true]; - } -} diff --git a/tests/Fixtures/Runtime/SchemaToolHandler.php b/tests/Fixtures/Runtime/SchemaToolHandler.php deleted file mode 100644 index 80e3d4f5..00000000 --- a/tests/Fixtures/Runtime/SchemaToolHandler.php +++ /dev/null @@ -1,33 +0,0 @@ - 'object', 'properties' => ['x' => ['type' => 'string']]]; - } - - public function getOutputSchema(): ?array - { - return null; - } - - public function execute(array $arguments, ClientGateway $gateway): mixed - { - return ['ok' => true]; - } -} diff --git a/tests/Unit/Capability/Registry/Loader/ArrayLoaderRuntimeHandlerTest.php b/tests/Unit/Capability/Registry/Loader/ArrayLoaderRuntimeHandlerTest.php deleted file mode 100644 index 7ecd82c0..00000000 --- a/tests/Unit/Capability/Registry/Loader/ArrayLoaderRuntimeHandlerTest.php +++ /dev/null @@ -1,209 +0,0 @@ -buildAndGetRegistry(static fn (Server\Builder $b) => $b->addTool( - handler: $handler, - name: 'demo', - description: 'Demo tool', - )); - - $reference = $registry->getTool('demo'); - $this->assertSame('demo', $reference->tool->name); - $this->assertSame('Demo tool', $reference->tool->description); - $this->assertSame($handler->getInputSchema(), $reference->tool->inputSchema); - } - - public function testAddToolPrefersInputSchemaKwargOverHandler(): void - { - $handler = new SchemaToolHandler(); - $kwargSchema = ['type' => 'object', 'properties' => ['y' => ['type' => 'integer']]]; - - $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->addTool( - handler: $handler, - name: 'demo', - description: 'Demo tool', - inputSchema: $kwargSchema, - )); - - $this->assertSame($kwargSchema, $registry->getTool('demo')->tool->inputSchema); - } - - public function testAddToolPrefersOutputSchemaKwargOverHandler(): void - { - $handler = new OutputSchemaToolHandler(); - $kwargOutput = ['type' => 'object', 'properties' => ['from' => ['const' => 'kwarg']]]; - - $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->addTool( - handler: $handler, - name: 'demo', - description: 'Demo tool', - outputSchema: $kwargOutput, - )); - - $this->assertSame($kwargOutput, $registry->getTool('demo')->tool->outputSchema); - } - - public function testAddToolUsesOutputSchemaFromHandlerWhenNoKwarg(): void - { - $handler = new OutputSchemaToolHandler(); - - $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->addTool( - handler: $handler, - name: 'demo', - description: 'Demo tool', - )); - - $this->assertSame($handler->getOutputSchema(), $registry->getTool('demo')->tool->outputSchema); - } - - public function testAddToolWithoutNameRaisesConfigurationException(): void - { - $this->expectException(ConfigurationException::class); - $this->expectExceptionMessageMatches('/'.preg_quote(SchemaToolHandler::class, '/').'/'); - - $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->addTool( - handler: new SchemaToolHandler(), - description: 'no name', - )); - } - - public function testAddToolWithoutDescriptionRaisesConfigurationException(): void - { - $this->expectException(ConfigurationException::class); - $this->expectExceptionMessageMatches('/'.preg_quote(SchemaToolHandler::class, '/').'/'); - - $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->addTool( - handler: new SchemaToolHandler(), - name: 'demo', - )); - } - - public function testAddResourceRegistersRuntimeHandler(): void - { - $handler = new BareResourceHandler(); - - $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->addResource( - handler: $handler, - uri: 'config://app/settings', - name: 'app_settings', - description: 'App settings', - mimeType: 'application/json', - )); - - $reference = $registry->getResource('config://app/settings', false); - $this->assertSame('app_settings', $reference->resource->name); - $this->assertSame('App settings', $reference->resource->description); - $this->assertSame('application/json', $reference->resource->mimeType); - } - - public function testAddResourceWithoutNameRaisesConfigurationException(): void - { - $this->expectException(ConfigurationException::class); - $this->expectExceptionMessageMatches('/'.preg_quote(BareResourceHandler::class, '/').'/'); - - $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->addResource( - handler: new BareResourceHandler(), - uri: 'config://x', - description: 'no name', - )); - } - - public function testAddResourceTemplateRegistersRuntimeHandlerWithCompletionProviders(): void - { - $handler = new ResourceTemplateRuntimeHandler(); - - $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->addResourceTemplate( - handler: $handler, - uriTemplate: 'user://{userId}/profile', - name: 'user_profile', - description: 'User profile by ID', - mimeType: 'application/json', - )); - - $reference = $registry->getResourceTemplate('user://{userId}/profile'); - $this->assertSame('user_profile', $reference->resourceTemplate->name); - $this->assertSame('application/json', $reference->resourceTemplate->mimeType); - $this->assertArrayHasKey('userId', $reference->completionProviders); - $this->assertInstanceOf(ListCompletionProvider::class, $reference->completionProviders['userId']); - } - - public function testAddResourceTemplateWithoutDescriptionRaisesConfigurationException(): void - { - $this->expectException(ConfigurationException::class); - $this->expectExceptionMessageMatches('/'.preg_quote(ResourceTemplateRuntimeHandler::class, '/').'/'); - - $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->addResourceTemplate( - handler: new ResourceTemplateRuntimeHandler(), - uriTemplate: 'user://{userId}', - name: 'user', - )); - } - - public function testAddPromptRegistersRuntimeHandlerWithArgumentsFromHandler(): void - { - $handler = new PromptRuntimeHandler(); - - $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->addPrompt( - handler: $handler, - name: 'ask', - description: 'Ask the assistant a question', - )); - - $reference = $registry->getPrompt('ask'); - $this->assertSame('ask', $reference->prompt->name); - $this->assertEquals($handler->getPromptArguments(), $reference->prompt->arguments); - $this->assertArrayHasKey('q', $reference->completionProviders); - $this->assertInstanceOf(ListCompletionProvider::class, $reference->completionProviders['q']); - } - - public function testAddPromptWithoutNameRaisesConfigurationException(): void - { - $this->expectException(ConfigurationException::class); - $this->expectExceptionMessageMatches('/'.preg_quote(PromptRuntimeHandler::class, '/').'/'); - - $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->addPrompt( - handler: new PromptRuntimeHandler(), - description: 'no name', - )); - } - - /** - * @param callable(Server\Builder): Server\Builder $configure - */ - private function buildAndGetRegistry(callable $configure): \Mcp\Capability\RegistryInterface - { - $registry = new \Mcp\Capability\Registry(); - $builder = Server::builder() - ->setServerInfo('test', '1.0.0') - ->setRegistry($registry); - $configure($builder)->build(); - - return $registry; - } -} 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 index 6ef5071c..92a2f33a 100644 --- a/tests/Unit/Capability/Registry/ReferenceHandlerTest.php +++ b/tests/Unit/Capability/Registry/ReferenceHandlerTest.php @@ -15,19 +15,20 @@ use Mcp\Capability\Registry\ReferenceHandler; use Mcp\Exception\InvalidArgumentException; use Mcp\Server\ClientGateway; -use Mcp\Server\Handler\RuntimeHandlerInterface; +use Mcp\Server\Handler\ResourceHandlerInterface; +use Mcp\Server\Handler\ToolHandlerInterface; use Mcp\Server\Session\SessionInterface; use PHPUnit\Framework\TestCase; use Symfony\Component\Uid\Uuid; final class ReferenceHandlerTest extends TestCase { - public function testHandleDispatchesToRuntimeHandlerAndForwardsClientGateway(): void + public function testHandleDispatchesToToolHandlerAndForwardsClientGateway(): void { $session = $this->createMock(SessionInterface::class); $session->method('getId')->willReturn(Uuid::v4()); - $runtimeHandler = new class implements RuntimeHandlerInterface { + $toolHandler = new class implements ToolHandlerInterface { /** @var array|null */ public ?array $executedWith = null; public ?ClientGateway $receivedGateway = null; @@ -37,11 +38,11 @@ public function execute(array $arguments, ClientGateway $gateway): mixed $this->executedWith = $arguments; $this->receivedGateway = $gateway; - return 'runtime-result'; + return 'tool-result'; } }; - $reference = new ElementReference($runtimeHandler, true); + $reference = new ElementReference($toolHandler, true); $referenceHandler = new ReferenceHandler(); $request = new \stdClass(); @@ -52,25 +53,25 @@ public function execute(array $arguments, ClientGateway $gateway): mixed 'other' => 'value2', ]); - $this->assertSame('runtime-result', $result); + $this->assertSame('tool-result', $result); $this->assertSame( ['kept' => 'value', 'other' => 'value2'], - $runtimeHandler->executedWith, + $toolHandler->executedWith, ); - $this->assertInstanceOf(ClientGateway::class, $runtimeHandler->receivedGateway); + $this->assertInstanceOf(ClientGateway::class, $toolHandler->receivedGateway); } - public function testRuntimeHandlerTakesPriorityOverInvokeAndCallableDetection(): void + public function testToolHandlerTakesPriorityOverInvokeAndCallableDetection(): void { $session = $this->createMock(SessionInterface::class); $session->method('getId')->willReturn(Uuid::v4()); - $runtimeHandler = new class implements RuntimeHandlerInterface { + $toolHandler = new class implements ToolHandlerInterface { public bool $executed = false; public function __invoke(): string { - throw new \LogicException('__invoke must not be called when RuntimeHandlerInterface is implemented'); + throw new \LogicException('__invoke must not be called when ToolHandlerInterface is implemented'); } public function execute(array $arguments, ClientGateway $gateway): mixed @@ -81,11 +82,44 @@ public function execute(array $arguments, ClientGateway $gateway): mixed } }; - $reference = new ElementReference($runtimeHandler); + $reference = new ElementReference($toolHandler); $referenceHandler = new ReferenceHandler(); $this->assertSame('priority-ok', $referenceHandler->handle($reference, ['_session' => $session])); - $this->assertTrue($runtimeHandler->executed); + $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