From 0347dc85b4e577037f2fa177ac3fccd6aca7d8d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateu=20Aguil=C3=B3=20Bosch?= Date: Fri, 29 May 2026 07:16:12 +0200 Subject: [PATCH] [Server] Allow runtime-resolved element handlers (#294) * [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. * 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. * 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. * 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. * 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. * 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/. * chore: please the linting gods * 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. * 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. * 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. * doc: update docs/server-builder.md Co-authored-by: Christopher Hertel * feat: peer revie feedback Introduce a new `Builder::add(...)` pattern for a more generic runtime handler. * refactor(server): apply PR 294 review feedback Move per-interface handler dispatch from ReferenceHandler into ExplicitElementLoader by wrapping each ElementHandlerInterface in a Closure that satisfies the existing callable contract, revert the *Reference / Registry handler-type widening PR 294 introduced, throw Mcp\Exception\InvalidArgumentException (not \TypeError) on mismatched Builder::add() pairings, and rename two classes for clarity: * Mcp\Capability\Registry\Loader\ArrayLoader -> ReflectedElementLoader * Mcp\Schema\Resource -> Mcp\Schema\ResourceDefinition (no alias) ReferenceHandler::prepareArguments() now injects ClientGateway by type and binds an `array $arguments` / `array $variables` closure parameter from the leftover inbound arguments. This addresses Antoine's coupling concern (ReferenceHandler no longer knows about the four handler interfaces) and Chris's package-exception preference, and clears the phpdoc reserved-word collision Antoine flagged on the resource schema class. Existing tests are re-targeted to exercise the closure path through ExplicitElementLoader / ReferenceHandler. * doc: address PR 294 review feedback Apply Chris's line-level suggestions on docs/server-builder.md (rewrap to 120 chars, smoothed Drupal mcp module name-drop in the explicit-element registration section, exception name reads Mcp\Exception\InvalidArgumentException), rewrap docs/mcp-elements.md to 120 chars, and add 0.6.0 CHANGELOG entries covering the Builder::add() entry point, the new handler interfaces, the new ExplicitElementLoader, plus three BC breaks (Resource -> ResourceDefinition rename, ArrayLoader -> ReflectedElementLoader rename, mismatched-pairing exception swap). * refactor: address self-review feedback * CHANGELOG: drop spurious [BC Break] entry for the exception type Builder::add() raises (method is new in this PR). * docs/server-builder: revert wrap-only edits to pre-existing main content; keep 120ch wrapping only for new sections. * ExplicitElementLoader: trim verbose class docblock. * ReferenceHandler: rename SPLAT_PARAMETER_NAMES constant to ARGUMENT_BAG_PARAMETERS. * refactor: rename CallableHandler to Handler * style: fix phpdoc param alignment * refactor: inline $reference->handler access * fix: maintainer review feedback docs: mention ExplicitElementLoader in comment docs: move Drupal reference to README list docs: drop implementation-only changelog entry docs: revert rewrap noise from mcp-elements.md * style: fix phpdoc param alignment --------- Co-authored-by: Christopher Hertel Co-authored-by: soyuka --- CHANGELOG.md | 4 + README.md | 1 + docs/mcp-elements.md | 3 + docs/server-builder.md | 56 +++ src/Capability/Discovery/Discoverer.php | 4 +- src/Capability/Registry.php | 4 +- .../Registry/Loader/ExplicitElementLoader.php | 105 +++++ ...yLoader.php => ReflectedElementLoader.php} | 6 +- src/Capability/Registry/ReferenceHandler.php | 15 + src/Capability/Registry/ResourceReference.php | 4 +- src/Capability/RegistryInterface.php | 4 +- src/Schema/Page.php | 4 +- .../{Resource.php => ResourceDefinition.php} | 12 +- src/Schema/Result/ListResourcesResult.php | 14 +- src/Server/Builder.php | 82 +++- .../Handler/ElementHandlerInterface.php | 23 ++ src/Server/Handler/PromptHandlerInterface.php | 28 ++ .../Handler/ResourceHandlerInterface.php | 25 ++ .../ResourceTemplateHandlerInterface.php | 28 ++ src/Server/Handler/ToolHandlerInterface.php | 28 ++ .../Discovery/DiscoveryStateTest.php | 4 +- .../Registry/Loader/DiscoveryLoaderTest.php | 6 +- .../Loader/ExplicitElementLoaderTest.php | 384 ++++++++++++++++++ ...flectedElementLoaderResourceTitleTest.php} | 8 +- ...> ReflectedElementLoaderToolTitleTest.php} | 6 +- .../Registry/ReferenceHandlerTest.php | 144 +++++++ tests/Unit/Capability/RegistryTest.php | 10 +- ...rceTest.php => ResourceDefinitionTest.php} | 32 +- .../Request/ListResourcesHandlerTest.php | 4 +- .../Request/ReadResourceHandlerTest.php | 14 +- .../Handler/Request/ResourceSubscribeTest.php | 6 +- .../Request/ResourceUnsubscribeTest.php | 6 +- 32 files changed, 996 insertions(+), 78 deletions(-) create mode 100644 src/Capability/Registry/Loader/ExplicitElementLoader.php rename src/Capability/Registry/Loader/{ArrayLoader.php => ReflectedElementLoader.php} (98%) rename src/Schema/{Resource.php => ResourceDefinition.php} (95%) create mode 100644 src/Server/Handler/ElementHandlerInterface.php create mode 100644 src/Server/Handler/PromptHandlerInterface.php create mode 100644 src/Server/Handler/ResourceHandlerInterface.php create mode 100644 src/Server/Handler/ResourceTemplateHandlerInterface.php create mode 100644 src/Server/Handler/ToolHandlerInterface.php create mode 100644 tests/Unit/Capability/Registry/Loader/ExplicitElementLoaderTest.php rename tests/Unit/Capability/Registry/Loader/{ArrayLoaderResourceTitleTest.php => ReflectedElementLoaderResourceTitleTest.php} (88%) rename tests/Unit/Capability/Registry/Loader/{ArrayLoaderToolTitleTest.php => ReflectedElementLoaderToolTitleTest.php} (87%) create mode 100644 tests/Unit/Capability/Registry/ReferenceHandlerTest.php rename tests/Unit/Schema/{ResourceTest.php => ResourceDefinitionTest.php} (81%) diff --git a/CHANGELOG.md b/CHANGELOG.md index d72de1f..1d94d50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to `mcp/sdk` will be documented in this file. 0.6.0 ----- +* Add `Builder::add(Tool|ResourceDefinition|ResourceTemplate|Prompt $definition, ElementHandlerInterface $handler)` for explicit registration of elements whose schema is only known at runtime. +* Add handler interfaces `ToolHandlerInterface`, `ResourceHandlerInterface`, `ResourceTemplateHandlerInterface`, `PromptHandlerInterface`, and the `ElementHandlerInterface` marker. +* [BC Break] Renamed `Mcp\Schema\Resource` to `Mcp\Schema\ResourceDefinition`. No alias. +* [BC Break] Renamed `Mcp\Capability\Registry\Loader\ArrayLoader` to `Mcp\Capability\Registry\Loader\ReflectedElementLoader`. * [BC Break] Bump default protocol version to `2025-11-25` * Allow overriding the default name pattern for Discovery * Add configurable session garbage collection (`gcProbability`/`gcDivisor`) diff --git a/README.md b/README.md index bc97892..8b3bccf 100644 --- a/README.md +++ b/README.md @@ -290,6 +290,7 @@ $client->connect($transport); - [api-platform/mcp](https://github.com/api-platform/mcp) — MCP integration for API Platform - [bnomei/kirby-mcp](https://github.com/bnomei/kirby-mcp) — MCP server for the Kirby CMS +- [drupal/mcp_server](https://www.drupal.org/project/mcp_server) — MCP server for Drupal exposing configuration and entities as MCP elements - [josbeir/cakephp-synapse](https://github.com/josbeir/cakephp-synapse) — CakePHP plugin exposing application functionality over MCP - [nette/mcp-inspector](https://github.com/nette/mcp-inspector) — MCP server for introspecting Nette applications - [symfony/ai-mate](https://github.com/symfony/ai-mate) — AI development assistant MCP server for Symfony projects diff --git a/docs/mcp-elements.md b/docs/mcp-elements.md index b1a045f..2e4a151 100644 --- a/docs/mcp-elements.md +++ b/docs/mcp-elements.md @@ -42,6 +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 whose shape is not known at compile time, see +[Explicit element registration](server-builder.md#explicit-element-registration) in the Server Builder docs. + ## Tools Tools are callable functions that perform actions and return results. diff --git a/docs/server-builder.md b/docs/server-builder.md index a48e96d..9b7dfa8 100644 --- a/docs/server-builder.md +++ b/docs/server-builder.md @@ -398,6 +398,61 @@ the handler's method name and docblock. For more details on MCP elements, handlers, and attribute-based discovery, see [MCP Elements](mcp-elements.md). +### Explicit element registration + +When an element's name, schema, or description is only known at runtime, pair an `Mcp\Schema\*` value object with one of +the four handler interfaces below and register it through `Builder::add()`. + +| Element kind | Handler interface | +|-------------------|-------------------------------------------------------| +| Tool | `Mcp\Server\Handler\ToolHandlerInterface` | +| Resource | `Mcp\Server\Handler\ResourceHandlerInterface` | +| Resource template | `Mcp\Server\Handler\ResourceTemplateHandlerInterface` | +| Prompt | `Mcp\Server\Handler\PromptHandlerInterface` | + +Each handler interface declares a single execution method. Tool and prompt handlers receive an arguments map and a +`ClientGateway`. Resource handlers receive the requested URI; resource template handlers additionally receive the parsed +template variables. + +```php +use Mcp\Schema\Tool; +use Mcp\Server; +use Mcp\Server\ClientGateway; +use Mcp\Server\Handler\ToolHandlerInterface; + +final class WeatherHandler implements ToolHandlerInterface +{ + public function execute(array $arguments, ClientGateway $gateway): mixed + { + return ['temperature' => 21, 'unit' => 'C']; + } +} + +$tool = new Tool( + name: 'get_weather', + title: null, + inputSchema: [ + 'type' => 'object', + 'properties' => ['city' => ['type' => 'string']], + 'required' => ['city'], + ], + description: 'Returns the current weather for a city.', + annotations: null, +); + +$server = Server::builder() + ->add($tool, new WeatherHandler()) + ->build(); +``` + +`Builder::add()` validates the pairing at registration time. Pairing a `Tool` definition with, for example, a +`PromptHandlerInterface` raises `Mcp\Exception\InvalidArgumentException`. The schema value object validates its own +inputs (name pattern, schema shape, etc.), so passing an incomplete definition fails before `add()` returns. + +Use `add()` when the metadata cannot be inferred from a handler class via reflection. For statically-known elements, +prefer `addTool/addResource/addResourceTemplate/addPrompt`, which can derive metadata from the handler's signature and +docblock. + ## Service Dependencies ### Container @@ -619,4 +674,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/Discovery/Discoverer.php b/src/Capability/Discovery/Discoverer.php index 9304c48..dab6590 100644 --- a/src/Capability/Discovery/Discoverer.php +++ b/src/Capability/Discovery/Discoverer.php @@ -27,7 +27,7 @@ use Mcp\Exception\ExceptionInterface; use Mcp\Exception\RuntimeException; use Mcp\Schema\Prompt; use Mcp\Schema\PromptArgument; -use Mcp\Schema\Resource; +use Mcp\Schema\ResourceDefinition; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\Tool; use Psr\Log\LoggerInterface; @@ -249,7 +249,7 @@ final class Discoverer implements DiscovererInterface $docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null); $name = $instance->name ?? ('__invoke' === $methodName ? $classShortName : $methodName); $description = $instance->description ?? $this->docBlockParser->getDescription($docBlock) ?? null; - $resource = new Resource( + $resource = new ResourceDefinition( $instance->uri, $name, $instance->title, diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index c8fdca3..23167b6 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -26,7 +26,7 @@ use Mcp\Exception\ResourceNotFoundException; use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\Page; use Mcp\Schema\Prompt; -use Mcp\Schema\Resource; +use Mcp\Schema\ResourceDefinition; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\Tool; use Psr\EventDispatcher\EventDispatcherInterface; @@ -83,7 +83,7 @@ final class Registry implements RegistryInterface return $reference; } - public function registerResource(Resource $resource, callable|array|string $handler): ResourceReference + public function registerResource(ResourceDefinition $resource, callable|array|string $handler): ResourceReference { $reference = new ResourceReference($resource, $handler); $this->resources[$resource->uri] = $reference; diff --git a/src/Capability/Registry/Loader/ExplicitElementLoader.php b/src/Capability/Registry/Loader/ExplicitElementLoader.php new file mode 100644 index 0000000..4733ab9 --- /dev/null +++ b/src/Capability/Registry/Loader/ExplicitElementLoader.php @@ -0,0 +1,105 @@ + + */ +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) { + $handler = $entry['handler']; + $registry->registerTool($entry['definition'], $this->boundClosure( + static function (array $arguments) use ($handler): mixed { + $gateway = new ClientGateway($arguments['_session']); + unset($arguments['_session'], $arguments['_request']); + + return $handler->execute($arguments, $gateway); + }, + )); + } + + foreach ($this->resources as $entry) { + $handler = $entry['handler']; + $registry->registerResource($entry['definition'], $this->boundClosure( + static fn (array $arguments): mixed => $handler->read( + $arguments['uri'], + new ClientGateway($arguments['_session']), + ), + )); + } + + foreach ($this->resourceTemplates as $entry) { + $handler = $entry['handler']; + $registry->registerResourceTemplate($entry['definition'], $this->boundClosure( + static function (array $arguments) use ($handler): mixed { + $gateway = new ClientGateway($arguments['_session']); + $uri = $arguments['uri']; + unset($arguments['_session'], $arguments['_request'], $arguments['uri']); + + return $handler->read($uri, $arguments, $gateway); + }, + ), $entry['completionProviders']); + } + + foreach ($this->prompts as $entry) { + $handler = $entry['handler']; + $registry->registerPrompt($entry['definition'], $this->boundClosure( + static function (array $arguments) use ($handler): mixed { + $gateway = new ClientGateway($arguments['_session']); + unset($arguments['_session'], $arguments['_request']); + + return $handler->get($arguments, $gateway); + }, + ), $entry['completionProviders']); + } + } + + private function boundClosure(\Closure $closure): \Closure + { + return \Closure::bind($closure, null, ReferenceHandler::class); + } +} diff --git a/src/Capability/Registry/Loader/ArrayLoader.php b/src/Capability/Registry/Loader/ReflectedElementLoader.php similarity index 98% rename from src/Capability/Registry/Loader/ArrayLoader.php rename to src/Capability/Registry/Loader/ReflectedElementLoader.php index 8865b0f..a87dd58 100644 --- a/src/Capability/Registry/Loader/ArrayLoader.php +++ b/src/Capability/Registry/Loader/ReflectedElementLoader.php @@ -26,7 +26,7 @@ use Mcp\Schema\Annotations; use Mcp\Schema\Icon; use Mcp\Schema\Prompt; use Mcp\Schema\PromptArgument; -use Mcp\Schema\Resource; +use Mcp\Schema\ResourceDefinition; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\Tool; use Mcp\Schema\ToolAnnotations; @@ -39,7 +39,7 @@ use Psr\Log\NullLogger; * * @phpstan-import-type Handler from ElementReference */ -final class ArrayLoader implements LoaderInterface +final class ReflectedElementLoader implements LoaderInterface { /** * @param array{ @@ -156,7 +156,7 @@ final class ArrayLoader implements LoaderInterface $description = $data['description'] ?? $docBlockParser->getDescription($docBlock) ?? null; } - $resource = new Resource( + $resource = new ResourceDefinition( uri: $data['uri'], name: $name, title: $data['title'] ?? null, diff --git a/src/Capability/Registry/ReferenceHandler.php b/src/Capability/Registry/ReferenceHandler.php index 7b4a0cd..99e5844 100644 --- a/src/Capability/Registry/ReferenceHandler.php +++ b/src/Capability/Registry/ReferenceHandler.php @@ -13,6 +13,7 @@ namespace Mcp\Capability\Registry; use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\RegistryException; +use Mcp\Server\ClientGateway; use Mcp\Server\RequestContext; use Mcp\Server\Session\SessionInterface; use Psr\Container\ContainerInterface; @@ -32,6 +33,15 @@ final class ReferenceHandler implements ReferenceHandlerInterface */ public function handle(ElementReference $reference, array $arguments): mixed { + // Closures bound to this class as their scope consume the raw argument bag + // directly. Used by ExplicitElementLoader so reflection + name-based parameter + // mapping is bypassed for explicitly registered handler interfaces. + if ($reference->handler instanceof \Closure + && self::class === (new \ReflectionFunction($reference->handler))->getClosureScopeClass()?->getName() + ) { + return ($reference->handler)($arguments); + } + $session = $arguments['_session']; if (\is_string($reference->handler)) { @@ -102,6 +112,11 @@ final class ReferenceHandler implements ReferenceHandlerInterface $finalArgs[$paramPosition] = new RequestContext($arguments['_session'], $arguments['_request']); continue; } + + if (ClientGateway::class === $typeName && isset($arguments['_session'])) { + $finalArgs[$paramPosition] = new ClientGateway($arguments['_session']); + continue; + } } if (isset($arguments[$paramName])) { diff --git a/src/Capability/Registry/ResourceReference.php b/src/Capability/Registry/ResourceReference.php index bec8b9b..10d7837 100644 --- a/src/Capability/Registry/ResourceReference.php +++ b/src/Capability/Registry/ResourceReference.php @@ -13,7 +13,7 @@ namespace Mcp\Capability\Registry; use Mcp\Capability\Formatter\ResourceResultFormatter; use Mcp\Schema\Content\ResourceContents; -use Mcp\Schema\Resource; +use Mcp\Schema\ResourceDefinition; /** * @phpstan-import-type Handler from ElementReference @@ -26,7 +26,7 @@ class ResourceReference extends ElementReference * @param Handler $handler */ public function __construct( - public readonly Resource $resource, + public readonly ResourceDefinition $resource, callable|array|string $handler, ) { parent::__construct($handler); diff --git a/src/Capability/RegistryInterface.php b/src/Capability/RegistryInterface.php index 124704b..bbbef76 100644 --- a/src/Capability/RegistryInterface.php +++ b/src/Capability/RegistryInterface.php @@ -21,7 +21,7 @@ use Mcp\Exception\ResourceNotFoundException; use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\Page; use Mcp\Schema\Prompt; -use Mcp\Schema\Resource; +use Mcp\Schema\ResourceDefinition; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\Tool; @@ -47,7 +47,7 @@ interface RegistryInterface * * @param Handler $handler */ - public function registerResource(Resource $resource, callable|array|string $handler): ResourceReference; + public function registerResource(ResourceDefinition $resource, callable|array|string $handler): ResourceReference; /** * Registers a resource template with its handler and completion providers. diff --git a/src/Schema/Page.php b/src/Schema/Page.php index bad46e9..3d54646 100644 --- a/src/Schema/Page.php +++ b/src/Schema/Page.php @@ -12,14 +12,14 @@ namespace Mcp\Schema; /** - * @phpstan-type PageItem Tool|Prompt|ResourceTemplate|Resource + * @phpstan-type PageItem Tool|Prompt|ResourceTemplate|ResourceDefinition * * @extends \ArrayObject */ final class Page extends \ArrayObject { /** - * @param array $references Items can be Tool, Prompt, ResourceTemplate, or Resource + * @param array $references Items can be Tool, Prompt, ResourceTemplate, or ResourceDefinition */ public function __construct( public readonly array $references, diff --git a/src/Schema/Resource.php b/src/Schema/ResourceDefinition.php similarity index 95% rename from src/Schema/Resource.php rename to src/Schema/ResourceDefinition.php index 00fc267..a399966 100644 --- a/src/Schema/Resource.php +++ b/src/Schema/ResourceDefinition.php @@ -19,7 +19,7 @@ use Mcp\Exception\InvalidArgumentException; * @phpstan-import-type AnnotationsData from Annotations * @phpstan-import-type IconData from Icon * - * @phpstan-type ResourceData array{ + * @phpstan-type ResourceDefinitionData array{ * uri: string, * name: string, * title?: string, @@ -33,7 +33,7 @@ use Mcp\Exception\InvalidArgumentException; * * @author Kyrian Obikwelu */ -class Resource implements \JsonSerializable +class ResourceDefinition implements \JsonSerializable { /** * Resource name pattern regex - must contain only alphanumeric characters, underscores, and hyphens. @@ -77,19 +77,19 @@ class Resource implements \JsonSerializable } /** - * @param ResourceData $data + * @param ResourceDefinitionData $data */ public static function fromArray(array $data): self { if (empty($data['uri']) || !\is_string($data['uri'])) { - throw new InvalidArgumentException('Invalid or missing "uri" in Resource data.'); + throw new InvalidArgumentException('Invalid or missing "uri" in ResourceDefinition data.'); } if (empty($data['name']) || !\is_string($data['name'])) { - throw new InvalidArgumentException('Invalid or missing "name" in Resource data.'); + throw new InvalidArgumentException('Invalid or missing "name" in ResourceDefinition data.'); } if (!empty($data['_meta']) && !\is_array($data['_meta'])) { - throw new InvalidArgumentException('Invalid "_meta" in Resource data.'); + throw new InvalidArgumentException('Invalid "_meta" in ResourceDefinition data.'); } return new self( diff --git a/src/Schema/Result/ListResourcesResult.php b/src/Schema/Result/ListResourcesResult.php index 50fa9bf..0f338ca 100644 --- a/src/Schema/Result/ListResourcesResult.php +++ b/src/Schema/Result/ListResourcesResult.php @@ -14,20 +14,20 @@ namespace Mcp\Schema\Result; use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\JsonRpc\ResultInterface; -use Mcp\Schema\Resource as ResourceSchema; +use Mcp\Schema\ResourceDefinition; /** * The server's response to a resources/list request from the client. * - * @phpstan-import-type ResourceData from ResourceSchema + * @phpstan-import-type ResourceDefinitionData from ResourceDefinition * * @author Kyrian Obikwelu */ class ListResourcesResult implements ResultInterface { /** - * @param array $resources the list of resource definitions - * @param string|null $nextCursor An opaque token representing the pagination position after the last returned result. + * @param array $resources the list of resource definitions + * @param string|null $nextCursor An opaque token representing the pagination position after the last returned result. * * If present, there may be more results available. */ @@ -39,7 +39,7 @@ class ListResourcesResult implements ResultInterface /** * @param array{ - * resources: array, + * resources: array, * nextCursor?: string, * } $data */ @@ -50,14 +50,14 @@ class ListResourcesResult implements ResultInterface } return new self( - array_map(static fn (array $resource) => ResourceSchema::fromArray($resource), $data['resources']), + array_map(static fn (array $resource) => ResourceDefinition::fromArray($resource), $data['resources']), $data['nextCursor'] ?? null ); } /** * @return array{ - * resources: array, + * resources: array, * nextCursor?: string, * } */ diff --git a/src/Server/Builder.php b/src/Server/Builder.php index ba26d23..29add5a 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -11,6 +11,7 @@ namespace Mcp\Server; +use Mcp\Capability\Completion\ProviderInterface; use Mcp\Capability\Discovery\CachedDiscoverer; use Mcp\Capability\Discovery\Discoverer; use Mcp\Capability\Discovery\DiscovererInterface; @@ -18,10 +19,11 @@ 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\ChainLoader; use Mcp\Capability\Registry\Loader\DiscoveryLoader; +use Mcp\Capability\Registry\Loader\ExplicitElementLoader; use Mcp\Capability\Registry\Loader\LoaderInterface; +use Mcp\Capability\Registry\Loader\ReflectedElementLoader; use Mcp\Capability\Registry\ReferenceHandler; use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\RegistryInterface; @@ -31,11 +33,20 @@ use Mcp\Schema\Annotations; use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Icon; use Mcp\Schema\Implementation; +use Mcp\Schema\Prompt; +use Mcp\Schema\ResourceDefinition; +use Mcp\Schema\ResourceTemplate; use Mcp\Schema\ServerCapabilities; +use Mcp\Schema\Tool; use Mcp\Schema\ToolAnnotations; use Mcp\Server; +use Mcp\Server\Handler\ElementHandlerInterface; use Mcp\Server\Handler\Notification\NotificationHandlerInterface; +use Mcp\Server\Handler\PromptHandlerInterface; use Mcp\Server\Handler\Request\RequestHandlerInterface; +use Mcp\Server\Handler\ResourceHandlerInterface; +use Mcp\Server\Handler\ResourceTemplateHandlerInterface; +use Mcp\Server\Handler\ToolHandlerInterface; use Mcp\Server\Resource\SessionSubscriptionManager; use Mcp\Server\Resource\SubscriptionManagerInterface; use Mcp\Server\Session\InMemorySessionStore; @@ -107,6 +118,7 @@ final class Builder * title: ?string, * description: ?string, * annotations: ?ToolAnnotations, + * inputSchema: ?array, * icons: ?Icon[], * meta: ?array, * outputSchema: ?array, @@ -148,6 +160,7 @@ final class Builder * @var array{ * handler: Handler, * name: ?string, + * title: ?string, * description: ?string, * icons: ?Icon[], * meta: ?array @@ -155,6 +168,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; /** @@ -518,6 +551,41 @@ final class Builder 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 + * `Mcp\Exception\InvalidArgumentException`. Completion providers are only supported on + * `Prompt` and `ResourceTemplate` definitions; supplying them with `Tool` or + * `ResourceDefinition` raises the same exception. + * + * @param array $completionProviders Keyed by argument/variable name + */ + public function add( + Tool|ResourceDefinition|ResourceTemplate|Prompt $definition, + ElementHandlerInterface $handler, + array $completionProviders = [], + ): self { + if ([] !== $completionProviders && ($definition instanceof Tool || $definition instanceof ResourceDefinition)) { + throw new InvalidArgumentException(\sprintf('Completion providers are only supported on Prompt and ResourceTemplate definitions, got %s.', $definition::class)); + } + + match (true) { + $definition instanceof Tool && $handler instanceof ToolHandlerInterface => $this->explicitTools[] = ['definition' => $definition, 'handler' => $handler], + $definition instanceof ResourceDefinition && $handler instanceof ResourceHandlerInterface => $this->explicitResources[] = ['definition' => $definition, 'handler' => $handler], + $definition instanceof ResourceTemplate && $handler instanceof ResourceTemplateHandlerInterface => $this->explicitResourceTemplates[] = ['definition' => $definition, 'handler' => $handler, 'completionProviders' => $completionProviders], + $definition instanceof Prompt && $handler instanceof PromptHandlerInterface => $this->explicitPrompts[] = ['definition' => $definition, 'handler' => $handler, 'completionProviders' => $completionProviders], + default => throw new InvalidArgumentException(\sprintf('%s definition cannot be paired with %s; expected the matching handler interface.', $definition::class, $handler::class)), + }; + + return $this; + } + /** * Register a single custom loader. */ @@ -556,11 +624,17 @@ final class Builder $this->gcDivisor, ); - // ArrayLoader runs before DiscoveryLoader so manual entries are seen first; DiscoveryLoader's - // identity check then preserves them against same-name discovered entries. + // ExplicitElementLoader and ReflectedElementLoader run before DiscoveryLoader so manual entries are seen first; + // DiscoveryLoader's identity check then preserves them against same-name discovered entries. $loaders = [ ...$this->loaders, - new ArrayLoader($this->tools, $this->resources, $this->resourceTemplates, $this->prompts, $logger, $this->schemaGenerator), + new ExplicitElementLoader( + $this->explicitTools, + $this->explicitResources, + $this->explicitResourceTemplates, + $this->explicitPrompts, + ), + new ReflectedElementLoader($this->tools, $this->resources, $this->resourceTemplates, $this->prompts, $logger, $this->schemaGenerator), ]; if (null !== $this->discoveryBasePath) { diff --git a/src/Server/Handler/ElementHandlerInterface.php b/src/Server/Handler/ElementHandlerInterface.php new file mode 100644 index 0000000..6b08955 --- /dev/null +++ b/src/Server/Handler/ElementHandlerInterface.php @@ -0,0 +1,23 @@ + + */ +interface ElementHandlerInterface +{ +} diff --git a/src/Server/Handler/PromptHandlerInterface.php b/src/Server/Handler/PromptHandlerInterface.php new file mode 100644 index 0000000..4143c97 --- /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 0000000..7ca533a --- /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 0000000..892e439 --- /dev/null +++ b/src/Server/Handler/ResourceTemplateHandlerInterface.php @@ -0,0 +1,28 @@ + + */ +interface ResourceTemplateHandlerInterface extends ElementHandlerInterface +{ + /** + * @param array $variables + */ + public function read(string $uri, array $variables, ClientGateway $gateway): mixed; +} diff --git a/src/Server/Handler/ToolHandlerInterface.php b/src/Server/Handler/ToolHandlerInterface.php new file mode 100644 index 0000000..4f2ee3e --- /dev/null +++ b/src/Server/Handler/ToolHandlerInterface.php @@ -0,0 +1,28 @@ + + */ +interface ToolHandlerInterface extends ElementHandlerInterface +{ + /** + * @param array $arguments + */ + public function execute(array $arguments, ClientGateway $gateway): mixed; +} diff --git a/tests/Unit/Capability/Discovery/DiscoveryStateTest.php b/tests/Unit/Capability/Discovery/DiscoveryStateTest.php index b5b60b1..e40c722 100644 --- a/tests/Unit/Capability/Discovery/DiscoveryStateTest.php +++ b/tests/Unit/Capability/Discovery/DiscoveryStateTest.php @@ -17,7 +17,7 @@ use Mcp\Capability\Registry\ResourceReference; use Mcp\Capability\Registry\ResourceTemplateReference; use Mcp\Capability\Registry\ToolReference; use Mcp\Schema\Prompt; -use Mcp\Schema\Resource; +use Mcp\Schema\ResourceDefinition; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\Tool; use PHPUnit\Framework\TestCase; @@ -141,7 +141,7 @@ class DiscoveryStateTest extends TestCase private function resource(string $uri): ResourceReference { return new ResourceReference( - new Resource(uri: $uri, name: 'r', description: null, mimeType: 'text/plain'), + new ResourceDefinition(uri: $uri, name: 'r', description: null, mimeType: 'text/plain'), static fn () => null, ); } diff --git a/tests/Unit/Capability/Registry/Loader/DiscoveryLoaderTest.php b/tests/Unit/Capability/Registry/Loader/DiscoveryLoaderTest.php index eaab2df..dcb1d8d 100644 --- a/tests/Unit/Capability/Registry/Loader/DiscoveryLoaderTest.php +++ b/tests/Unit/Capability/Registry/Loader/DiscoveryLoaderTest.php @@ -24,7 +24,7 @@ use Mcp\Exception\PromptNotFoundException; use Mcp\Exception\ResourceNotFoundException; use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\Prompt; -use Mcp\Schema\Resource; +use Mcp\Schema\ResourceDefinition; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\Tool; use Mcp\Tests\Unit\Capability\Registry\Loader\Stub\MutableDiscoverer; @@ -267,9 +267,9 @@ class DiscoveryLoaderTest extends TestCase ); } - private function makeResource(string $uri): Resource + private function makeResource(string $uri): ResourceDefinition { - return new Resource(uri: $uri, name: 'r', description: null, mimeType: 'text/plain'); + return new ResourceDefinition(uri: $uri, name: 'r', description: null, mimeType: 'text/plain'); } private function makePrompt(string $name): Prompt diff --git a/tests/Unit/Capability/Registry/Loader/ExplicitElementLoaderTest.php b/tests/Unit/Capability/Registry/Loader/ExplicitElementLoaderTest.php new file mode 100644 index 0000000..0066a31 --- /dev/null +++ b/tests/Unit/Capability/Registry/Loader/ExplicitElementLoaderTest.php @@ -0,0 +1,384 @@ + '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); + + $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 ResourceDefinition( + 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); + + $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); + + $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); + + $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); + } + + public function testAddPromptForwardsCompletionProvidersToRegistry(): void + { + $prompt = new Prompt(name: 'greet', title: null, description: null); + $handler = new class implements PromptHandlerInterface { + public function get(array $arguments, ClientGateway $gateway): mixed + { + return null; + } + }; + $provider = new class implements ProviderInterface { + public function getCompletions(string $currentValue): array + { + return ['alice', 'bob']; + } + }; + + $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->add( + $prompt, + $handler, + ['name' => $provider], + )); + + $reference = $registry->getPrompt('greet'); + $this->assertSame(['name' => $provider], $reference->completionProviders); + } + + public function testAddResourceTemplateForwardsCompletionProvidersToRegistry(): void + { + $template = new ResourceTemplate( + uriTemplate: 'config://{key}', + name: 'config_template', + description: null, + ); + $handler = new class implements ResourceTemplateHandlerInterface { + public function read(string $uri, array $variables, ClientGateway $gateway): mixed + { + return null; + } + }; + $provider = new class implements ProviderInterface { + public function getCompletions(string $currentValue): array + { + return ['alpha', 'beta']; + } + }; + + $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->add( + $template, + $handler, + ['key' => $provider], + )); + + $reference = $registry->getResourceTemplate('config://{key}'); + $this->assertSame(['key' => $provider], $reference->completionProviders); + } + + public function testAddPromptWithoutCompletionProvidersDefaultsToEmptyArray(): void + { + $prompt = new Prompt(name: 'no_completion', title: null, description: null); + $handler = new class implements PromptHandlerInterface { + public function get(array $arguments, ClientGateway $gateway): mixed + { + return null; + } + }; + + $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->add($prompt, $handler)); + + $this->assertSame([], $registry->getPrompt('no_completion')->completionProviders); + } + + public function testAddToolWithCompletionProvidersThrows(): void + { + $tool = new Tool( + name: 'noop', + title: null, + inputSchema: ['type' => 'object', 'properties' => [], 'required' => []], + description: null, + annotations: null, + ); + $handler = new class implements ToolHandlerInterface { + public function execute(array $arguments, ClientGateway $gateway): mixed + { + return null; + } + }; + $provider = new class implements ProviderInterface { + public function getCompletions(string $currentValue): array + { + return []; + } + }; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Completion providers are only supported on Prompt and ResourceTemplate'); + + Server::builder() + ->setServerInfo('test', '1.0.0') + ->add($tool, $handler, ['foo' => $provider]); + } + + public function testAddResourceWithCompletionProvidersThrows(): void + { + $resource = new ResourceDefinition(uri: 'config://demo', name: 'demo'); + $handler = new class implements ResourceHandlerInterface { + public function read(string $uri, ClientGateway $gateway): mixed + { + return null; + } + }; + $provider = new class implements ProviderInterface { + public function getCompletions(string $currentValue): array + { + return []; + } + }; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Completion providers are only supported on Prompt and ResourceTemplate'); + + Server::builder() + ->setServerInfo('test', '1.0.0') + ->add($resource, $handler, ['foo' => $provider]); + } + + public function testMismatchedDefinitionAndHandlerThrowsInvalidArgumentException(): void + { + $prompt = new Prompt(name: 'mismatched', title: null, description: null); + $toolHandler = new class implements ToolHandlerInterface { + public function execute(array $arguments, ClientGateway $gateway): mixed + { + return null; + } + }; + + $this->expectException(InvalidArgumentException::class); + + Server::builder() + ->setServerInfo('test', '1.0.0') + ->add($prompt, $toolHandler); + } + + public function testLoaderRegistersClosuresRatherThanHandlerInstances(): void + { + $tool = new Tool( + name: 'closure_check', + title: null, + inputSchema: ['type' => 'object', 'properties' => [], 'required' => []], + description: null, + annotations: null, + ); + $handler = new class implements ToolHandlerInterface { + public function execute(array $arguments, ClientGateway $gateway): mixed + { + return null; + } + }; + + $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->add($tool, $handler)); + + $reference = $registry->getTool('closure_check'); + $this->assertInstanceOf(\Closure::class, $reference->handler); + } + + /** + * @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/Loader/ArrayLoaderResourceTitleTest.php b/tests/Unit/Capability/Registry/Loader/ReflectedElementLoaderResourceTitleTest.php similarity index 88% rename from tests/Unit/Capability/Registry/Loader/ArrayLoaderResourceTitleTest.php rename to tests/Unit/Capability/Registry/Loader/ReflectedElementLoaderResourceTitleTest.php index bd01207..6dd0999 100644 --- a/tests/Unit/Capability/Registry/Loader/ArrayLoaderResourceTitleTest.php +++ b/tests/Unit/Capability/Registry/Loader/ReflectedElementLoaderResourceTitleTest.php @@ -12,10 +12,10 @@ namespace Mcp\Tests\Unit\Capability\Registry\Loader; use Mcp\Capability\Registry; -use Mcp\Capability\Registry\Loader\ArrayLoader; +use Mcp\Capability\Registry\Loader\ReflectedElementLoader; use PHPUnit\Framework\TestCase; -class ArrayLoaderResourceTitleTest extends TestCase +class ReflectedElementLoaderResourceTitleTest extends TestCase { public function testLoadPropagatesResourceTitleToRegisteredResource(): void { @@ -34,7 +34,7 @@ class ArrayLoaderResourceTitleTest extends TestCase ], ]; - $loader = new ArrayLoader([], $resources); + $loader = new ReflectedElementLoader([], $resources); $registry = new Registry(); $loader->load($registry); @@ -58,7 +58,7 @@ class ArrayLoaderResourceTitleTest extends TestCase ], ]; - $loader = new ArrayLoader([], [], $resourceTemplates); + $loader = new ReflectedElementLoader([], [], $resourceTemplates); $registry = new Registry(); $loader->load($registry); diff --git a/tests/Unit/Capability/Registry/Loader/ArrayLoaderToolTitleTest.php b/tests/Unit/Capability/Registry/Loader/ReflectedElementLoaderToolTitleTest.php similarity index 87% rename from tests/Unit/Capability/Registry/Loader/ArrayLoaderToolTitleTest.php rename to tests/Unit/Capability/Registry/Loader/ReflectedElementLoaderToolTitleTest.php index 68a1bfb..8537fcc 100644 --- a/tests/Unit/Capability/Registry/Loader/ArrayLoaderToolTitleTest.php +++ b/tests/Unit/Capability/Registry/Loader/ReflectedElementLoaderToolTitleTest.php @@ -12,10 +12,10 @@ namespace Mcp\Tests\Unit\Capability\Registry\Loader; use Mcp\Capability\Registry; -use Mcp\Capability\Registry\Loader\ArrayLoader; +use Mcp\Capability\Registry\Loader\ReflectedElementLoader; use PHPUnit\Framework\TestCase; -class ArrayLoaderToolTitleTest extends TestCase +class ReflectedElementLoaderToolTitleTest extends TestCase { public function testLoadPropagatesToolTitleToRegisteredTool(): void { @@ -37,7 +37,7 @@ class ArrayLoaderToolTitleTest extends TestCase ], ]; - $loader = new ArrayLoader($tools); + $loader = new ReflectedElementLoader($tools); $registry = new Registry(); $loader->load($registry); diff --git a/tests/Unit/Capability/Registry/ReferenceHandlerTest.php b/tests/Unit/Capability/Registry/ReferenceHandlerTest.php new file mode 100644 index 0000000..dadca9f --- /dev/null +++ b/tests/Unit/Capability/Registry/ReferenceHandlerTest.php @@ -0,0 +1,144 @@ +createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + + $toolHandler = new class implements ToolHandlerInterface { + /** @var array|null */ + public ?array $executedWith = null; + public ?ClientGateway $receivedGateway = null; + + public function execute(array $arguments, ClientGateway $gateway): mixed + { + $this->executedWith = $arguments; + $this->receivedGateway = $gateway; + + return 'tool-result'; + } + }; + + $closure = \Closure::bind( + static function (array $arguments) use ($toolHandler): mixed { + $gateway = new ClientGateway($arguments['_session']); + unset($arguments['_session'], $arguments['_request']); + + return $toolHandler->execute($arguments, $gateway); + }, + null, + ReferenceHandler::class, + ); + $reference = new ElementReference($closure); + + $result = (new ReferenceHandler())->handle($reference, [ + '_session' => $session, + '_request' => new \stdClass(), + 'kept' => 'value', + 'other' => 'value2', + ]); + + $this->assertSame('tool-result', $result); + $this->assertSame( + ['kept' => 'value', 'other' => 'value2'], + $toolHandler->executedWith, + ); + $this->assertInstanceOf(ClientGateway::class, $toolHandler->receivedGateway); + } + + public function testHandleDispatchesToBoundResourceClosureWithRawArgumentBag(): 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']; + } + }; + + $closure = \Closure::bind( + static fn (array $arguments): mixed => $resourceHandler->read( + $arguments['uri'], + new ClientGateway($arguments['_session']), + ), + null, + ReferenceHandler::class, + ); + $reference = new ElementReference($closure); + + $result = (new ReferenceHandler())->handle($reference, [ + '_session' => $session, + '_request' => new \stdClass(), + 'uri' => 'config://x', + ]); + + $this->assertSame(['contents' => 'r-ok'], $result); + $this->assertSame('config://x', $resourceHandler->receivedUri); + $this->assertInstanceOf(ClientGateway::class, $resourceHandler->receivedGateway); + } + + public function testHandleStillReflectsOrdinaryClosuresAndDoesNotInjectArgumentBag(): void + { + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + + $captured = null; + $closure = static function (string $kept) use (&$captured): string { + $captured = $kept; + + return $kept; + }; + $reference = new ElementReference($closure); + + $result = (new ReferenceHandler())->handle($reference, [ + '_session' => $session, + '_request' => new \stdClass(), + 'kept' => 'value', + ]); + + $this->assertSame('value', $result); + $this->assertSame('value', $captured); + } + + 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]); + } +} diff --git a/tests/Unit/Capability/RegistryTest.php b/tests/Unit/Capability/RegistryTest.php index 5173aaf..a4d2780 100644 --- a/tests/Unit/Capability/RegistryTest.php +++ b/tests/Unit/Capability/RegistryTest.php @@ -21,7 +21,7 @@ use Mcp\Exception\PromptNotFoundException; use Mcp\Exception\ResourceNotFoundException; use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\Prompt; -use Mcp\Schema\Resource; +use Mcp\Schema\ResourceDefinition; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\Tool; use PHPUnit\Framework\MockObject\MockObject; @@ -124,8 +124,8 @@ class RegistryTest extends TestCase $this->assertCount(2, $resources); $this->assertArrayHasKey('test://resource1', $resources->references); $this->assertArrayHasKey('test://resource2', $resources->references); - $this->assertInstanceOf(Resource::class, $resources->references['test://resource1']); - $this->assertInstanceOf(Resource::class, $resources->references['test://resource2']); + $this->assertInstanceOf(ResourceDefinition::class, $resources->references['test://resource1']); + $this->assertInstanceOf(ResourceDefinition::class, $resources->references['test://resource2']); } public function testGetResourceReturnsRegisteredResource(): void @@ -554,9 +554,9 @@ class RegistryTest extends TestCase ); } - private function createValidResource(string $uri): Resource + private function createValidResource(string $uri): ResourceDefinition { - return new Resource( + return new ResourceDefinition( uri: $uri, name: 'test_resource', description: 'Test resource', diff --git a/tests/Unit/Schema/ResourceTest.php b/tests/Unit/Schema/ResourceDefinitionTest.php similarity index 81% rename from tests/Unit/Schema/ResourceTest.php rename to tests/Unit/Schema/ResourceDefinitionTest.php index 7a2d9a1..ab39f41 100644 --- a/tests/Unit/Schema/ResourceTest.php +++ b/tests/Unit/Schema/ResourceDefinitionTest.php @@ -12,11 +12,11 @@ namespace Mcp\Tests\Unit\Schema; use Mcp\Exception\InvalidArgumentException; -use Mcp\Schema\Resource; +use Mcp\Schema\ResourceDefinition; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -class ResourceTest extends TestCase +class ResourceDefinitionTest extends TestCase { private const VALID_URI = 'https://example.com/list-books'; @@ -24,12 +24,12 @@ class ResourceTest extends TestCase { $uri = self::VALID_URI; - $resource = new Resource( + $resource = new ResourceDefinition( uri: $uri, name: 'list-books', ); - $this->assertInstanceOf(Resource::class, $resource); + $this->assertInstanceOf(ResourceDefinition::class, $resource); $this->assertSame($uri, $resource->uri); } @@ -40,7 +40,7 @@ class ResourceTest extends TestCase $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invalid resource URI: "/list-books" must be a valid URI with a scheme and optional path.'); - $resource = new Resource( + $resource = new ResourceDefinition( uri: $uri, name: 'list-books', ); @@ -49,12 +49,12 @@ class ResourceTest extends TestCase #[DataProvider('provideValidUris')] public function testConstructorAcceptsUris(string $uri): void { - $resource = new Resource( + $resource = new ResourceDefinition( uri: $uri, name: 'test-resource', ); - $this->assertInstanceOf(Resource::class, $resource); + $this->assertInstanceOf(ResourceDefinition::class, $resource); $this->assertSame($uri, $resource->uri); } @@ -69,12 +69,12 @@ class ResourceTest extends TestCase public function testFromArrayValid(): void { - $resource = Resource::fromArray([ + $resource = ResourceDefinition::fromArray([ 'uri' => self::VALID_URI, 'name' => 'list-books', ]); - $this->assertInstanceOf(Resource::class, $resource); + $this->assertInstanceOf(ResourceDefinition::class, $resource); $this->assertSame(self::VALID_URI, $resource->uri); $this->assertSame('list-books', $resource->name); $this->assertNull($resource->title); @@ -84,7 +84,7 @@ class ResourceTest extends TestCase public function testTitleFromArray(): void { - $resource = Resource::fromArray([ + $resource = ResourceDefinition::fromArray([ 'uri' => self::VALID_URI, 'name' => 'list-books', 'title' => 'Book Listing', @@ -95,7 +95,7 @@ class ResourceTest extends TestCase public function testTitleSerialization(): void { - $resource = new Resource( + $resource = new ResourceDefinition( uri: self::VALID_URI, name: 'list-books', title: 'Book Listing', @@ -107,7 +107,7 @@ class ResourceTest extends TestCase public function testTitleOmittedWhenNull(): void { - $resource = new Resource( + $resource = new ResourceDefinition( uri: self::VALID_URI, name: 'list-books', ); @@ -122,15 +122,15 @@ class ResourceTest extends TestCase $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage($expectedExceptionMessage); - Resource::fromArray($input); + ResourceDefinition::fromArray($input); } public static function provideInvalidResources(): iterable { - yield 'missing uri' => [[], 'Invalid or missing "uri" in Resource data.']; + yield 'missing uri' => [[], 'Invalid or missing "uri" in ResourceDefinition data.']; yield 'missing name' => [ ['uri' => self::VALID_URI], - 'Invalid or missing "name" in Resource data.', + 'Invalid or missing "name" in ResourceDefinition data.', ]; yield 'meta' => [ [ @@ -138,7 +138,7 @@ class ResourceTest extends TestCase 'name' => 'list-books', '_meta' => 'foo', ], - 'Invalid "_meta" in Resource data.', + 'Invalid "_meta" in ResourceDefinition data.', ]; } } diff --git a/tests/Unit/Server/Handler/Request/ListResourcesHandlerTest.php b/tests/Unit/Server/Handler/Request/ListResourcesHandlerTest.php index dca1c31..3e385fe 100644 --- a/tests/Unit/Server/Handler/Request/ListResourcesHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/ListResourcesHandlerTest.php @@ -14,7 +14,7 @@ namespace Mcp\Tests\Unit\Server\Handler\Request; use Mcp\Capability\Registry; use Mcp\Exception\InvalidCursorException; use Mcp\Schema\Request\ListResourcesRequest; -use Mcp\Schema\Resource; +use Mcp\Schema\ResourceDefinition; use Mcp\Schema\Result\ListResourcesResult; use Mcp\Server\Handler\Request\ListResourcesHandler; use Mcp\Server\Session\InMemorySessionStore; @@ -221,7 +221,7 @@ class ListResourcesHandlerTest extends TestCase private function addResourcesToRegistry(int $count): void { for ($i = 0; $i < $count; ++$i) { - $resource = new Resource( + $resource = new ResourceDefinition( uri: "resource://test/resource_$i", name: "resource_$i", description: "Test resource $i" diff --git a/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php b/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php index 66a2296..efdc914 100644 --- a/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php @@ -21,7 +21,7 @@ use Mcp\Schema\Content\TextResourceContents; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ReadResourceRequest; -use Mcp\Schema\Resource; +use Mcp\Schema\ResourceDefinition; use Mcp\Schema\Result\ReadResourceResult; use Mcp\Server\Handler\Request\ReadResourceHandler; use Mcp\Server\Session\SessionInterface; @@ -66,7 +66,7 @@ class ReadResourceHandlerTest extends TestCase $expectedResult = new ReadResourceResult([$expectedContent]); $resourceReference = $this->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'text/plain'), []]) + ->setConstructorArgs([new ResourceDefinition($uri, 'test', mimeType: 'text/plain'), []]) ->getMock(); $this->registry @@ -106,7 +106,7 @@ class ReadResourceHandlerTest extends TestCase $expectedResult = new ReadResourceResult([$expectedContent]); $resourceReference = $this->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'image/png'), []]) + ->setConstructorArgs([new ResourceDefinition($uri, 'test', mimeType: 'image/png'), []]) ->getMock(); $this->registry @@ -150,7 +150,7 @@ class ReadResourceHandlerTest extends TestCase $expectedResult = new ReadResourceResult([$textContent, $blobContent]); $resourceReference = $this->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'application/octet-stream'), []]) + ->setConstructorArgs([new ResourceDefinition($uri, 'test', mimeType: 'application/octet-stream'), []]) ->getMock(); $this->registry @@ -273,7 +273,7 @@ class ReadResourceHandlerTest extends TestCase $expectedResult = new ReadResourceResult([$expectedContent]); $resourceReference = $this->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'text/plain'), []]) + ->setConstructorArgs([new ResourceDefinition($uri, 'test', mimeType: 'text/plain'), []]) ->getMock(); $this->registry @@ -318,7 +318,7 @@ class ReadResourceHandlerTest extends TestCase $expectedResult = new ReadResourceResult([$expectedContent]); $resourceReference = $this->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'text/plain'), []]) + ->setConstructorArgs([new ResourceDefinition($uri, 'test', mimeType: 'text/plain'), []]) ->getMock(); $this->registry @@ -380,7 +380,7 @@ class ReadResourceHandlerTest extends TestCase $expectedResult = new ReadResourceResult([$expectedContent]); $resourceReference = $this->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([new Resource($uri, 'test', mimeType: $mimeType), []]) + ->setConstructorArgs([new ResourceDefinition($uri, 'test', mimeType: $mimeType), []]) ->getMock(); $this->registry diff --git a/tests/Unit/Server/Handler/Request/ResourceSubscribeTest.php b/tests/Unit/Server/Handler/Request/ResourceSubscribeTest.php index 02e2e5c..b7d491b 100644 --- a/tests/Unit/Server/Handler/Request/ResourceSubscribeTest.php +++ b/tests/Unit/Server/Handler/Request/ResourceSubscribeTest.php @@ -18,7 +18,7 @@ use Mcp\Exception\ResourceNotFoundException; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ResourceSubscribeRequest; -use Mcp\Schema\Resource; +use Mcp\Schema\ResourceDefinition; use Mcp\Schema\Result\EmptyResult; use Mcp\Server\Handler\Request\ResourceSubscribeHandler; use Mcp\Server\Resource\SubscriptionManagerInterface; @@ -51,7 +51,7 @@ class ResourceSubscribeTest extends TestCase $uri = 'file://documents/readme.txt'; $request = $this->createResourceSubscribeRequest($uri); $resourceReference = $this->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'text/plain'), []]) + ->setConstructorArgs([new ResourceDefinition($uri, 'test', mimeType: 'text/plain'), []]) ->getMock(); $this->registry @@ -77,7 +77,7 @@ class ResourceSubscribeTest extends TestCase $uri = 'file://documents/readme.txt'; $request = $this->createResourceSubscribeRequest($uri); $resourceReference = $this->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'text/plain'), []]) + ->setConstructorArgs([new ResourceDefinition($uri, 'test', mimeType: 'text/plain'), []]) ->getMock(); $this->registry diff --git a/tests/Unit/Server/Handler/Request/ResourceUnsubscribeTest.php b/tests/Unit/Server/Handler/Request/ResourceUnsubscribeTest.php index d71dfb3..5586d05 100644 --- a/tests/Unit/Server/Handler/Request/ResourceUnsubscribeTest.php +++ b/tests/Unit/Server/Handler/Request/ResourceUnsubscribeTest.php @@ -18,7 +18,7 @@ use Mcp\Exception\ResourceNotFoundException; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ResourceUnsubscribeRequest; -use Mcp\Schema\Resource; +use Mcp\Schema\ResourceDefinition; use Mcp\Schema\Result\EmptyResult; use Mcp\Server\Handler\Request\ResourceUnsubscribeHandler; use Mcp\Server\Resource\SubscriptionManagerInterface; @@ -53,7 +53,7 @@ class ResourceUnsubscribeTest extends TestCase $uri = 'file://documents/readme.txt'; $request = $this->createResourceUnsubscribeRequest($uri); $resourceReference = $this->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'text/plain'), []]) + ->setConstructorArgs([new ResourceDefinition($uri, 'test', mimeType: 'text/plain'), []]) ->getMock(); $this->registry @@ -82,7 +82,7 @@ class ResourceUnsubscribeTest extends TestCase $uri = 'file://documents/readme.txt'; $request = $this->createResourceUnsubscribeRequest($uri); $resourceReference = $this->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'text/plain'), []]) + ->setConstructorArgs([new ResourceDefinition($uri, 'test', mimeType: 'text/plain'), []]) ->getMock(); $this->registry