From 6dcfda71fade2d111b49435ca0fc1ae039031717 Mon Sep 17 00:00:00 2001 From: Mateu Aguilo Bosch Date: Mon, 1 Dec 2025 08:38:33 +0100 Subject: [PATCH 1/4] feat(capability): add dynamic provider interfaces Introduce three new interfaces for runtime-defined MCP elements: - DynamicToolProviderInterface for dynamic tool registration and execution - DynamicPromptProviderInterface for dynamic prompt handling - DynamicResourceProviderInterface for dynamic resource management These interfaces enable CMS plugins, database-driven tools, and proxy servers to expose MCP elements without reflection-based parameter mapping. --- .../Formatter/PromptResultFormatter.php | 267 +++++++++++++ .../Formatter/ResourceResultFormatter.php | 200 ++++++++++ .../Formatter/ToolResultFormatter.php | 103 +++++ .../DynamicPromptProviderInterface.php | 41 ++ .../DynamicResourceProviderInterface.php | 33 ++ ...namicResourceTemplateProviderInterface.php | 38 ++ .../Provider/DynamicToolProviderInterface.php | 36 ++ src/Capability/Registry.php | 356 +++++++++++++++++- .../Registry/ArgumentPreparationInterface.php | 25 ++ .../DynamicArgumentPreparationTrait.php | 32 ++ .../Registry/DynamicPromptReference.php | 62 +++ .../Registry/DynamicResourceReference.php | 58 +++ .../DynamicResourceTemplateReference.php | 88 +++++ .../Registry/DynamicToolReference.php | 58 +++ src/Capability/Registry/ElementReference.php | 31 +- src/Capability/Registry/PromptReference.php | 236 +----------- src/Capability/Registry/ReferenceHandler.php | 273 +++----------- .../ReflectionArgumentPreparationTrait.php | 208 ++++++++++ src/Capability/Registry/ResourceReference.php | 158 +------- .../Registry/ResourceTemplateReference.php | 198 +--------- src/Capability/Registry/ToolReference.php | 60 +-- .../Registry/UriTemplateMatcher.php | 113 ++++++ src/Capability/RegistryInterface.php | 102 +++++ src/Server/Builder.php | 89 ++++- .../Handler/Request/CallToolHandler.php | 6 +- .../Request/CompletionCompleteHandler.php | 25 +- .../Handler/Request/GetPromptHandler.php | 10 +- .../Handler/Request/ReadResourceHandler.php | 44 ++- .../Formatter/PromptResultFormatterTest.php | 49 +++ .../Formatter/ResourceResultFormatterTest.php | 40 ++ .../Formatter/ToolResultFormatterTest.php | 58 +++ .../Provider/DynamicPromptProviderTest.php | 120 ++++++ .../Provider/DynamicResourceProviderTest.php | 125 ++++++ .../DynamicResourceTemplateProviderTest.php | 136 +++++++ .../Provider/DynamicToolProviderTest.php | 127 +++++++ .../Fixtures/TestDynamicPromptProvider.php | 75 ++++ .../Fixtures/TestDynamicResourceProvider.php | 65 ++++ .../TestDynamicResourceTemplateProvider.php | 70 ++++ .../Fixtures/TestDynamicToolProvider.php | 59 +++ .../Request/CompletionCompleteHandlerTest.php | 219 +++++++++++ 40 files changed, 3215 insertions(+), 878 deletions(-) create mode 100644 src/Capability/Formatter/PromptResultFormatter.php create mode 100644 src/Capability/Formatter/ResourceResultFormatter.php create mode 100644 src/Capability/Formatter/ToolResultFormatter.php create mode 100644 src/Capability/Provider/DynamicPromptProviderInterface.php create mode 100644 src/Capability/Provider/DynamicResourceProviderInterface.php create mode 100644 src/Capability/Provider/DynamicResourceTemplateProviderInterface.php create mode 100644 src/Capability/Provider/DynamicToolProviderInterface.php create mode 100644 src/Capability/Registry/ArgumentPreparationInterface.php create mode 100644 src/Capability/Registry/DynamicArgumentPreparationTrait.php create mode 100644 src/Capability/Registry/DynamicPromptReference.php create mode 100644 src/Capability/Registry/DynamicResourceReference.php create mode 100644 src/Capability/Registry/DynamicResourceTemplateReference.php create mode 100644 src/Capability/Registry/DynamicToolReference.php create mode 100644 src/Capability/Registry/ReflectionArgumentPreparationTrait.php create mode 100644 src/Capability/Registry/UriTemplateMatcher.php create mode 100644 tests/Unit/Capability/Formatter/PromptResultFormatterTest.php create mode 100644 tests/Unit/Capability/Formatter/ResourceResultFormatterTest.php create mode 100644 tests/Unit/Capability/Formatter/ToolResultFormatterTest.php create mode 100644 tests/Unit/Capability/Provider/DynamicPromptProviderTest.php create mode 100644 tests/Unit/Capability/Provider/DynamicResourceProviderTest.php create mode 100644 tests/Unit/Capability/Provider/DynamicResourceTemplateProviderTest.php create mode 100644 tests/Unit/Capability/Provider/DynamicToolProviderTest.php create mode 100644 tests/Unit/Capability/Provider/Fixtures/TestDynamicPromptProvider.php create mode 100644 tests/Unit/Capability/Provider/Fixtures/TestDynamicResourceProvider.php create mode 100644 tests/Unit/Capability/Provider/Fixtures/TestDynamicResourceTemplateProvider.php create mode 100644 tests/Unit/Capability/Provider/Fixtures/TestDynamicToolProvider.php create mode 100644 tests/Unit/Server/Handler/Request/CompletionCompleteHandlerTest.php diff --git a/src/Capability/Formatter/PromptResultFormatter.php b/src/Capability/Formatter/PromptResultFormatter.php new file mode 100644 index 00000000..871bded7 --- /dev/null +++ b/src/Capability/Formatter/PromptResultFormatter.php @@ -0,0 +1,267 @@ + + * @author Mateu Aguiló Bosch + */ +final class PromptResultFormatter +{ + /** + * Formats the raw result of a prompt generator into an array of MCP PromptMessages. + * + * @param mixed $promptGenerationResult expected: array of message structures + * + * @return PromptMessage[] array of PromptMessage objects + * + * @throws \RuntimeException if the result cannot be formatted + * @throws \JsonException if JSON encoding fails + */ + public function format(mixed $promptGenerationResult): array + { + if ($promptGenerationResult instanceof PromptMessage) { + return [$promptGenerationResult]; + } + + if (!\is_array($promptGenerationResult)) { + throw new RuntimeException('Prompt generator method must return an array of messages.'); + } + + if (empty($promptGenerationResult)) { + return []; + } + + if (\is_array($promptGenerationResult)) { + $allArePromptMessages = true; + $hasPromptMessages = false; + + foreach ($promptGenerationResult as $item) { + if ($item instanceof PromptMessage) { + $hasPromptMessages = true; + } else { + $allArePromptMessages = false; + } + } + + if ($allArePromptMessages && $hasPromptMessages) { + return $promptGenerationResult; + } + + if ($hasPromptMessages) { + $result = []; + foreach ($promptGenerationResult as $index => $item) { + if ($item instanceof PromptMessage) { + $result[] = $item; + } else { + $result = array_merge($result, $this->format($item)); + } + } + + return $result; + } + + if (!array_is_list($promptGenerationResult)) { + if (isset($promptGenerationResult['user']) || isset($promptGenerationResult['assistant'])) { + $result = []; + if (isset($promptGenerationResult['user'])) { + $userContent = $this->formatContent($promptGenerationResult['user']); + $result[] = new PromptMessage(Role::User, $userContent); + } + if (isset($promptGenerationResult['assistant'])) { + $assistantContent = $this->formatContent($promptGenerationResult['assistant']); + $result[] = new PromptMessage(Role::Assistant, $assistantContent); + } + + return $result; + } + + if (isset($promptGenerationResult['role']) && isset($promptGenerationResult['content'])) { + return [$this->formatMessage($promptGenerationResult)]; + } + + throw new RuntimeException('Associative array must contain either role/content keys or user/assistant keys.'); + } + + $formattedMessages = []; + foreach ($promptGenerationResult as $index => $message) { + if ($message instanceof PromptMessage) { + $formattedMessages[] = $message; + } else { + $formattedMessages[] = $this->formatMessage($message, $index); + } + } + + return $formattedMessages; + } + + throw new RuntimeException('Invalid prompt generation result format.'); + } + + /** + * Formats a single message into a PromptMessage. + */ + private function formatMessage(mixed $message, ?int $index = null): PromptMessage + { + $indexStr = null !== $index ? " at index {$index}" : ''; + + if (!\is_array($message) || !\array_key_exists('role', $message) || !\array_key_exists('content', $message)) { + throw new RuntimeException("Invalid message format{$indexStr}. Expected an array with 'role' and 'content' keys."); + } + + $role = $message['role'] instanceof Role ? $message['role'] : Role::tryFrom($message['role']); + if (null === $role) { + throw new RuntimeException("Invalid role '{$message['role']}' in prompt message{$indexStr}. Only 'user' or 'assistant' are supported."); + } + + $content = $this->formatContent($message['content'], $index); + + return new PromptMessage($role, $content); + } + + /** + * Formats content into a proper Content object. + */ + private function formatContent(mixed $content, ?int $index = null): TextContent|ImageContent|AudioContent|EmbeddedResource + { + $indexStr = null !== $index ? " at index {$index}" : ''; + + if ($content instanceof Content) { + if ( + $content instanceof TextContent || $content instanceof ImageContent + || $content instanceof AudioContent || $content instanceof EmbeddedResource + ) { + return $content; + } + throw new RuntimeException("Invalid Content type{$indexStr}. PromptMessage only supports TextContent, ImageContent, AudioContent, or EmbeddedResource."); + } + + if (\is_string($content)) { + return new TextContent($content); + } + + if (\is_array($content) && isset($content['type'])) { + return $this->formatTypedContent($content, $index); + } + + if (\is_scalar($content) || null === $content) { + $stringContent = null === $content ? '(null)' : (\is_bool($content) ? ($content ? 'true' : 'false') : (string) $content); + + return new TextContent($stringContent); + } + + $jsonContent = json_encode($content, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR); + + return new TextContent($jsonContent); + } + + /** + * Formats typed content arrays into Content objects. + * + * @param array $content + */ + private function formatTypedContent(array $content, ?int $index = null): TextContent|ImageContent|AudioContent|EmbeddedResource + { + $indexStr = null !== $index ? " at index {$index}" : ''; + $type = $content['type']; + + return match ($type) { + 'text' => $this->formatTextContent($content, $indexStr), + 'image' => $this->formatImageContent($content, $indexStr), + 'audio' => $this->formatAudioContent($content, $indexStr), + 'resource' => $this->formatResourceContent($content, $indexStr), + default => throw new RuntimeException("Invalid content type '{$type}'{$indexStr}."), + }; + } + + /** + * @param array $content + */ + private function formatTextContent(array $content, string $indexStr): TextContent + { + if (!isset($content['text']) || !\is_string($content['text'])) { + throw new RuntimeException(\sprintf('Invalid "text" content%s: Missing or invalid "text" string.', $indexStr)); + } + + return new TextContent($content['text']); + } + + /** + * @param array $content + */ + private function formatImageContent(array $content, string $indexStr): ImageContent + { + if (!isset($content['data']) || !\is_string($content['data'])) { + throw new RuntimeException("Invalid 'image' content{$indexStr}: Missing or invalid 'data' string (base64)."); + } + if (!isset($content['mimeType']) || !\is_string($content['mimeType'])) { + throw new RuntimeException("Invalid 'image' content{$indexStr}: Missing or invalid 'mimeType' string."); + } + + return new ImageContent($content['data'], $content['mimeType']); + } + + /** + * @param array $content + */ + private function formatAudioContent(array $content, string $indexStr): AudioContent + { + if (!isset($content['data']) || !\is_string($content['data'])) { + throw new RuntimeException("Invalid 'audio' content{$indexStr}: Missing or invalid 'data' string (base64)."); + } + if (!isset($content['mimeType']) || !\is_string($content['mimeType'])) { + throw new RuntimeException("Invalid 'audio' content{$indexStr}: Missing or invalid 'mimeType' string."); + } + + return new AudioContent($content['data'], $content['mimeType']); + } + + /** + * @param array $content + */ + private function formatResourceContent(array $content, string $indexStr): EmbeddedResource + { + if (!isset($content['resource']) || !\is_array($content['resource'])) { + throw new RuntimeException("Invalid 'resource' content{$indexStr}: Missing or invalid 'resource' object."); + } + + $resource = $content['resource']; + if (!isset($resource['uri']) || !\is_string($resource['uri'])) { + throw new RuntimeException("Invalid resource{$indexStr}: Missing or invalid 'uri'."); + } + + if (isset($resource['text']) && \is_string($resource['text'])) { + $resourceObj = new TextResourceContents($resource['uri'], $resource['mimeType'] ?? 'text/plain', $resource['text']); + } elseif (isset($resource['blob']) && \is_string($resource['blob'])) { + $resourceObj = new BlobResourceContents( + $resource['uri'], + $resource['mimeType'] ?? 'application/octet-stream', + $resource['blob'] + ); + } else { + throw new RuntimeException("Invalid resource{$indexStr}: Must contain 'text' or 'blob'."); + } + + return new EmbeddedResource($resourceObj); + } +} diff --git a/src/Capability/Formatter/ResourceResultFormatter.php b/src/Capability/Formatter/ResourceResultFormatter.php new file mode 100644 index 00000000..828cc67f --- /dev/null +++ b/src/Capability/Formatter/ResourceResultFormatter.php @@ -0,0 +1,200 @@ + + * @author Mateu Aguiló Bosch + */ +final class ResourceResultFormatter +{ + /** + * Formats the raw result of a resource read operation into MCP ResourceContent items. + * + * @param mixed $readResult the raw result from the resource handler method + * @param string $uri the URI of the resource that was read + * @param string|null $mimeType the MIME type from the ResourceDefinition + * @param mixed $meta optional metadata to include in the ResourceContents + * + * @return ResourceContents[] array of ResourceContents objects + * + * @throws RuntimeException If the result cannot be formatted. + * + * Supported result types: + * - ResourceContents: Used as-is + * - EmbeddedResource: Resource is extracted from the EmbeddedResource + * - string: Converted to text content with guessed or provided MIME type + * - stream resource: Read and converted to blob with provided MIME type + * - array with 'blob' key: Used as blob content + * - array with 'text' key: Used as text content + * - SplFileInfo: Read and converted to blob + * - array: Converted to JSON if MIME type is application/json or contains 'json' + * For other MIME types, will also convert to JSON + */ + public function format(mixed $readResult, string $uri, ?string $mimeType = null, mixed $meta = null): array + { + if ($readResult instanceof ResourceContents) { + return [$readResult]; + } + + if ($readResult instanceof EmbeddedResource) { + return [$readResult->resource]; + } + + if (\is_array($readResult)) { + if (empty($readResult)) { + return [new TextResourceContents($uri, 'application/json', '[]', $meta)]; + } + + $allAreResourceContents = true; + $hasResourceContents = false; + $allAreEmbeddedResource = true; + $hasEmbeddedResource = false; + + foreach ($readResult as $item) { + if ($item instanceof ResourceContents) { + $hasResourceContents = true; + $allAreEmbeddedResource = false; + } elseif ($item instanceof EmbeddedResource) { + $hasEmbeddedResource = true; + $allAreResourceContents = false; + } else { + $allAreResourceContents = false; + $allAreEmbeddedResource = false; + } + } + + if ($allAreResourceContents && $hasResourceContents) { + return $readResult; + } + + if ($allAreEmbeddedResource && $hasEmbeddedResource) { + return array_map(fn ($item) => $item->resource, $readResult); + } + + if ($hasResourceContents || $hasEmbeddedResource) { + $result = []; + foreach ($readResult as $item) { + if ($item instanceof ResourceContents) { + $result[] = $item; + } elseif ($item instanceof EmbeddedResource) { + $result[] = $item->resource; + } else { + $result = array_merge($result, $this->format($item, $uri, $mimeType, $meta)); + } + } + + return $result; + } + } + + if (\is_string($readResult)) { + $mimeType = $mimeType ?? $this->guessMimeTypeFromString($readResult); + + return [new TextResourceContents($uri, $mimeType, $readResult, $meta)]; + } + + if (\is_resource($readResult) && 'stream' === get_resource_type($readResult)) { + $result = BlobResourceContents::fromStream( + $uri, + $readResult, + $mimeType ?? 'application/octet-stream', + $meta + ); + + @fclose($readResult); + + return [$result]; + } + + if (\is_array($readResult) && isset($readResult['blob']) && \is_string($readResult['blob'])) { + $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'application/octet-stream'; + + return [new BlobResourceContents($uri, $mimeType, $readResult['blob'], $meta)]; + } + + if (\is_array($readResult) && isset($readResult['text']) && \is_string($readResult['text'])) { + $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'text/plain'; + + return [new TextResourceContents($uri, $mimeType, $readResult['text'], $meta)]; + } + + if ($readResult instanceof \SplFileInfo && $readResult->isFile() && $readResult->isReadable()) { + if ($mimeType && str_contains(strtolower($mimeType), 'text')) { + return [new TextResourceContents($uri, $mimeType, file_get_contents($readResult->getPathname()), $meta)]; + } + + return [BlobResourceContents::fromSplFileInfo($uri, $readResult, $mimeType, $meta)]; + } + + if (\is_array($readResult)) { + if ($mimeType && (str_contains(strtolower($mimeType), 'json') + || 'application/json' === $mimeType)) { + try { + $jsonString = json_encode($readResult, \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT); + + return [new TextResourceContents($uri, $mimeType, $jsonString, $meta)]; + } catch (\JsonException $e) { + throw new RuntimeException(\sprintf('Failed to encode array as JSON for URI "%s": %s', $uri, $e->getMessage())); + } + } + + try { + $jsonString = json_encode($readResult, \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT); + $mimeType = $mimeType ?? 'application/json'; + + return [new TextResourceContents($uri, $mimeType, $jsonString, $meta)]; + } catch (\JsonException $e) { + throw new RuntimeException(\sprintf('Failed to encode array as JSON for URI "%s": %s', $uri, $e->getMessage())); + } + } + + throw new RuntimeException(\sprintf('Cannot format resource read result for URI "%s". Handler method returned unhandled type: ', $uri).\gettype($readResult)); + } + + /** + * Guesses MIME type from string content (very basic). + */ + private function guessMimeTypeFromString(string $content): string + { + $trimmed = ltrim($content); + + if (str_starts_with($trimmed, '<') && str_ends_with(rtrim($content), '>')) { + if (str_contains($trimmed, ' + * @author Mateu Aguiló Bosch + */ +final class ToolResultFormatter +{ + /** + * Formats the result of a tool execution into an array of MCP Content items. + * + * - If the result is already a Content object, it's wrapped in an array. + * - If the result is an array: + * - If all elements are Content objects, the array is returned as is. + * - If it's a mixed array (Content and non-Content items), non-Content items are + * individually formatted (scalars to TextContent, others to JSON TextContent). + * - If it's an array with no Content items, the entire array is JSON-encoded into a single TextContent. + * - Scalars (string, int, float, bool) are wrapped in TextContent. + * - null is represented as TextContent('(null)'). + * - Other objects are JSON-encoded and wrapped in TextContent. + * + * @param mixed $toolExecutionResult the raw value returned by the tool's PHP method + * + * @return Content[] the content items for CallToolResult + * + * @throws \JsonException if JSON encoding fails for non-Content array/object results + */ + public function format(mixed $toolExecutionResult): array + { + if ($toolExecutionResult instanceof Content) { + return [$toolExecutionResult]; + } + + if (\is_array($toolExecutionResult)) { + if (empty($toolExecutionResult)) { + return [new TextContent('[]')]; + } + + $allAreContent = true; + $hasContent = false; + + foreach ($toolExecutionResult as $item) { + if ($item instanceof Content) { + $hasContent = true; + } else { + $allAreContent = false; + } + } + + if ($allAreContent && $hasContent) { + return $toolExecutionResult; + } + + if ($hasContent) { + $result = []; + foreach ($toolExecutionResult as $item) { + if ($item instanceof Content) { + $result[] = $item; + } else { + $result = array_merge($result, $this->format($item)); + } + } + + return $result; + } + } + + if (null === $toolExecutionResult) { + return [new TextContent('(null)')]; + } + + if (\is_bool($toolExecutionResult)) { + return [new TextContent($toolExecutionResult ? 'true' : 'false')]; + } + + if (\is_scalar($toolExecutionResult)) { + return [new TextContent($toolExecutionResult)]; + } + + $jsonResult = json_encode( + $toolExecutionResult, + \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR | \JSON_INVALID_UTF8_SUBSTITUTE + ); + + return [new TextContent($jsonResult)]; + } +} diff --git a/src/Capability/Provider/DynamicPromptProviderInterface.php b/src/Capability/Provider/DynamicPromptProviderInterface.php new file mode 100644 index 00000000..eda092e5 --- /dev/null +++ b/src/Capability/Provider/DynamicPromptProviderInterface.php @@ -0,0 +1,41 @@ + + */ +interface DynamicPromptProviderInterface +{ + /** + * @return iterable + */ + public function getPrompts(): iterable; + + public function supportsPrompt(string $promptName): bool; + + /** + * @param array $arguments + */ + public function getPrompt(string $promptName, array $arguments): mixed; + + /** + * @return array + */ + public function getCompletionProviders(string $promptName): array; +} diff --git a/src/Capability/Provider/DynamicResourceProviderInterface.php b/src/Capability/Provider/DynamicResourceProviderInterface.php new file mode 100644 index 00000000..07ce918e --- /dev/null +++ b/src/Capability/Provider/DynamicResourceProviderInterface.php @@ -0,0 +1,33 @@ + + */ +interface DynamicResourceProviderInterface +{ + /** + * @return iterable + */ + public function getResources(): iterable; + + public function supportsResource(string $uri): bool; + + public function readResource(string $uri): mixed; +} diff --git a/src/Capability/Provider/DynamicResourceTemplateProviderInterface.php b/src/Capability/Provider/DynamicResourceTemplateProviderInterface.php new file mode 100644 index 00000000..4b2936ad --- /dev/null +++ b/src/Capability/Provider/DynamicResourceTemplateProviderInterface.php @@ -0,0 +1,38 @@ + + */ +interface DynamicResourceTemplateProviderInterface +{ + /** + * @return iterable + */ + public function getResourceTemplates(): iterable; + + public function supportsResourceTemplate(string $uriTemplate): bool; + + public function readResource(string $uriTemplate, string $uri): mixed; + + /** + * @return array + */ + public function getCompletionProviders(string $uriTemplate): array; +} diff --git a/src/Capability/Provider/DynamicToolProviderInterface.php b/src/Capability/Provider/DynamicToolProviderInterface.php new file mode 100644 index 00000000..24d0e2bd --- /dev/null +++ b/src/Capability/Provider/DynamicToolProviderInterface.php @@ -0,0 +1,36 @@ + + */ +interface DynamicToolProviderInterface +{ + /** + * @return iterable + */ + public function getTools(): iterable; + + public function supportsTool(string $toolName): bool; + + /** + * @param array $arguments + */ + public function executeTool(string $toolName, array $arguments): mixed; +} diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index d0813412..2df5a419 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -12,10 +12,19 @@ namespace Mcp\Capability; use Mcp\Capability\Discovery\DiscoveryState; +use Mcp\Capability\Provider\DynamicPromptProviderInterface; +use Mcp\Capability\Provider\DynamicResourceProviderInterface; +use Mcp\Capability\Provider\DynamicResourceTemplateProviderInterface; +use Mcp\Capability\Provider\DynamicToolProviderInterface; +use Mcp\Capability\Registry\DynamicPromptReference; +use Mcp\Capability\Registry\DynamicResourceReference; +use Mcp\Capability\Registry\DynamicResourceTemplateReference; +use Mcp\Capability\Registry\DynamicToolReference; use Mcp\Capability\Registry\PromptReference; use Mcp\Capability\Registry\ResourceReference; use Mcp\Capability\Registry\ResourceTemplateReference; use Mcp\Capability\Registry\ToolReference; +use Mcp\Capability\Registry\UriTemplateMatcher; use Mcp\Capability\Tool\NameValidator; use Mcp\Event\PromptListChangedEvent; use Mcp\Event\ResourceListChangedEvent; @@ -23,6 +32,7 @@ use Mcp\Event\ToolListChangedEvent; use Mcp\Exception\InvalidCursorException; use Mcp\Exception\PromptNotFoundException; +use Mcp\Exception\RegistryException; use Mcp\Exception\ResourceNotFoundException; use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\Page; @@ -41,6 +51,58 @@ */ final class Registry implements RegistryInterface { + /** + * Configuration for each capability type to enable DRY registration logic. + * + * @var array + */ + private const CAPABILITIES = [ + 'tool' => [ + 'staticRegistry' => 'tools', + 'dynamicProviders' => 'dynamicToolProviders', + 'itemsGetter' => 'getTools', + 'keyProperty' => 'name', + 'supportMethod' => 'supportsTool', + 'event' => ToolListChangedEvent::class, + 'label' => 'tool', + ], + 'prompt' => [ + 'staticRegistry' => 'prompts', + 'dynamicProviders' => 'dynamicPromptProviders', + 'itemsGetter' => 'getPrompts', + 'keyProperty' => 'name', + 'supportMethod' => 'supportsPrompt', + 'event' => PromptListChangedEvent::class, + 'label' => 'prompt', + ], + 'resource' => [ + 'staticRegistry' => 'resources', + 'dynamicProviders' => 'dynamicResourceProviders', + 'itemsGetter' => 'getResources', + 'keyProperty' => 'uri', + 'supportMethod' => 'supportsResource', + 'event' => ResourceListChangedEvent::class, + 'label' => 'resource', + ], + 'resource template' => [ + 'staticRegistry' => 'resourceTemplates', + 'dynamicProviders' => 'dynamicResourceTemplateProviders', + 'itemsGetter' => 'getResourceTemplates', + 'keyProperty' => 'uriTemplate', + 'supportMethod' => 'supportsResourceTemplate', + 'event' => ResourceTemplateListChangedEvent::class, + 'label' => 'template', + ], + ]; + /** * @var array */ @@ -61,6 +123,26 @@ final class Registry implements RegistryInterface */ private array $resourceTemplates = []; + /** + * @var array + */ + private array $dynamicToolProviders = []; + + /** + * @var array + */ + private array $dynamicPromptProviders = []; + + /** + * @var array + */ + private array $dynamicResourceProviders = []; + + /** + * @var array + */ + private array $dynamicResourceTemplateProviders = []; + public function __construct( private readonly ?EventDispatcherInterface $eventDispatcher = null, private readonly LoggerInterface $logger = new NullLogger(), @@ -159,6 +241,83 @@ public function registerPrompt( $this->eventDispatcher?->dispatch(new PromptListChangedEvent()); } + public function registerDynamicToolProvider(DynamicToolProviderInterface $provider): void + { + $this->registerDynamicProvider($provider, 'tool'); + } + + public function registerDynamicPromptProvider(DynamicPromptProviderInterface $provider): void + { + $this->registerDynamicProvider($provider, 'prompt'); + } + + public function registerDynamicResourceProvider(DynamicResourceProviderInterface $provider): void + { + $this->registerDynamicProvider($provider, 'resource'); + } + + public function registerDynamicResourceTemplateProvider(DynamicResourceTemplateProviderInterface $provider): void + { + $this->registerDynamicProvider($provider, 'resource template'); + } + + /** + * @param DynamicToolProviderInterface|DynamicPromptProviderInterface|DynamicResourceProviderInterface|DynamicResourceTemplateProviderInterface $provider + */ + private function registerDynamicProvider(object $provider, string $capability): void + { + $config = self::CAPABILITIES[$capability]; + + array_map( + fn ($item) => $this->assertCapabilityNotRegistered($item->{$config['keyProperty']}, $capability), + iterator_to_array($provider->{$config['itemsGetter']}()), + ); + + $this->{$config['dynamicProviders']}[] = $provider; + $this->eventDispatcher?->dispatch(new ($config['event'])()); + } + + private function assertCapabilityNotRegistered(string $key, string $capability): void + { + $config = self::CAPABILITIES[$capability]; + $label = $config['label']; + + if (isset($this->{$config['staticRegistry']}[$key])) { + throw RegistryException::invalidParams(\sprintf( + 'Dynamic %s provider conflict: %s "%s" is already registered as a static %s.', + $capability, + $label, + $key, + $capability, + )); + } + + $conflictingProvider = $this->findDynamicProviderByKey($key, $capability); + if (null !== $conflictingProvider) { + throw RegistryException::invalidParams(\sprintf( + 'Dynamic %s provider conflict: %s "%s" is already supported by another provider.', + $capability, + $label, + $key, + )); + } + } + + /** + * @return DynamicToolProviderInterface|DynamicPromptProviderInterface|DynamicResourceProviderInterface|DynamicResourceTemplateProviderInterface|null + */ + private function findDynamicProviderByKey(string $key, string $capability): ?object + { + $config = self::CAPABILITIES[$capability]; + + $matching = array_filter( + $this->{$config['dynamicProviders']}, + fn (object $p) => $p->{$config['supportMethod']}($key), + ); + + return array_shift($matching); + } + public function clear(): void { $clearCount = 0; @@ -204,6 +363,21 @@ public function getTools(?int $limit = null, ?string $cursor = null): Page foreach ($this->tools as $toolReference) { $tools[$toolReference->tool->name] = $toolReference->tool; } + $tools = [ + ...$tools, + ...array_reduce( + $this->dynamicToolProviders, + fn (array $acc, DynamicToolProviderInterface $provider) => array_merge( + $acc, + array_column( + iterator_to_array($provider->getTools()), + null, + 'name', + ), + ), + [], + ), + ]; if (null === $limit) { return new Page($tools, null); @@ -236,6 +410,17 @@ public function getResources(?int $limit = null, ?string $cursor = null): Page foreach ($this->resources as $resourceReference) { $resources[$resourceReference->schema->uri] = $resourceReference->schema; } + $resources = [ + ...$resources, + ...array_reduce( + $this->dynamicResourceProviders, + fn (array $acc, DynamicResourceProviderInterface $provider) => array_merge( + $acc, + array_column(iterator_to_array($provider->getResources()), null, 'uri'), + ), + [], + ), + ]; if (null === $limit) { return new Page($resources, null); @@ -276,16 +461,24 @@ public function getResource( public function hasResourceTemplates(): bool { - return [] !== $this->resourceTemplates; + return [] !== $this->resourceTemplates || [] !== $this->dynamicResourceTemplateProviders; } public function getResourceTemplates(?int $limit = null, ?string $cursor = null): Page { + // Static templates $templates = []; foreach ($this->resourceTemplates as $templateReference) { $templates[$templateReference->resourceTemplate->uriTemplate] = $templateReference->resourceTemplate; } + // Merge dynamic templates from providers + foreach ($this->dynamicResourceTemplateProviders as $provider) { + foreach ($provider->getResourceTemplates() as $template) { + $templates[$template->uriTemplate] = $template; + } + } + if (null === $limit) { return new Page($templates, null); } @@ -317,6 +510,17 @@ public function getPrompts(?int $limit = null, ?string $cursor = null): Page foreach ($this->prompts as $promptReference) { $prompts[$promptReference->prompt->name] = $promptReference->prompt; } + $prompts = [ + ...$prompts, + ...array_reduce( + $this->dynamicPromptProviders, + fn (array $acc, DynamicPromptProviderInterface $provider) => array_merge( + $acc, + array_column(iterator_to_array($provider->getPrompts()), null, 'name'), + ), + [], + ), + ]; if (null === $limit) { return new Page($prompts, null); @@ -338,6 +542,156 @@ public function getPrompt(string $name): PromptReference return $this->prompts[$name] ?? throw new PromptNotFoundException($name); } + public function getDynamicToolProviders(): array + { + return $this->dynamicToolProviders; + } + + public function getDynamicPromptProviders(): array + { + return $this->dynamicPromptProviders; + } + + public function getDynamicResourceProviders(): array + { + return $this->dynamicResourceProviders; + } + + /** + * @return array + */ + public function getDynamicResourceTemplateProviders(): array + { + return $this->dynamicResourceTemplateProviders; + } + + public function getDynamicPromptCompletionProviders(string $name): ?array + { + $provider = $this->findDynamicPromptProvider($name); + + return $provider?->getCompletionProviders($name); + } + + public function getDynamicResourceTemplateCompletionProviders(string $uri): ?array + { + $provider = $this->findDynamicResourceTemplateProvider($uri); + + return $provider?->getCompletionProviders($uri); + } + + private function findDynamicToolProvider(string $toolName): ?DynamicToolProviderInterface + { + $matching = array_filter( + $this->dynamicToolProviders, + fn (DynamicToolProviderInterface $p) => $p->supportsTool($toolName), + ); + + return array_shift($matching); + } + + private function findDynamicPromptProvider(string $promptName): ?DynamicPromptProviderInterface + { + $matching = array_filter( + $this->dynamicPromptProviders, + fn (DynamicPromptProviderInterface $p) => $p->supportsPrompt($promptName), + ); + + return array_shift($matching); + } + + private function findDynamicResourceProvider(string $uri): ?DynamicResourceProviderInterface + { + $matching = array_filter( + $this->dynamicResourceProviders, + fn (DynamicResourceProviderInterface $p) => $p->supportsResource($uri), + ); + + return array_shift($matching); + } + + private function findDynamicResourceTemplateProvider(string $uriTemplate): ?DynamicResourceTemplateProviderInterface + { + $matching = array_filter( + $this->dynamicResourceTemplateProviders, + fn (DynamicResourceTemplateProviderInterface $p) => $p->supportsResourceTemplate($uriTemplate), + ); + + return array_shift($matching); + } + + public function getDynamicTool(string $name): ?DynamicToolReference + { + $provider = $this->findDynamicToolProvider($name); + if (null === $provider) { + return null; + } + + foreach ($provider->getTools() as $tool) { + if ($tool->name === $name) { + return new DynamicToolReference($tool, $provider, $name); + } + } + + return null; + } + + public function getDynamicPrompt(string $name): ?DynamicPromptReference + { + $provider = $this->findDynamicPromptProvider($name); + if (null === $provider) { + return null; + } + + foreach ($provider->getPrompts() as $prompt) { + if ($prompt->name === $name) { + return new DynamicPromptReference( + $prompt, + $provider, + $name, + $provider->getCompletionProviders($name), + ); + } + } + + return null; + } + + public function getDynamicResource(string $uri): ?DynamicResourceReference + { + $provider = $this->findDynamicResourceProvider($uri); + if (null === $provider) { + return null; + } + + foreach ($provider->getResources() as $resource) { + if ($resource->uri === $uri) { + return new DynamicResourceReference($resource, $provider, $uri); + } + } + + return null; + } + + public function getDynamicResourceTemplate(string $uri): ?DynamicResourceTemplateReference + { + $uriTemplateMatcher = new UriTemplateMatcher(); + + foreach ($this->dynamicResourceTemplateProviders as $provider) { + foreach ($provider->getResourceTemplates() as $template) { + if ($uriTemplateMatcher->matches($uri, $template->uriTemplate)) { + return new DynamicResourceTemplateReference( + $template, + $provider, + $template->uriTemplate, + $provider->getCompletionProviders($template->uriTemplate), + ); + } + } + } + + return null; + } + /** * Get the current discovery state (only discovered elements, not manual ones). */ diff --git a/src/Capability/Registry/ArgumentPreparationInterface.php b/src/Capability/Registry/ArgumentPreparationInterface.php new file mode 100644 index 00000000..82531ae1 --- /dev/null +++ b/src/Capability/Registry/ArgumentPreparationInterface.php @@ -0,0 +1,25 @@ + + */ +interface ArgumentPreparationInterface +{ + /** + * @param array $arguments + * + * @return array + */ + public function prepareArguments(array $arguments, callable $resolvedHandler): array; +} diff --git a/src/Capability/Registry/DynamicArgumentPreparationTrait.php b/src/Capability/Registry/DynamicArgumentPreparationTrait.php new file mode 100644 index 00000000..62ae9352 --- /dev/null +++ b/src/Capability/Registry/DynamicArgumentPreparationTrait.php @@ -0,0 +1,32 @@ + + */ +trait DynamicArgumentPreparationTrait +{ + /** + * @param array $arguments + * + * @return array + */ + public function prepareArguments(array $arguments, callable $resolvedHandler): array + { + unset($arguments['_session']); + + return [$arguments]; + } +} diff --git a/src/Capability/Registry/DynamicPromptReference.php b/src/Capability/Registry/DynamicPromptReference.php new file mode 100644 index 00000000..a39aa007 --- /dev/null +++ b/src/Capability/Registry/DynamicPromptReference.php @@ -0,0 +1,62 @@ + + */ +class DynamicPromptReference extends ElementReference implements ClientAwareInterface +{ + use DynamicArgumentPreparationTrait; + + /** + * @param array $completionProviders + */ + public function __construct( + public readonly Prompt $prompt, + public readonly DynamicPromptProviderInterface $provider, + public readonly string $promptName, + public readonly array $completionProviders = [], + ) { + parent::__construct($this, false); + } + + public function setClient(ClientGateway $clientGateway): void + { + if ($this->provider instanceof ClientAwareInterface) { + $this->provider->setClient($clientGateway); + } + } + + /** + * @param array $arguments + */ + public function __invoke(array $arguments): mixed + { + return $this->provider->getPrompt($this->promptName, $arguments); + } + + /** + * @return PromptMessage[] + */ + public function formatResult(mixed $promptGenerationResult): array + { + return (new PromptResultFormatter())->format($promptGenerationResult); + } +} diff --git a/src/Capability/Registry/DynamicResourceReference.php b/src/Capability/Registry/DynamicResourceReference.php new file mode 100644 index 00000000..4887972d --- /dev/null +++ b/src/Capability/Registry/DynamicResourceReference.php @@ -0,0 +1,58 @@ + + */ +class DynamicResourceReference extends ElementReference implements ClientAwareInterface +{ + use DynamicArgumentPreparationTrait; + + public function __construct( + public readonly Resource $schema, + public readonly DynamicResourceProviderInterface $provider, + public readonly string $uri, + ) { + parent::__construct($this, false); + } + + public function setClient(ClientGateway $clientGateway): void + { + if ($this->provider instanceof ClientAwareInterface) { + $this->provider->setClient($clientGateway); + } + } + + /** + * @param array $arguments + */ + public function __invoke(array $arguments): mixed + { + return $this->provider->readResource($arguments['uri'] ?? $this->uri); + } + + /** + * @return ResourceContents[] + */ + public function formatResult(mixed $readResult, string $uri, ?string $mimeType = null): array + { + return (new ResourceResultFormatter())->format($readResult, $uri, $mimeType, $this->schema->meta); + } +} diff --git a/src/Capability/Registry/DynamicResourceTemplateReference.php b/src/Capability/Registry/DynamicResourceTemplateReference.php new file mode 100644 index 00000000..ab86c57d --- /dev/null +++ b/src/Capability/Registry/DynamicResourceTemplateReference.php @@ -0,0 +1,88 @@ + + */ +class DynamicResourceTemplateReference extends ElementReference implements ClientAwareInterface +{ + use DynamicArgumentPreparationTrait; + + private readonly UriTemplateMatcher $uriTemplateMatcher; + + /** + * @param array $completionProviders + */ + public function __construct( + public readonly ResourceTemplate $resourceTemplate, + public readonly DynamicResourceTemplateProviderInterface $provider, + public readonly string $uriTemplate, + public readonly array $completionProviders = [], + ?UriTemplateMatcher $uriTemplateMatcher = null, + ) { + parent::__construct($this, false); + + $this->uriTemplateMatcher = $uriTemplateMatcher ?? new UriTemplateMatcher(); + } + + public function setClient(ClientGateway $clientGateway): void + { + if ($this->provider instanceof ClientAwareInterface) { + $this->provider->setClient($clientGateway); + } + } + + /** + * @param array $arguments + */ + public function __invoke(array $arguments): mixed + { + return $this->provider->readResource($this->uriTemplate, $arguments['uri'] ?? ''); + } + + /** + * @return array + */ + public function getVariableNames(): array + { + return $this->uriTemplateMatcher->getVariableNames($this->resourceTemplate->uriTemplate); + } + + public function matches(string $uri): bool + { + return $this->uriTemplateMatcher->matches($uri, $this->resourceTemplate->uriTemplate); + } + + /** + * @return array + */ + public function extractVariables(string $uri): array + { + return $this->uriTemplateMatcher->extractVariables($uri, $this->resourceTemplate->uriTemplate); + } + + /** + * @return ResourceContents[] + */ + public function formatResult(mixed $readResult, string $uri, ?string $mimeType = null): array + { + return (new ResourceResultFormatter())->format($readResult, $uri, $mimeType, $this->resourceTemplate->meta); + } +} diff --git a/src/Capability/Registry/DynamicToolReference.php b/src/Capability/Registry/DynamicToolReference.php new file mode 100644 index 00000000..1d02a2f6 --- /dev/null +++ b/src/Capability/Registry/DynamicToolReference.php @@ -0,0 +1,58 @@ + + */ +class DynamicToolReference extends ElementReference implements ClientAwareInterface +{ + use DynamicArgumentPreparationTrait; + + public function __construct( + public readonly Tool $tool, + public readonly DynamicToolProviderInterface $provider, + public readonly string $toolName, + ) { + parent::__construct($this, false); + } + + public function setClient(ClientGateway $clientGateway): void + { + if ($this->provider instanceof ClientAwareInterface) { + $this->provider->setClient($clientGateway); + } + } + + /** + * @param array $arguments + */ + public function __invoke(array $arguments): mixed + { + return $this->provider->executeTool($this->toolName, $arguments); + } + + /** + * @return Content[] + */ + public function formatResult(mixed $toolExecutionResult): array + { + return (new ToolResultFormatter())->format($toolExecutionResult); + } +} diff --git a/src/Capability/Registry/ElementReference.php b/src/Capability/Registry/ElementReference.php index 6425ba13..37341dd5 100644 --- a/src/Capability/Registry/ElementReference.php +++ b/src/Capability/Registry/ElementReference.php @@ -12,18 +12,41 @@ namespace Mcp\Capability\Registry; /** - * @phpstan-type Handler \Closure|array{0: object|string, 1: string}|string + * Base class for element references with default passthrough argument preparation. + * + * @phpstan-type Handler \Closure|array{0: object|string, 1: string}|string|object * * @author Kyrian Obikwelu */ -class ElementReference +class ElementReference implements ArgumentPreparationInterface { /** - * @param Handler $handler + * @param Handler $handler The handler can be a Closure, array method reference, + * string function/class name, or a callable object (implementing __invoke) */ public function __construct( - public readonly \Closure|array|string $handler, + public readonly object|array|string $handler, public readonly bool $isManual = false, ) { } + + /** + * Default passthrough implementation - returns arguments as-is. + * + * Subclasses can override via traits or direct implementation to provide + * custom argument preparation (e.g., reflection-based mapping). + * + * @param array $arguments Raw arguments from MCP request + * @param callable $resolvedHandler The resolved handler callable (unused in passthrough) + * + * @return array The arguments as a list, ready to be spread + */ + public function prepareArguments( + array $arguments, + callable $resolvedHandler, + ): array { + unset($arguments['_session']); + + return array_values($arguments); + } } diff --git a/src/Capability/Registry/PromptReference.php b/src/Capability/Registry/PromptReference.php index cff89241..5c6333de 100644 --- a/src/Capability/Registry/PromptReference.php +++ b/src/Capability/Registry/PromptReference.php @@ -11,16 +11,8 @@ namespace Mcp\Capability\Registry; -use Mcp\Exception\RuntimeException; -use Mcp\Schema\Content\AudioContent; -use Mcp\Schema\Content\BlobResourceContents; -use Mcp\Schema\Content\Content; -use Mcp\Schema\Content\EmbeddedResource; -use Mcp\Schema\Content\ImageContent; +use Mcp\Capability\Formatter\PromptResultFormatter; use Mcp\Schema\Content\PromptMessage; -use Mcp\Schema\Content\TextContent; -use Mcp\Schema\Content\TextResourceContents; -use Mcp\Schema\Enum\Role; use Mcp\Schema\Prompt; /** @@ -30,6 +22,8 @@ */ class PromptReference extends ElementReference { + use ReflectionArgumentPreparationTrait; + /** * @param Handler $handler * @param array $completionProviders @@ -55,228 +49,6 @@ public function __construct( */ public function formatResult(mixed $promptGenerationResult): array { - if ($promptGenerationResult instanceof PromptMessage) { - return [$promptGenerationResult]; - } - - if (!\is_array($promptGenerationResult)) { - throw new RuntimeException('Prompt generator method must return an array of messages.'); - } - - if (empty($promptGenerationResult)) { - return []; - } - - if (\is_array($promptGenerationResult)) { - $allArePromptMessages = true; - $hasPromptMessages = false; - - foreach ($promptGenerationResult as $item) { - if ($item instanceof PromptMessage) { - $hasPromptMessages = true; - } else { - $allArePromptMessages = false; - } - } - - if ($allArePromptMessages && $hasPromptMessages) { - return $promptGenerationResult; - } - - if ($hasPromptMessages) { - $result = []; - foreach ($promptGenerationResult as $index => $item) { - if ($item instanceof PromptMessage) { - $result[] = $item; - } else { - $result = array_merge($result, $this->formatResult($item)); - } - } - - return $result; - } - - if (!array_is_list($promptGenerationResult)) { - if (isset($promptGenerationResult['user']) || isset($promptGenerationResult['assistant'])) { - $result = []; - if (isset($promptGenerationResult['user'])) { - $userContent = $this->formatContent($promptGenerationResult['user']); - $result[] = new PromptMessage(Role::User, $userContent); - } - if (isset($promptGenerationResult['assistant'])) { - $assistantContent = $this->formatContent($promptGenerationResult['assistant']); - $result[] = new PromptMessage(Role::Assistant, $assistantContent); - } - - return $result; - } - - if (isset($promptGenerationResult['role']) && isset($promptGenerationResult['content'])) { - return [$this->formatMessage($promptGenerationResult)]; - } - - throw new RuntimeException('Associative array must contain either role/content keys or user/assistant keys.'); - } - - $formattedMessages = []; - foreach ($promptGenerationResult as $index => $message) { - if ($message instanceof PromptMessage) { - $formattedMessages[] = $message; - } else { - $formattedMessages[] = $this->formatMessage($message, $index); - } - } - - return $formattedMessages; - } - - throw new RuntimeException('Invalid prompt generation result format.'); - } - - /** - * Formats a single message into a PromptMessage. - */ - private function formatMessage(mixed $message, ?int $index = null): PromptMessage - { - $indexStr = null !== $index ? " at index {$index}" : ''; - - if (!\is_array($message) || !\array_key_exists('role', $message) || !\array_key_exists('content', $message)) { - throw new RuntimeException("Invalid message format{$indexStr}. Expected an array with 'role' and 'content' keys."); - } - - $role = $message['role'] instanceof Role ? $message['role'] : Role::tryFrom($message['role']); - if (null === $role) { - throw new RuntimeException("Invalid role '{$message['role']}' in prompt message{$indexStr}. Only 'user' or 'assistant' are supported."); - } - - $content = $this->formatContent($message['content'], $index); - - return new PromptMessage($role, $content); - } - - /** - * Formats content into a proper Content object. - */ - private function formatContent(mixed $content, ?int $index = null): TextContent|ImageContent|AudioContent|EmbeddedResource - { - $indexStr = null !== $index ? " at index {$index}" : ''; - - if ($content instanceof Content) { - if ( - $content instanceof TextContent || $content instanceof ImageContent - || $content instanceof AudioContent || $content instanceof EmbeddedResource - ) { - return $content; - } - throw new RuntimeException("Invalid Content type{$indexStr}. PromptMessage only supports TextContent, ImageContent, AudioContent, or EmbeddedResource."); - } - - if (\is_string($content)) { - return new TextContent($content); - } - - if (\is_array($content) && isset($content['type'])) { - return $this->formatTypedContent($content, $index); - } - - if (\is_scalar($content) || null === $content) { - $stringContent = null === $content ? '(null)' : (\is_bool($content) ? ($content ? 'true' : 'false') : (string) $content); - - return new TextContent($stringContent); - } - - $jsonContent = json_encode($content, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR); - - return new TextContent($jsonContent); - } - - /** - * Formats typed content arrays into Content objects. - * - * @param array $content - */ - private function formatTypedContent(array $content, ?int $index = null): TextContent|ImageContent|AudioContent|EmbeddedResource - { - $indexStr = null !== $index ? " at index {$index}" : ''; - $type = $content['type']; - - return match ($type) { - 'text' => $this->formatTextContent($content, $indexStr), - 'image' => $this->formatImageContent($content, $indexStr), - 'audio' => $this->formatAudioContent($content, $indexStr), - 'resource' => $this->formatResourceContent($content, $indexStr), - default => throw new RuntimeException("Invalid content type '{$type}'{$indexStr}."), - }; - } - - /** - * @param array $content - */ - private function formatTextContent(array $content, string $indexStr): TextContent - { - if (!isset($content['text']) || !\is_string($content['text'])) { - throw new RuntimeException(\sprintf('Invalid "text" content%s: Missing or invalid "text" string.', $indexStr)); - } - - return new TextContent($content['text']); - } - - /** - * @param array $content - */ - private function formatImageContent(array $content, string $indexStr): ImageContent - { - if (!isset($content['data']) || !\is_string($content['data'])) { - throw new RuntimeException("Invalid 'image' content{$indexStr}: Missing or invalid 'data' string (base64)."); - } - if (!isset($content['mimeType']) || !\is_string($content['mimeType'])) { - throw new RuntimeException("Invalid 'image' content{$indexStr}: Missing or invalid 'mimeType' string."); - } - - return new ImageContent($content['data'], $content['mimeType']); - } - - /** - * @param array $content - */ - private function formatAudioContent(array $content, string $indexStr): AudioContent - { - if (!isset($content['data']) || !\is_string($content['data'])) { - throw new RuntimeException("Invalid 'audio' content{$indexStr}: Missing or invalid 'data' string (base64)."); - } - if (!isset($content['mimeType']) || !\is_string($content['mimeType'])) { - throw new RuntimeException("Invalid 'audio' content{$indexStr}: Missing or invalid 'mimeType' string."); - } - - return new AudioContent($content['data'], $content['mimeType']); - } - - /** - * @param array $content - */ - private function formatResourceContent(array $content, string $indexStr): EmbeddedResource - { - if (!isset($content['resource']) || !\is_array($content['resource'])) { - throw new RuntimeException("Invalid 'resource' content{$indexStr}: Missing or invalid 'resource' object."); - } - - $resource = $content['resource']; - if (!isset($resource['uri']) || !\is_string($resource['uri'])) { - throw new RuntimeException("Invalid resource{$indexStr}: Missing or invalid 'uri'."); - } - - if (isset($resource['text']) && \is_string($resource['text'])) { - $resourceObj = new TextResourceContents($resource['uri'], $resource['mimeType'] ?? 'text/plain', $resource['text']); - } elseif (isset($resource['blob']) && \is_string($resource['blob'])) { - $resourceObj = new BlobResourceContents( - $resource['uri'], - $resource['mimeType'] ?? 'application/octet-stream', - $resource['blob'] - ); - } else { - throw new RuntimeException("Invalid resource{$indexStr}: Must contain 'text' or 'blob'."); - } - - return new EmbeddedResource($resourceObj); + return (new PromptResultFormatter())->format($promptGenerationResult); } } diff --git a/src/Capability/Registry/ReferenceHandler.php b/src/Capability/Registry/ReferenceHandler.php index 7ce8c737..e9670cf6 100644 --- a/src/Capability/Registry/ReferenceHandler.php +++ b/src/Capability/Registry/ReferenceHandler.php @@ -12,13 +12,13 @@ namespace Mcp\Capability\Registry; use Mcp\Exception\InvalidArgumentException; -use Mcp\Exception\RegistryException; use Mcp\Server\ClientAwareInterface; use Mcp\Server\ClientGateway; -use Mcp\Server\Session\SessionInterface; use Psr\Container\ContainerInterface; /** + * Handles the execution of element references by resolving handlers and delegating argument preparation. + * * @author Kyrian Obikwelu */ final class ReferenceHandler implements ReferenceHandlerInterface @@ -29,270 +29,87 @@ public function __construct( } /** + * Handles the execution of an element reference. + * * @param array $arguments */ public function handle(ElementReference $reference, array $arguments): mixed { $session = $arguments['_session']; + $clientGateway = new ClientGateway($session); - 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); - $arguments = $this->prepareArguments($reflection, $arguments); - - if ($instance instanceof ClientAwareInterface) { - $instance->setClient(new ClientGateway($session)); - } - - return \call_user_func($instance, ...$arguments); - } - - if (\function_exists($reference->handler)) { - $reflection = new \ReflectionFunction($reference->handler); - $arguments = $this->prepareArguments($reflection, $arguments); + // Resolve the handler to a callable and optionally an instance + [$callable, $instance] = $this->resolveHandler($reference->handler); - return \call_user_func($reference->handler, ...$arguments); - } + // Set client on reference if it implements ClientAwareInterface + // (e.g., dynamic references forward this to their providers) + if ($reference instanceof ClientAwareInterface) { + $reference->setClient($clientGateway); } - if (\is_callable($reference->handler)) { - $reflection = $this->getReflectionForCallable($reference->handler, $session); - $arguments = $this->prepareArguments($reflection, $arguments); - - return \call_user_func($reference->handler, ...$arguments); + // Set client on handler instances that implement ClientAwareInterface + if ($instance instanceof ClientAwareInterface) { + $instance->setClient($clientGateway); } - if (\is_array($reference->handler)) { - [$className, $methodName] = $reference->handler; - $reflection = new \ReflectionMethod($className, $methodName); - $instance = $this->getClassInstance($className); - - if ($instance instanceof ClientAwareInterface) { - $instance->setClient(new ClientGateway($session)); - } - - $arguments = $this->prepareArguments($reflection, $arguments); + // Delegate argument preparation to the reference + $preparedArguments = $reference->prepareArguments($arguments, $callable); - return \call_user_func([$instance, $methodName], ...$arguments); - } - - throw new InvalidArgumentException('Invalid handler type'); - } - - private function getClassInstance(string $className): object - { - if (null !== $this->container && $this->container->has($className)) { - return $this->container->get($className); - } - - return new $className(); + return \call_user_func($callable, ...$preparedArguments); } /** - * @param array $arguments + * Resolves a handler to a callable, optionally returning an instance. + * + * @param \Closure|array|string $handler * - * @return array + * @return array{0: callable, 1: ?object} */ - private function prepareArguments(\ReflectionFunctionAbstract $reflection, array $arguments): array + private function resolveHandler(\Closure|array|string $handler): array { - $finalArgs = []; - - foreach ($reflection->getParameters() as $parameter) { - // TODO: Handle variadic parameters. - $paramName = $parameter->getName(); - $paramPosition = $parameter->getPosition(); - - // Check if parameter is a special injectable type - $type = $parameter->getType(); - if ($type instanceof \ReflectionNamedType && !$type->isBuiltin()) { - $typeName = $type->getName(); + // String handler: class name with __invoke or function name + if (\is_string($handler)) { + if (class_exists($handler) && method_exists($handler, '__invoke')) { + $instance = $this->getClassInstance($handler); - if (ClientGateway::class === $typeName && isset($arguments['_session'])) { - $finalArgs[$paramPosition] = new ClientGateway($arguments['_session']); - continue; - } + return [$instance, $instance]; } - if (isset($arguments[$paramName])) { - $argument = $arguments[$paramName]; - try { - $finalArgs[$paramPosition] = $this->castArgumentType($argument, $parameter); - } catch (InvalidArgumentException $e) { - throw RegistryException::invalidParams($e->getMessage(), $e); - } catch (\Throwable $e) { - throw RegistryException::internalError("Error processing parameter `{$paramName}`: {$e->getMessage()}", $e); - } - } elseif ($parameter->isDefaultValueAvailable()) { - $finalArgs[$paramPosition] = $parameter->getDefaultValue(); - } elseif ($parameter->allowsNull()) { - $finalArgs[$paramPosition] = null; - } elseif ($parameter->isOptional()) { - continue; - } else { - $reflectionName = $reflection instanceof \ReflectionMethod - ? $reflection->class.'::'.$reflection->name - : 'Closure'; - throw RegistryException::internalError("Missing required argument `{$paramName}` for {$reflectionName}."); + if (\function_exists($handler)) { + return [$handler, null]; } - } - return array_values($finalArgs); - } - - /** - * Gets a ReflectionMethod or ReflectionFunction for a callable. - */ - private function getReflectionForCallable(callable $handler, SessionInterface $session): \ReflectionMethod|\ReflectionFunction - { - if (\is_string($handler)) { - return new \ReflectionFunction($handler); + throw new InvalidArgumentException("Invalid string handler: '{$handler}' is not a valid class or function."); } + // Closure handler if ($handler instanceof \Closure) { - return new \ReflectionFunction($handler); + return [$handler, null]; } - if (\is_array($handler) && 2 === \count($handler)) { - [$class, $method] = $handler; - - if ($class instanceof ClientAwareInterface) { - $class->setClient(new ClientGateway($session)); - } - - return new \ReflectionMethod($class, $method); - } + // Array handler: [class/object, method] + if (\is_array($handler)) { + [$classOrObject, $methodName] = $handler; - throw new InvalidArgumentException('Cannot create reflection for this callable type'); - } - - /** - * Attempts type casting based on ReflectionParameter type hints. - * - * @throws InvalidArgumentException if casting is impossible for the required type - */ - private function castArgumentType(mixed $argument, \ReflectionParameter $parameter): mixed - { - $type = $parameter->getType(); + if (\is_string($classOrObject)) { + $instance = $this->getClassInstance($classOrObject); - if (null === $argument) { - if ($type && $type->allowsNull()) { - return null; + return [[$instance, $methodName], $instance]; } - } - - if (!$type instanceof \ReflectionNamedType) { - return $argument; - } - - $typeName = $type->getName(); - - if (enum_exists($typeName)) { - if (\is_object($argument) && $argument instanceof $typeName) { - return $argument; - } - - if (is_subclass_of($typeName, \BackedEnum::class)) { - $value = $typeName::tryFrom($argument); - if (null === $value) { - throw new InvalidArgumentException("Invalid value '{$argument}' for backed enum {$typeName}. Expected one of its backing values."); - } - - return $value; - } else { - if (\is_string($argument)) { - foreach ($typeName::cases() as $case) { - if ($case->name === $argument) { - return $case; - } - } - $validNames = array_map(fn ($c) => $c->name, $typeName::cases()); - throw new InvalidArgumentException("Invalid value '{$argument}' for unit enum {$typeName}. Expected one of: ".implode(', ', $validNames).'.'); - } else { - throw new InvalidArgumentException("Invalid value type '{$argument}' for unit enum {$typeName}. Expected a string matching a case name."); - } - } - } - - try { - return match (strtolower($typeName)) { - 'int', 'integer' => $this->castToInt($argument), - 'string' => (string) $argument, - 'bool', 'boolean' => $this->castToBoolean($argument), - 'float', 'double' => $this->castToFloat($argument), - 'array' => $this->castToArray($argument), - default => $argument, - }; - } catch (\TypeError $e) { - throw new InvalidArgumentException("Value cannot be cast to required type `{$typeName}`.", 0, $e); - } - } - - /** - * Helper to cast strictly to boolean. - */ - private function castToBoolean(mixed $argument): bool - { - if (\is_bool($argument)) { - return $argument; - } - if (1 === $argument || '1' === $argument || 'true' === strtolower((string) $argument)) { - return true; - } - if (0 === $argument || '0' === $argument || 'false' === strtolower((string) $argument)) { - return false; - } - - throw new InvalidArgumentException('Cannot cast value to boolean. Use true/false/1/0.'); - } - /** - * Helper to cast strictly to integer. - */ - private function castToInt(mixed $argument): int - { - if (\is_int($argument)) { - return $argument; - } - if (is_numeric($argument) && floor((float) $argument) == $argument && !\is_string($argument)) { - return (int) $argument; - } - if (\is_string($argument) && ctype_digit(ltrim($argument, '-'))) { - return (int) $argument; - } - - throw new InvalidArgumentException('Cannot cast value to integer. Expected integer representation.'); - } - - /** - * Helper to cast strictly to float. - */ - private function castToFloat(mixed $argument): float - { - if (\is_float($argument)) { - return $argument; - } - if (\is_int($argument)) { - return (float) $argument; - } - if (is_numeric($argument)) { - return (float) $argument; + // Already an object instance + return [[$classOrObject, $methodName], $classOrObject]; } - throw new InvalidArgumentException('Cannot cast value to float. Expected numeric representation.'); + throw new InvalidArgumentException('Invalid handler type'); } - /** - * Helper to cast strictly to array. - * - * @return array - */ - private function castToArray(mixed $argument): array + private function getClassInstance(string $className): object { - if (\is_array($argument)) { - return $argument; + if (null !== $this->container && $this->container->has($className)) { + return $this->container->get($className); } - throw new InvalidArgumentException('Cannot cast value to array. Expected array.'); + return new $className(); } } diff --git a/src/Capability/Registry/ReflectionArgumentPreparationTrait.php b/src/Capability/Registry/ReflectionArgumentPreparationTrait.php new file mode 100644 index 00000000..d0043ee7 --- /dev/null +++ b/src/Capability/Registry/ReflectionArgumentPreparationTrait.php @@ -0,0 +1,208 @@ + + */ +trait ReflectionArgumentPreparationTrait +{ + /** + * @param array $arguments + * + * @return array + */ + public function prepareArguments(array $arguments, callable $resolvedHandler): array + { + $reflection = $this->getReflectionFromCallable($resolvedHandler); + $finalArgs = []; + + foreach ($reflection->getParameters() as $parameter) { + $paramName = $parameter->getName(); + $paramPosition = $parameter->getPosition(); + + if (isset($arguments[$paramName])) { + $argument = $arguments[$paramName]; + try { + $finalArgs[$paramPosition] = $this->castArgumentType($argument, $parameter); + } catch (InvalidArgumentException $e) { + throw RegistryException::invalidParams($e->getMessage(), $e); + } catch (\Throwable $e) { + throw RegistryException::internalError("Error processing parameter `{$paramName}`: {$e->getMessage()}", $e); + } + } elseif ($parameter->isDefaultValueAvailable()) { + $finalArgs[$paramPosition] = $parameter->getDefaultValue(); + } elseif ($parameter->allowsNull()) { + $finalArgs[$paramPosition] = null; + } elseif ($parameter->isOptional()) { + continue; + } else { + $reflectionName = $reflection instanceof \ReflectionMethod + ? $reflection->class.'::'.$reflection->name + : 'Closure'; + throw RegistryException::internalError("Missing required argument `{$paramName}` for {$reflectionName}."); + } + } + + return array_values($finalArgs); + } + + private function getReflectionFromCallable(callable $handler): \ReflectionFunctionAbstract + { + if ($handler instanceof \Closure) { + return new \ReflectionFunction($handler); + } + + if (\is_string($handler)) { + return new \ReflectionFunction($handler); + } + + if (\is_array($handler) && 2 === \count($handler)) { + [$classOrObject, $method] = $handler; + + return new \ReflectionMethod($classOrObject, $method); + } + + if (\is_object($handler) && method_exists($handler, '__invoke')) { + return new \ReflectionMethod($handler, '__invoke'); + } + + throw new InvalidArgumentException('Cannot create reflection for this callable type'); + } + + private function castArgumentType(mixed $argument, \ReflectionParameter $parameter): mixed + { + $type = $parameter->getType(); + + if (null === $argument) { + if ($type && $type->allowsNull()) { + return null; + } + } + + if (!$type instanceof \ReflectionNamedType) { + return $argument; + } + + $typeName = $type->getName(); + + if (enum_exists($typeName)) { + return $this->castToEnum($argument, $typeName); + } + + try { + return match (strtolower($typeName)) { + 'int', 'integer' => $this->castToInt($argument), + 'string' => (string) $argument, + 'bool', 'boolean' => $this->castToBoolean($argument), + 'float', 'double' => $this->castToFloat($argument), + 'array' => $this->castToArray($argument), + default => $argument, + }; + } catch (\TypeError $e) { + throw new InvalidArgumentException("Value cannot be cast to required type `{$typeName}`.", 0, $e); + } + } + + /** + * @param class-string $typeName + */ + private function castToEnum(mixed $argument, string $typeName): mixed + { + if (\is_object($argument) && $argument instanceof $typeName) { + return $argument; + } + + if (is_subclass_of($typeName, \BackedEnum::class)) { + $value = $typeName::tryFrom($argument); + if (null === $value) { + throw new InvalidArgumentException("Invalid value '{$argument}' for backed enum {$typeName}."); + } + + return $value; + } + + if (\is_string($argument)) { + foreach ($typeName::cases() as $case) { + if ($case->name === $argument) { + return $case; + } + } + $validNames = array_map(fn ($c) => $c->name, $typeName::cases()); + throw new InvalidArgumentException("Invalid value '{$argument}' for unit enum {$typeName}. Expected one of: ".implode(', ', $validNames).'.'); + } + + throw new InvalidArgumentException("Invalid value type for unit enum {$typeName}."); + } + + private function castToBoolean(mixed $argument): bool + { + if (\is_bool($argument)) { + return $argument; + } + if (1 === $argument || '1' === $argument || 'true' === strtolower((string) $argument)) { + return true; + } + if (0 === $argument || '0' === $argument || 'false' === strtolower((string) $argument)) { + return false; + } + + throw new InvalidArgumentException('Cannot cast value to boolean.'); + } + + private function castToInt(mixed $argument): int + { + if (\is_int($argument)) { + return $argument; + } + if (is_numeric($argument) && floor((float) $argument) == $argument && !\is_string($argument)) { + return (int) $argument; + } + if (\is_string($argument) && ctype_digit(ltrim($argument, '-'))) { + return (int) $argument; + } + + throw new InvalidArgumentException('Cannot cast value to integer.'); + } + + private function castToFloat(mixed $argument): float + { + if (\is_float($argument)) { + return $argument; + } + if (\is_int($argument)) { + return (float) $argument; + } + if (is_numeric($argument)) { + return (float) $argument; + } + + throw new InvalidArgumentException('Cannot cast value to float.'); + } + + /** + * @return array + */ + private function castToArray(mixed $argument): array + { + if (\is_array($argument)) { + return $argument; + } + + throw new InvalidArgumentException('Cannot cast value to array.'); + } +} diff --git a/src/Capability/Registry/ResourceReference.php b/src/Capability/Registry/ResourceReference.php index d9b6a7e4..6f38378d 100644 --- a/src/Capability/Registry/ResourceReference.php +++ b/src/Capability/Registry/ResourceReference.php @@ -11,11 +11,8 @@ namespace Mcp\Capability\Registry; -use Mcp\Exception\RuntimeException; -use Mcp\Schema\Content\BlobResourceContents; -use Mcp\Schema\Content\EmbeddedResource; +use Mcp\Capability\Formatter\ResourceResultFormatter; use Mcp\Schema\Content\ResourceContents; -use Mcp\Schema\Content\TextResourceContents; use Mcp\Schema\Resource; /** @@ -25,6 +22,8 @@ */ class ResourceReference extends ElementReference { + use ReflectionArgumentPreparationTrait; + /** * @param Handler $handler */ @@ -45,10 +44,8 @@ public function __construct( * * @return ResourceContents[] array of ResourceContents objects * - * @throws RuntimeException If the result cannot be formatted. - * * Supported result types: - * - ResourceContent: Used as-is + * - ResourceContents: Used as-is * - EmbeddedResource: Resource is extracted from the EmbeddedResource * - string: Converted to text content with guessed or provided MIME type * - stream resource: Read and converted to blob with provided MIME type @@ -60,151 +57,6 @@ public function __construct( */ public function formatResult(mixed $readResult, string $uri, ?string $mimeType = null): array { - if ($readResult instanceof ResourceContents) { - return [$readResult]; - } - - if ($readResult instanceof EmbeddedResource) { - return [$readResult->resource]; - } - - $meta = $this->schema->meta; - - if (\is_array($readResult)) { - if (empty($readResult)) { - return [new TextResourceContents($uri, 'application/json', '[]', $meta)]; - } - - $allAreResourceContents = true; - $hasResourceContents = false; - $allAreEmbeddedResource = true; - $hasEmbeddedResource = false; - - foreach ($readResult as $item) { - if ($item instanceof ResourceContents) { - $hasResourceContents = true; - $allAreEmbeddedResource = false; - } elseif ($item instanceof EmbeddedResource) { - $hasEmbeddedResource = true; - $allAreResourceContents = false; - } else { - $allAreResourceContents = false; - $allAreEmbeddedResource = false; - } - } - - if ($allAreResourceContents && $hasResourceContents) { - return $readResult; - } - - if ($allAreEmbeddedResource && $hasEmbeddedResource) { - return array_map(fn ($item) => $item->resource, $readResult); - } - - if ($hasResourceContents || $hasEmbeddedResource) { - $result = []; - foreach ($readResult as $item) { - if ($item instanceof ResourceContents) { - $result[] = $item; - } elseif ($item instanceof EmbeddedResource) { - $result[] = $item->resource; - } else { - $result = array_merge($result, $this->formatResult($item, $uri, $mimeType)); - } - } - - return $result; - } - } - - if (\is_string($readResult)) { - $mimeType = $mimeType ?? $this->guessMimeTypeFromString($readResult); - - return [new TextResourceContents($uri, $mimeType, $readResult, $meta)]; - } - - if (\is_resource($readResult) && 'stream' === get_resource_type($readResult)) { - $result = BlobResourceContents::fromStream( - $uri, - $readResult, - $mimeType ?? 'application/octet-stream', - $meta - ); - - @fclose($readResult); - - return [$result]; - } - - if (\is_array($readResult) && isset($readResult['blob']) && \is_string($readResult['blob'])) { - $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'application/octet-stream'; - - return [new BlobResourceContents($uri, $mimeType, $readResult['blob'], $meta)]; - } - - if (\is_array($readResult) && isset($readResult['text']) && \is_string($readResult['text'])) { - $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'text/plain'; - - return [new TextResourceContents($uri, $mimeType, $readResult['text'], $meta)]; - } - - if ($readResult instanceof \SplFileInfo && $readResult->isFile() && $readResult->isReadable()) { - if ($mimeType && str_contains(strtolower($mimeType), 'text')) { - return [new TextResourceContents($uri, $mimeType, file_get_contents($readResult->getPathname()), $meta)]; - } - - return [BlobResourceContents::fromSplFileInfo($uri, $readResult, $mimeType, $meta)]; - } - - if (\is_array($readResult)) { - if ($mimeType && (str_contains(strtolower($mimeType), 'json') - || 'application/json' === $mimeType)) { - try { - $jsonString = json_encode($readResult, \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT); - - return [new TextResourceContents($uri, $mimeType, $jsonString, $meta)]; - } catch (\JsonException $e) { - throw new RuntimeException(\sprintf('Failed to encode array as JSON for URI "%s": %s', $uri, $e->getMessage())); - } - } - - try { - $jsonString = json_encode($readResult, \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT); - $mimeType = $mimeType ?? 'application/json'; - - return [new TextResourceContents($uri, $mimeType, $jsonString, $meta)]; - } catch (\JsonException $e) { - throw new RuntimeException(\sprintf('Failed to encode array as JSON for URI "%s": %s', $uri, $e->getMessage())); - } - } - - throw new RuntimeException(\sprintf('Cannot format resource read result for URI "%s". Handler method returned unhandled type: ', $uri).\gettype($readResult)); - } - - /** Guesses MIME type from string content (very basic) */ - private function guessMimeTypeFromString(string $content): string - { - $trimmed = ltrim($content); - - if (str_starts_with($trimmed, '<') && str_ends_with(rtrim($content), '>')) { - if (str_contains($trimmed, 'format($readResult, $uri, $mimeType, $this->schema->meta); } } diff --git a/src/Capability/Registry/ResourceTemplateReference.php b/src/Capability/Registry/ResourceTemplateReference.php index 88104c9d..07cb2f54 100644 --- a/src/Capability/Registry/ResourceTemplateReference.php +++ b/src/Capability/Registry/ResourceTemplateReference.php @@ -11,11 +11,8 @@ namespace Mcp\Capability\Registry; -use Mcp\Exception\RuntimeException; -use Mcp\Schema\Content\BlobResourceContents; -use Mcp\Schema\Content\EmbeddedResource; +use Mcp\Capability\Formatter\ResourceResultFormatter; use Mcp\Schema\Content\ResourceContents; -use Mcp\Schema\Content\TextResourceContents; use Mcp\Schema\ResourceTemplate; /** @@ -25,12 +22,9 @@ */ class ResourceTemplateReference extends ElementReference { - /** - * @var array - */ - private array $variableNames; + use ReflectionArgumentPreparationTrait; - private string $uriTemplateRegex; + private readonly UriTemplateMatcher $uriTemplateMatcher; /** * @param Handler $handler @@ -41,10 +35,11 @@ public function __construct( callable|array|string $handler, bool $isManual = false, public readonly array $completionProviders = [], + ?UriTemplateMatcher $uriTemplateMatcher = null, ) { parent::__construct($handler, $isManual); - $this->compileTemplate(); + $this->uriTemplateMatcher = $uriTemplateMatcher ?? new UriTemplateMatcher(); } /** @@ -52,22 +47,20 @@ public function __construct( */ public function getVariableNames(): array { - return $this->variableNames; + return $this->uriTemplateMatcher->getVariableNames($this->resourceTemplate->uriTemplate); } public function matches(string $uri): bool { - return 1 === preg_match($this->uriTemplateRegex, $uri); + return $this->uriTemplateMatcher->matches($uri, $this->resourceTemplate->uriTemplate); } - /** @return array */ + /** + * @return array + */ public function extractVariables(string $uri): array { - $matches = []; - - preg_match($this->uriTemplateRegex, $uri, $matches); - - return array_filter($matches, fn ($key) => \in_array($key, $this->variableNames), \ARRAY_FILTER_USE_KEY); + return $this->uriTemplateMatcher->extractVariables($uri, $this->resourceTemplate->uriTemplate); } /** @@ -81,7 +74,7 @@ public function extractVariables(string $uri): array * @throws \RuntimeException If the result cannot be formatted. * * Supported result types: - * - ResourceContent: Used as-is + * - ResourceContents: Used as-is * - EmbeddedResource: Resource is extracted from the EmbeddedResource * - string: Converted to text content with guessed or provided MIME type * - stream resource: Read and converted to blob with provided MIME type @@ -93,171 +86,6 @@ public function extractVariables(string $uri): array */ public function formatResult(mixed $readResult, string $uri, ?string $mimeType = null): array { - if ($readResult instanceof ResourceContents) { - return [$readResult]; - } - - if ($readResult instanceof EmbeddedResource) { - return [$readResult->resource]; - } - - $meta = $this->resourceTemplate->meta; - - if (\is_array($readResult)) { - if (empty($readResult)) { - return [new TextResourceContents($uri, 'application/json', '[]', $meta)]; - } - - $allAreResourceContents = true; - $hasResourceContents = false; - $allAreEmbeddedResource = true; - $hasEmbeddedResource = false; - - foreach ($readResult as $item) { - if ($item instanceof ResourceContents) { - $hasResourceContents = true; - $allAreEmbeddedResource = false; - } elseif ($item instanceof EmbeddedResource) { - $hasEmbeddedResource = true; - $allAreResourceContents = false; - } else { - $allAreResourceContents = false; - $allAreEmbeddedResource = false; - } - } - - if ($allAreResourceContents && $hasResourceContents) { - return $readResult; - } - - if ($allAreEmbeddedResource && $hasEmbeddedResource) { - return array_map(fn ($item) => $item->resource, $readResult); - } - - if ($hasResourceContents || $hasEmbeddedResource) { - $result = []; - foreach ($readResult as $item) { - if ($item instanceof ResourceContents) { - $result[] = $item; - } elseif ($item instanceof EmbeddedResource) { - $result[] = $item->resource; - } else { - $result = array_merge($result, $this->formatResult($item, $uri, $mimeType)); - } - } - - return $result; - } - } - - if (\is_string($readResult)) { - $mimeType = $mimeType ?? $this->guessMimeTypeFromString($readResult); - - return [new TextResourceContents($uri, $mimeType, $readResult, $meta)]; - } - - if (\is_resource($readResult) && 'stream' === get_resource_type($readResult)) { - $result = BlobResourceContents::fromStream( - $uri, - $readResult, - $mimeType ?? 'application/octet-stream', - $meta - ); - - @fclose($readResult); - - return [$result]; - } - - if (\is_array($readResult) && isset($readResult['blob']) && \is_string($readResult['blob'])) { - $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'application/octet-stream'; - - return [new BlobResourceContents($uri, $mimeType, $readResult['blob'], $meta)]; - } - - if (\is_array($readResult) && isset($readResult['text']) && \is_string($readResult['text'])) { - $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'text/plain'; - - return [new TextResourceContents($uri, $mimeType, $readResult['text'], $meta)]; - } - - if ($readResult instanceof \SplFileInfo && $readResult->isFile() && $readResult->isReadable()) { - if ($mimeType && str_contains(strtolower($mimeType), 'text')) { - return [new TextResourceContents($uri, $mimeType, file_get_contents($readResult->getPathname()), $meta)]; - } - - return [BlobResourceContents::fromSplFileInfo($uri, $readResult, $mimeType, $meta)]; - } - - if (\is_array($readResult)) { - if ($mimeType && (str_contains(strtolower($mimeType), 'json') - || 'application/json' === $mimeType)) { - try { - $jsonString = json_encode($readResult, \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT); - - return [new TextResourceContents($uri, $mimeType, $jsonString, $meta)]; - } catch (\JsonException $e) { - throw new RuntimeException("Failed to encode array as JSON for URI '{$uri}': {$e->getMessage()}"); - } - } - - try { - $jsonString = json_encode($readResult, \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT); - $mimeType = $mimeType ?? 'application/json'; - - return [new TextResourceContents($uri, $mimeType, $jsonString, $meta)]; - } catch (\JsonException $e) { - throw new RuntimeException("Failed to encode array as JSON for URI '{$uri}': {$e->getMessage()}"); - } - } - - throw new RuntimeException("Cannot format resource read result for URI '{$uri}'. Handler method returned unhandled type: ".\gettype($readResult)); - } - - private function compileTemplate(): void - { - $this->variableNames = []; - $regexParts = []; - - $segments = preg_split('/(\{\w+\})/', $this->resourceTemplate->uriTemplate, -1, \PREG_SPLIT_DELIM_CAPTURE | \PREG_SPLIT_NO_EMPTY); - - foreach ($segments as $segment) { - if (preg_match('/^\{(\w+)\}$/', $segment, $matches)) { - $varName = $matches[1]; - $this->variableNames[] = $varName; - $regexParts[] = '(?P<'.$varName.'>[^/]+)'; - } else { - $regexParts[] = preg_quote($segment, '#'); - } - } - - $this->uriTemplateRegex = '#^'.implode('', $regexParts).'$#'; - } - - /** Guesses MIME type from string content (very basic) */ - private function guessMimeTypeFromString(string $content): string - { - $trimmed = ltrim($content); - - if (str_starts_with($trimmed, '<') && str_ends_with(rtrim($content), '>')) { - if (str_contains($trimmed, 'format($readResult, $uri, $mimeType, $this->resourceTemplate->meta); } } diff --git a/src/Capability/Registry/ToolReference.php b/src/Capability/Registry/ToolReference.php index e5b5a7df..13c7f6ab 100644 --- a/src/Capability/Registry/ToolReference.php +++ b/src/Capability/Registry/ToolReference.php @@ -11,6 +11,7 @@ namespace Mcp\Capability\Registry; +use Mcp\Capability\Formatter\ToolResultFormatter; use Mcp\Schema\Content\Content; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Tool; @@ -22,6 +23,8 @@ */ class ToolReference extends ElementReference { + use ReflectionArgumentPreparationTrait; + /** * @param Handler $handler */ @@ -54,61 +57,6 @@ public function __construct( */ public function formatResult(mixed $toolExecutionResult): array { - if ($toolExecutionResult instanceof Content) { - return [$toolExecutionResult]; - } - - if (\is_array($toolExecutionResult)) { - if (empty($toolExecutionResult)) { - return [new TextContent('[]')]; - } - - $allAreContent = true; - $hasContent = false; - - foreach ($toolExecutionResult as $item) { - if ($item instanceof Content) { - $hasContent = true; - } else { - $allAreContent = false; - } - } - - if ($allAreContent && $hasContent) { - return $toolExecutionResult; - } - - if ($hasContent) { - $result = []; - foreach ($toolExecutionResult as $item) { - if ($item instanceof Content) { - $result[] = $item; - } else { - $result = array_merge($result, $this->formatResult($item)); - } - } - - return $result; - } - } - - if (null === $toolExecutionResult) { - return [new TextContent('(null)')]; - } - - if (\is_bool($toolExecutionResult)) { - return [new TextContent($toolExecutionResult ? 'true' : 'false')]; - } - - if (\is_scalar($toolExecutionResult)) { - return [new TextContent($toolExecutionResult)]; - } - - $jsonResult = json_encode( - $toolExecutionResult, - \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR | \JSON_INVALID_UTF8_SUBSTITUTE - ); - - return [new TextContent($jsonResult)]; + return (new ToolResultFormatter())->format($toolExecutionResult); } } diff --git a/src/Capability/Registry/UriTemplateMatcher.php b/src/Capability/Registry/UriTemplateMatcher.php new file mode 100644 index 00000000..d5a15920 --- /dev/null +++ b/src/Capability/Registry/UriTemplateMatcher.php @@ -0,0 +1,113 @@ + + * @author Mateu Aguiló Bosch + */ +final class UriTemplateMatcher +{ + /** + * @var array}> + */ + private array $compiledTemplates = []; + + /** + * Checks if a URI matches a URI template pattern. + * + * @param string $uri The concrete URI to check + * @param string $uriTemplate The URI template with {placeholders} + */ + public function matches(string $uri, string $uriTemplate): bool + { + $compiled = $this->compile($uriTemplate); + + return 1 === preg_match($compiled['regex'], $uri); + } + + /** + * Extracts variable values from a URI based on a template. + * + * @param string $uri The concrete URI to extract from + * @param string $uriTemplate The URI template with {placeholders} + * + * @return array Map of variable name => extracted value + */ + public function extractVariables(string $uri, string $uriTemplate): array + { + $compiled = $this->compile($uriTemplate); + $matches = []; + + if (!preg_match($compiled['regex'], $uri, $matches)) { + return []; + } + + return array_filter( + $matches, + fn ($key) => \in_array($key, $compiled['variables'], true), + \ARRAY_FILTER_USE_KEY + ); + } + + /** + * Gets the variable names defined in a URI template. + * + * @param string $uriTemplate The URI template with {placeholders} + * + * @return array List of variable names + */ + public function getVariableNames(string $uriTemplate): array + { + return $this->compile($uriTemplate)['variables']; + } + + /** + * Compiles a URI template into a regex pattern and extracts variable names. + * + * Results are cached for performance. + * + * @return array{regex: string, variables: array} + */ + private function compile(string $uriTemplate): array + { + if (isset($this->compiledTemplates[$uriTemplate])) { + return $this->compiledTemplates[$uriTemplate]; + } + + $variableNames = []; + $regexParts = []; + + $segments = preg_split('/(\{\w+\})/', $uriTemplate, -1, \PREG_SPLIT_DELIM_CAPTURE | \PREG_SPLIT_NO_EMPTY); + + foreach ($segments as $segment) { + if (preg_match('/^\{(\w+)\}$/', $segment, $matches)) { + $varName = $matches[1]; + $variableNames[] = $varName; + $regexParts[] = '(?P<'.$varName.'>[^/]+)'; + } else { + $regexParts[] = preg_quote($segment, '#'); + } + } + + $this->compiledTemplates[$uriTemplate] = [ + 'regex' => '#^'.implode('', $regexParts).'$#', + 'variables' => $variableNames, + ]; + + return $this->compiledTemplates[$uriTemplate]; + } +} diff --git a/src/Capability/RegistryInterface.php b/src/Capability/RegistryInterface.php index 67295681..264b583e 100644 --- a/src/Capability/RegistryInterface.php +++ b/src/Capability/RegistryInterface.php @@ -12,6 +12,14 @@ namespace Mcp\Capability; use Mcp\Capability\Discovery\DiscoveryState; +use Mcp\Capability\Provider\DynamicPromptProviderInterface; +use Mcp\Capability\Provider\DynamicResourceProviderInterface; +use Mcp\Capability\Provider\DynamicResourceTemplateProviderInterface; +use Mcp\Capability\Provider\DynamicToolProviderInterface; +use Mcp\Capability\Registry\DynamicPromptReference; +use Mcp\Capability\Registry\DynamicResourceReference; +use Mcp\Capability\Registry\DynamicResourceTemplateReference; +use Mcp\Capability\Registry\DynamicToolReference; use Mcp\Capability\Registry\ElementReference; use Mcp\Capability\Registry\PromptReference; use Mcp\Capability\Registry\ResourceReference; @@ -157,4 +165,98 @@ public function getPrompts(?int $limit = null, ?string $cursor = null): Page; * @throws PromptNotFoundException */ public function getPrompt(string $name): PromptReference; + + /** + * Registers a dynamic tool provider. + */ + public function registerDynamicToolProvider(DynamicToolProviderInterface $provider): void; + + /** + * Registers a dynamic prompt provider. + */ + public function registerDynamicPromptProvider(DynamicPromptProviderInterface $provider): void; + + /** + * Registers a dynamic resource provider. + */ + public function registerDynamicResourceProvider(DynamicResourceProviderInterface $provider): void; + + /** + * Gets all registered dynamic tool providers. + * + * @return array + */ + public function getDynamicToolProviders(): array; + + /** + * Gets all registered dynamic prompt providers. + * + * @return array + */ + public function getDynamicPromptProviders(): array; + + /** + * Gets all registered dynamic resource providers. + * + * @return array + */ + public function getDynamicResourceProviders(): array; + + /** + * Registers a dynamic resource template provider. + */ + public function registerDynamicResourceTemplateProvider(DynamicResourceTemplateProviderInterface $provider): void; + + /** + * Gets all registered dynamic resource template providers. + * + * @return array + */ + public function getDynamicResourceTemplateProviders(): array; + + /** + * Gets completion providers from a dynamic prompt provider. + * + * @return array|null Completion providers, or null if no dynamic provider found + */ + public function getDynamicPromptCompletionProviders(string $name): ?array; + + /** + * Gets completion providers from a dynamic resource template provider. + * + * @return array|null Completion providers, or null if no dynamic provider found + */ + public function getDynamicResourceTemplateCompletionProviders(string $uri): ?array; + + /** + * Gets a dynamic tool reference by name. + * + * Returns a reference that wraps the dynamic tool provider for uniform handling + * with static tool references. Returns null if no dynamic provider supports the tool. + */ + public function getDynamicTool(string $name): ?DynamicToolReference; + + /** + * Gets a dynamic prompt reference by name. + * + * Returns a reference that wraps the dynamic prompt provider for uniform handling + * with static prompt references. Returns null if no dynamic provider supports the prompt. + */ + public function getDynamicPrompt(string $name): ?DynamicPromptReference; + + /** + * Gets a dynamic resource reference by URI. + * + * Returns a reference that wraps the dynamic resource provider for uniform handling + * with static resource references. Returns null if no dynamic provider supports the URI. + */ + public function getDynamicResource(string $uri): ?DynamicResourceReference; + + /** + * Gets a dynamic resource template reference by URI. + * + * Returns a reference that wraps the dynamic resource template provider for uniform handling + * with static resource template references. Returns null if no dynamic provider supports the URI. + */ + public function getDynamicResourceTemplate(string $uri): ?DynamicResourceTemplateReference; } diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 4142b97a..4f398739 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -11,6 +11,10 @@ namespace Mcp\Server; +use Mcp\Capability\Provider\DynamicPromptProviderInterface; +use Mcp\Capability\Provider\DynamicResourceProviderInterface; +use Mcp\Capability\Provider\DynamicResourceTemplateProviderInterface; +use Mcp\Capability\Provider\DynamicToolProviderInterface; use Mcp\Capability\Registry; use Mcp\Capability\Registry\Container; use Mcp\Capability\Registry\ElementReference; @@ -150,6 +154,26 @@ final class Builder */ private array $loaders = []; + /** + * @var array + */ + private array $dynamicToolProviders = []; + + /** + * @var array + */ + private array $dynamicPromptProviders = []; + + /** + * @var array + */ + private array $dynamicResourceProviders = []; + + /** + * @var array + */ + private array $dynamicResourceTemplateProviders = []; + /** * Sets the server's identity. Required. * @@ -443,6 +467,59 @@ public function addLoaders(...$loaders): self return $this; } + /** + * Registers a dynamic tool provider. + * + * Dynamic providers allow tools to be registered or updated at runtime based on + * application state or external conditions. + */ + public function addDynamicToolProvider(DynamicToolProviderInterface $provider): self + { + $this->dynamicToolProviders[] = $provider; + + return $this; + } + + /** + * Registers a dynamic prompt provider. + * + * Dynamic providers allow prompts to be registered or updated at runtime based on + * application state or external conditions. + */ + public function addDynamicPromptProvider(DynamicPromptProviderInterface $provider): self + { + $this->dynamicPromptProviders[] = $provider; + + return $this; + } + + /** + * Registers a dynamic resource provider. + * + * Dynamic providers allow resources to be registered or updated at runtime based on + * application state or external conditions. + */ + public function addDynamicResourceProvider(DynamicResourceProviderInterface $provider): self + { + $this->dynamicResourceProviders[] = $provider; + + return $this; + } + + /** + * Registers a dynamic resource template provider. + * + * Dynamic providers allow resource templates to be registered or updated at runtime + * based on application state or external conditions. Resource templates describe + * URI patterns that clients can use to construct valid resource URIs. + */ + public function addDynamicResourceTemplateProvider(DynamicResourceTemplateProviderInterface $provider): self + { + $this->dynamicResourceTemplateProviders[] = $provider; + + return $this; + } + /** * Builds the fully configured Server instance. */ @@ -465,18 +542,24 @@ public function build(): Server $loader->load($registry); } + // Dynamic providers are registered after static loaders for proper collision detection + array_map($registry->registerDynamicToolProvider(...), $this->dynamicToolProviders); + array_map($registry->registerDynamicPromptProvider(...), $this->dynamicPromptProviders); + array_map($registry->registerDynamicResourceProvider(...), $this->dynamicResourceProviders); + array_map($registry->registerDynamicResourceTemplateProvider(...), $this->dynamicResourceTemplateProviders); + $sessionTtl = $this->sessionTtl ?? 3600; $sessionFactory = $this->sessionFactory ?? new SessionFactory(); $sessionStore = $this->sessionStore ?? new InMemorySessionStore($sessionTtl); $messageFactory = MessageFactory::make(); $capabilities = $this->serverCapabilities ?? new ServerCapabilities( - tools: $registry->hasTools(), + tools: $registry->hasTools() || [] !== $this->dynamicToolProviders, toolsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, - resources: $registry->hasResources() || $registry->hasResourceTemplates(), + resources: $registry->hasResources() || $registry->hasResourceTemplates() || [] !== $this->dynamicResourceProviders || [] !== $this->dynamicResourceTemplateProviders, resourcesSubscribe: false, resourcesListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, - prompts: $registry->hasPrompts(), + prompts: $registry->hasPrompts() || [] !== $this->dynamicPromptProviders, promptsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, logging: false, completions: true, diff --git a/src/Server/Handler/Request/CallToolHandler.php b/src/Server/Handler/Request/CallToolHandler.php index e0430802..7e393f36 100644 --- a/src/Server/Handler/Request/CallToolHandler.php +++ b/src/Server/Handler/Request/CallToolHandler.php @@ -11,6 +11,7 @@ namespace Mcp\Server\Handler\Request; +use Mcp\Capability\Registry\DynamicToolReference; use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\RegistryInterface; use Mcp\Exception\ToolCallException; @@ -58,7 +59,9 @@ public function handle(Request $request, SessionInterface $session): Response|Er $this->logger->debug('Executing tool', ['name' => $toolName, 'arguments' => $arguments]); try { - $reference = $this->registry->getTool($toolName); + // Try dynamic tool first, fall back to static + $reference = $this->registry->getDynamicTool($toolName) + ?? $this->registry->getTool($toolName); $arguments['_session'] = $session; @@ -71,6 +74,7 @@ public function handle(Request $request, SessionInterface $session): Response|Er $this->logger->debug('Tool executed successfully', [ 'name' => $toolName, 'result_type' => \gettype($result), + 'dynamic' => $reference instanceof DynamicToolReference, ]); return new Response($request->getId(), $result); diff --git a/src/Server/Handler/Request/CompletionCompleteHandler.php b/src/Server/Handler/Request/CompletionCompleteHandler.php index 29c41fb7..201c9dda 100644 --- a/src/Server/Handler/Request/CompletionCompleteHandler.php +++ b/src/Server/Handler/Request/CompletionCompleteHandler.php @@ -56,12 +56,27 @@ public function handle(Request $request, SessionInterface $session): Response|Er $value = $request->argument['value'] ?? ''; try { - $reference = match (true) { - $request->ref instanceof PromptReference => $this->registry->getPrompt($request->ref->name), - $request->ref instanceof ResourceReference => $this->registry->getResource($request->ref->uri), - }; + $providers = []; + + if ($request->ref instanceof PromptReference) { + $providers = $this->registry->getDynamicPromptCompletionProviders($request->ref->name); + if (null === $providers) { + $reference = $this->registry->getPrompt($request->ref->name); + $providers = $reference->completionProviders; + } + } elseif (str_contains($request->ref->uri, '{')) { + // URI template - check dynamic resource template providers first + $providers = $this->registry->getDynamicResourceTemplateCompletionProviders($request->ref->uri); + if (null === $providers) { + $reference = $this->registry->getResourceTemplate($request->ref->uri); + $providers = $reference->completionProviders; + } + } else { + // Concrete URI - use existing static resource logic + $reference = $this->registry->getResource($request->ref->uri); + $providers = $reference->completionProviders; + } - $providers = $reference->completionProviders; $provider = $providers[$name] ?? null; if (null === $provider) { return new Response($request->getId(), new CompletionCompleteResult([])); diff --git a/src/Server/Handler/Request/GetPromptHandler.php b/src/Server/Handler/Request/GetPromptHandler.php index 274b8422..1e4be120 100644 --- a/src/Server/Handler/Request/GetPromptHandler.php +++ b/src/Server/Handler/Request/GetPromptHandler.php @@ -11,6 +11,7 @@ namespace Mcp\Server\Handler\Request; +use Mcp\Capability\Registry\DynamicPromptReference; use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\RegistryInterface; use Mcp\Exception\PromptGetException; @@ -54,7 +55,9 @@ public function handle(Request $request, SessionInterface $session): Response|Er $arguments = $request->arguments ?? []; try { - $reference = $this->registry->getPrompt($promptName); + // Try dynamic prompt first, fall back to static + $reference = $this->registry->getDynamicPrompt($promptName) + ?? $this->registry->getPrompt($promptName); $arguments['_session'] = $session; @@ -62,6 +65,11 @@ public function handle(Request $request, SessionInterface $session): Response|Er $formatted = $reference->formatResult($result); + $this->logger->debug('Prompt handled successfully', [ + 'name' => $promptName, + 'dynamic' => $reference instanceof DynamicPromptReference, + ]); + return new Response($request->getId(), new GetPromptResult($formatted)); } catch (PromptGetException $e) { $this->logger->error(\sprintf('Error while handling prompt "%s": "%s".', $promptName, $e->getMessage())); diff --git a/src/Server/Handler/Request/ReadResourceHandler.php b/src/Server/Handler/Request/ReadResourceHandler.php index f955f4b1..f2274639 100644 --- a/src/Server/Handler/Request/ReadResourceHandler.php +++ b/src/Server/Handler/Request/ReadResourceHandler.php @@ -11,7 +11,10 @@ namespace Mcp\Server\Handler\Request; +use Mcp\Capability\Registry\DynamicResourceReference; +use Mcp\Capability\Registry\DynamicResourceTemplateReference; use Mcp\Capability\Registry\ReferenceHandlerInterface; +use Mcp\Capability\Registry\ResourceReference; use Mcp\Capability\Registry\ResourceTemplateReference; use Mcp\Capability\RegistryInterface; use Mcp\Exception\ResourceNotFoundException; @@ -33,7 +36,7 @@ final class ReadResourceHandler implements RequestHandlerInterface { public function __construct( - private readonly RegistryInterface $referenceProvider, + private readonly RegistryInterface $registry, private readonly ReferenceHandlerInterface $referenceHandler, private readonly LoggerInterface $logger = new NullLogger(), ) { @@ -56,24 +59,33 @@ public function handle(Request $request, SessionInterface $session): Response|Er $this->logger->debug('Reading resource', ['uri' => $uri]); try { - $reference = $this->referenceProvider->getResource($uri); + // Try dynamic resource first, then dynamic template, fall back to static + $reference = $this->registry->getDynamicResource($uri) + ?? $this->registry->getDynamicResourceTemplate($uri) + ?? $this->registry->getResource($uri); $arguments = [ 'uri' => $uri, '_session' => $session, ]; - if ($reference instanceof ResourceTemplateReference) { + // For template references, extract variables from URI + if ($reference instanceof ResourceTemplateReference || $reference instanceof DynamicResourceTemplateReference) { $variables = $reference->extractVariables($uri); $arguments = array_merge($arguments, $variables); - - $result = $this->referenceHandler->handle($reference, $arguments); - $formatted = $reference->formatResult($result, $uri, $reference->resourceTemplate->mimeType); - } else { - $result = $this->referenceHandler->handle($reference, $arguments); - $formatted = $reference->formatResult($result, $uri, $reference->schema->mimeType); } + $result = $this->referenceHandler->handle($reference, $arguments); + + // Format result based on reference type + $mimeType = $this->getMimeType($reference); + $formatted = $reference->formatResult($result, $uri, $mimeType); + + $this->logger->debug('Resource read successfully', [ + 'uri' => $uri, + 'dynamic' => $reference instanceof DynamicResourceReference || $reference instanceof DynamicResourceTemplateReference, + ]); + return new Response($request->getId(), new ReadResourceResult($formatted)); } catch (ResourceReadException $e) { $this->logger->error(\sprintf('Error while reading resource "%s": "%s".', $uri, $e->getMessage())); @@ -89,4 +101,18 @@ public function handle(Request $request, SessionInterface $session): Response|Er return Error::forInternalError('Error while reading resource', $request->getId()); } } + + /** + * Gets the MIME type from a resource reference. + */ + private function getMimeType( + ResourceReference|ResourceTemplateReference|DynamicResourceReference|DynamicResourceTemplateReference $reference, + ): ?string { + return match (true) { + $reference instanceof ResourceReference => $reference->schema->mimeType, + $reference instanceof DynamicResourceReference => $reference->schema->mimeType, + $reference instanceof ResourceTemplateReference => $reference->resourceTemplate->mimeType, + $reference instanceof DynamicResourceTemplateReference => $reference->resourceTemplate->mimeType, + }; + } } diff --git a/tests/Unit/Capability/Formatter/PromptResultFormatterTest.php b/tests/Unit/Capability/Formatter/PromptResultFormatterTest.php new file mode 100644 index 00000000..53d6dc72 --- /dev/null +++ b/tests/Unit/Capability/Formatter/PromptResultFormatterTest.php @@ -0,0 +1,49 @@ +format($message); + $this->assertCount(1, $result); + $this->assertSame($message, $result[0]); + } + + public function testFormatUserAssistantShorthand(): void + { + $result = (new PromptResultFormatter())->format([ + 'user' => 'Hello', + 'assistant' => 'Hi there', + ]); + $this->assertCount(2, $result); + $this->assertSame(Role::User, $result[0]->role); + $this->assertSame(Role::Assistant, $result[1]->role); + } + + public function testFormatRoleContentArray(): void + { + $result = (new PromptResultFormatter())->format([ + ['role' => 'user', 'content' => 'Hello'], + ]); + $this->assertCount(1, $result); + $this->assertSame(Role::User, $result[0]->role); + } +} diff --git a/tests/Unit/Capability/Formatter/ResourceResultFormatterTest.php b/tests/Unit/Capability/Formatter/ResourceResultFormatterTest.php new file mode 100644 index 00000000..e98e617d --- /dev/null +++ b/tests/Unit/Capability/Formatter/ResourceResultFormatterTest.php @@ -0,0 +1,40 @@ +format('content', 'file://test'); + $this->assertCount(1, $result); + $this->assertInstanceOf(TextResourceContents::class, $result[0]); + } + + public function testFormatResourceContents(): void + { + $contents = new TextResourceContents('file://test', 'text/plain', 'content'); + $result = (new ResourceResultFormatter())->format($contents, 'file://test'); + $this->assertSame([$contents], $result); + } + + public function testFormatWithMimeType(): void + { + $result = (new ResourceResultFormatter())->format('content', 'file://test', 'text/html'); + $this->assertCount(1, $result); + $this->assertSame('text/html', $result[0]->mimeType); + } +} diff --git a/tests/Unit/Capability/Formatter/ToolResultFormatterTest.php b/tests/Unit/Capability/Formatter/ToolResultFormatterTest.php new file mode 100644 index 00000000..1c5ee7c1 --- /dev/null +++ b/tests/Unit/Capability/Formatter/ToolResultFormatterTest.php @@ -0,0 +1,58 @@ +format('hello'); + $this->assertCount(1, $result); + $this->assertInstanceOf(TextContent::class, $result[0]); + $this->assertSame('hello', $result[0]->text); + } + + public function testFormatContentResult(): void + { + $content = new TextContent('test'); + $result = (new ToolResultFormatter())->format($content); + $this->assertSame([$content], $result); + } + + public function testFormatArrayResult(): void + { + $result = (new ToolResultFormatter())->format(['key' => 'value']); + $this->assertCount(1, $result); + $this->assertInstanceOf(TextContent::class, $result[0]); + $this->assertStringContainsString('value', $result[0]->text); + } + + public function testFormatNullResult(): void + { + $result = (new ToolResultFormatter())->format(null); + $this->assertCount(1, $result); + $this->assertInstanceOf(TextContent::class, $result[0]); + $this->assertSame('(null)', $result[0]->text); + } + + public function testFormatBoolResult(): void + { + $result = (new ToolResultFormatter())->format(true); + $this->assertCount(1, $result); + $this->assertInstanceOf(TextContent::class, $result[0]); + $this->assertSame('true', $result[0]->text); + } +} diff --git a/tests/Unit/Capability/Provider/DynamicPromptProviderTest.php b/tests/Unit/Capability/Provider/DynamicPromptProviderTest.php new file mode 100644 index 00000000..d51dda29 --- /dev/null +++ b/tests/Unit/Capability/Provider/DynamicPromptProviderTest.php @@ -0,0 +1,120 @@ +registry = new Registry(null, new NullLogger()); + } + + public function testProviderRegistrationInRegistry(): void + { + $prompt = $this->createPrompt('test_prompt'); + $provider = new TestDynamicPromptProvider([$prompt]); + + $this->registry->registerDynamicPromptProvider($provider); + + $providers = $this->registry->getDynamicPromptProviders(); + $this->assertCount(1, $providers); + $this->assertSame($provider, $providers[0]); + } + + public function testPromptEnumerationFromDynamicProvider(): void + { + $prompt1 = $this->createPrompt('dynamic_prompt_1'); + $prompt2 = $this->createPrompt('dynamic_prompt_2'); + $provider = new TestDynamicPromptProvider([$prompt1, $prompt2]); + + $this->registry->registerDynamicPromptProvider($provider); + + $page = $this->registry->getPrompts(); + $prompts = $page->references; + + $this->assertCount(2, $prompts); + $this->assertArrayHasKey('dynamic_prompt_1', $prompts); + $this->assertArrayHasKey('dynamic_prompt_2', $prompts); + $this->assertSame($prompt1, $prompts['dynamic_prompt_1']); + $this->assertSame($prompt2, $prompts['dynamic_prompt_2']); + } + + public function testPromptEnumerationFromMixedSources(): void + { + // Register a static prompt + $staticPrompt = $this->createPrompt('static_prompt'); + $this->registry->registerPrompt($staticPrompt, fn () => []); + + // Register a dynamic provider + $dynamicPrompt = $this->createPrompt('dynamic_prompt'); + $provider = new TestDynamicPromptProvider([$dynamicPrompt]); + $this->registry->registerDynamicPromptProvider($provider); + + $page = $this->registry->getPrompts(); + $prompts = $page->references; + + $this->assertCount(2, $prompts); + $this->assertArrayHasKey('static_prompt', $prompts); + $this->assertArrayHasKey('dynamic_prompt', $prompts); + } + + public function testConflictDetectionStaticVsDynamic(): void + { + // Register a static prompt first + $staticPrompt = $this->createPrompt('conflicting_prompt'); + $this->registry->registerPrompt($staticPrompt, fn () => []); + + // Try to register a dynamic provider with the same prompt name + $dynamicPrompt = $this->createPrompt('conflicting_prompt'); + $provider = new TestDynamicPromptProvider([$dynamicPrompt]); + + $this->expectException(RegistryException::class); + $this->expectExceptionMessage('Dynamic prompt provider conflict: prompt "conflicting_prompt" is already registered as a static prompt.'); + + $this->registry->registerDynamicPromptProvider($provider); + } + + public function testConflictDetectionDynamicVsDynamic(): void + { + // Register first dynamic provider + $prompt1 = $this->createPrompt('shared_prompt'); + $provider1 = new TestDynamicPromptProvider([$prompt1]); + $this->registry->registerDynamicPromptProvider($provider1); + + // Try to register second dynamic provider with the same prompt name + $prompt2 = $this->createPrompt('shared_prompt'); + $provider2 = new TestDynamicPromptProvider([$prompt2]); + + $this->expectException(RegistryException::class); + $this->expectExceptionMessage('Dynamic prompt provider conflict: prompt "shared_prompt" is already supported by another provider.'); + + $this->registry->registerDynamicPromptProvider($provider2); + } + + private function createPrompt(string $name): Prompt + { + return new Prompt( + name: $name, + description: "Test prompt: {$name}", + arguments: [], + ); + } +} diff --git a/tests/Unit/Capability/Provider/DynamicResourceProviderTest.php b/tests/Unit/Capability/Provider/DynamicResourceProviderTest.php new file mode 100644 index 00000000..68bef7ef --- /dev/null +++ b/tests/Unit/Capability/Provider/DynamicResourceProviderTest.php @@ -0,0 +1,125 @@ +registry = new Registry(null, new NullLogger()); + } + + public function testProviderRegistrationInRegistry(): void + { + $resource = $this->createResource('test://resource'); + $provider = new TestDynamicResourceProvider([$resource]); + + $this->registry->registerDynamicResourceProvider($provider); + + $providers = $this->registry->getDynamicResourceProviders(); + $this->assertCount(1, $providers); + $this->assertSame($provider, $providers[0]); + } + + public function testResourceEnumerationFromDynamicProvider(): void + { + $resource1 = $this->createResource('dynamic://resource1'); + $resource2 = $this->createResource('dynamic://resource2'); + $provider = new TestDynamicResourceProvider([$resource1, $resource2]); + + $this->registry->registerDynamicResourceProvider($provider); + + $page = $this->registry->getResources(); + $resources = $page->references; + + $this->assertCount(2, $resources); + $this->assertArrayHasKey('dynamic://resource1', $resources); + $this->assertArrayHasKey('dynamic://resource2', $resources); + $this->assertSame($resource1, $resources['dynamic://resource1']); + $this->assertSame($resource2, $resources['dynamic://resource2']); + } + + public function testResourceEnumerationFromMixedSources(): void + { + // Register a static resource + $staticResource = $this->createResource('static://resource'); + $this->registry->registerResource($staticResource, fn () => 'static content'); + + // Register a dynamic provider + $dynamicResource = $this->createResource('dynamic://resource'); + $provider = new TestDynamicResourceProvider([$dynamicResource]); + $this->registry->registerDynamicResourceProvider($provider); + + $page = $this->registry->getResources(); + $resources = $page->references; + + $this->assertCount(2, $resources); + $this->assertArrayHasKey('static://resource', $resources); + $this->assertArrayHasKey('dynamic://resource', $resources); + } + + public function testConflictDetectionStaticVsDynamic(): void + { + // Register a static resource first + $staticResource = $this->createResource('conflict://resource'); + $this->registry->registerResource($staticResource, fn () => 'static content'); + + // Try to register a dynamic provider with the same resource URI + $dynamicResource = $this->createResource('conflict://resource'); + $provider = new TestDynamicResourceProvider([$dynamicResource]); + + $this->expectException(RegistryException::class); + $this->expectExceptionMessage('Dynamic resource provider conflict: resource "conflict://resource" is already registered as a static resource.'); + + $this->registry->registerDynamicResourceProvider($provider); + } + + public function testConflictDetectionDynamicVsDynamic(): void + { + // Register first dynamic provider + $resource1 = $this->createResource('shared://resource'); + $provider1 = new TestDynamicResourceProvider([$resource1]); + $this->registry->registerDynamicResourceProvider($provider1); + + // Try to register second dynamic provider with the same resource URI + $resource2 = $this->createResource('shared://resource'); + $provider2 = new TestDynamicResourceProvider([$resource2]); + + $this->expectException(RegistryException::class); + $this->expectExceptionMessage('Dynamic resource provider conflict: resource "shared://resource" is already supported by another provider.'); + + $this->registry->registerDynamicResourceProvider($provider2); + } + + private function createResource(string $uri, ?string $mimeType = null): Resource + { + // Generate a valid resource name (only alphanumeric, underscores, hyphens) + $name = preg_replace('/[^a-zA-Z0-9_-]/', '_', basename($uri)); + $name = $name ?: 'resource'; + + return new Resource( + uri: $uri, + name: $name, + description: "Test resource: {$uri}", + mimeType: $mimeType ?? 'text/plain', + ); + } +} diff --git a/tests/Unit/Capability/Provider/DynamicResourceTemplateProviderTest.php b/tests/Unit/Capability/Provider/DynamicResourceTemplateProviderTest.php new file mode 100644 index 00000000..f09d5985 --- /dev/null +++ b/tests/Unit/Capability/Provider/DynamicResourceTemplateProviderTest.php @@ -0,0 +1,136 @@ +registry = new Registry(null, new NullLogger()); + } + + public function testProviderRegistrationInRegistry(): void + { + $template = $this->createTemplate('test://resource/{id}'); + $provider = new TestDynamicResourceTemplateProvider([$template]); + + $this->registry->registerDynamicResourceTemplateProvider($provider); + + $providers = $this->registry->getDynamicResourceTemplateProviders(); + $this->assertCount(1, $providers); + $this->assertSame($provider, $providers[0]); + } + + public function testTemplateEnumerationFromDynamicProvider(): void + { + $template1 = $this->createTemplate('dynamic://resource/{id}'); + $template2 = $this->createTemplate('dynamic://entity/{type}/{id}'); + $provider = new TestDynamicResourceTemplateProvider([$template1, $template2]); + + $this->registry->registerDynamicResourceTemplateProvider($provider); + + $page = $this->registry->getResourceTemplates(); + $templates = $page->references; + + $this->assertCount(2, $templates); + $this->assertArrayHasKey('dynamic://resource/{id}', $templates); + $this->assertArrayHasKey('dynamic://entity/{type}/{id}', $templates); + $this->assertSame($template1, $templates['dynamic://resource/{id}']); + $this->assertSame($template2, $templates['dynamic://entity/{type}/{id}']); + } + + public function testTemplateEnumerationFromMixedSources(): void + { + // Register a static resource template + $staticTemplate = $this->createTemplate('static://template/{id}'); + $this->registry->registerResourceTemplate($staticTemplate, fn () => 'static content'); + + // Register a dynamic provider + $dynamicTemplate = $this->createTemplate('dynamic://template/{id}'); + $provider = new TestDynamicResourceTemplateProvider([$dynamicTemplate]); + $this->registry->registerDynamicResourceTemplateProvider($provider); + + $page = $this->registry->getResourceTemplates(); + $templates = $page->references; + + $this->assertCount(2, $templates); + $this->assertArrayHasKey('static://template/{id}', $templates); + $this->assertArrayHasKey('dynamic://template/{id}', $templates); + } + + public function testConflictDetectionStaticVsDynamic(): void + { + // Register a static resource template first + $staticTemplate = $this->createTemplate('conflict://template/{id}'); + $this->registry->registerResourceTemplate($staticTemplate, fn () => 'static content'); + + // Try to register a dynamic provider with the same template + $dynamicTemplate = $this->createTemplate('conflict://template/{id}'); + $provider = new TestDynamicResourceTemplateProvider([$dynamicTemplate]); + + $this->expectException(RegistryException::class); + $this->expectExceptionMessage('Dynamic resource template provider conflict: template "conflict://template/{id}" is already registered as a static resource template.'); + + $this->registry->registerDynamicResourceTemplateProvider($provider); + } + + public function testConflictDetectionDynamicVsDynamic(): void + { + // Register first dynamic provider + $template1 = $this->createTemplate('shared://template/{id}'); + $provider1 = new TestDynamicResourceTemplateProvider([$template1]); + $this->registry->registerDynamicResourceTemplateProvider($provider1); + + // Try to register second dynamic provider with the same template + $template2 = $this->createTemplate('shared://template/{id}'); + $provider2 = new TestDynamicResourceTemplateProvider([$template2]); + + $this->expectException(RegistryException::class); + $this->expectExceptionMessage('Dynamic resource template provider conflict: template "shared://template/{id}" is already supported by another provider.'); + + $this->registry->registerDynamicResourceTemplateProvider($provider2); + } + + public function testHasResourceTemplatesIncludesDynamic(): void + { + $this->assertFalse($this->registry->hasResourceTemplates()); + + $template = $this->createTemplate('test://template/{id}'); + $provider = new TestDynamicResourceTemplateProvider([$template]); + $this->registry->registerDynamicResourceTemplateProvider($provider); + + $this->assertTrue($this->registry->hasResourceTemplates()); + } + + private function createTemplate(string $uriTemplate, ?string $mimeType = null): ResourceTemplate + { + // Generate a valid resource template name (only alphanumeric, underscores, hyphens) + $name = preg_replace('/[^a-zA-Z0-9_-]/', '_', basename(str_replace(['{', '}'], '', $uriTemplate))); + $name = $name ?: 'template'; + + return new ResourceTemplate( + uriTemplate: $uriTemplate, + name: $name, + description: "Test template: {$uriTemplate}", + mimeType: $mimeType ?? 'text/plain', + ); + } +} diff --git a/tests/Unit/Capability/Provider/DynamicToolProviderTest.php b/tests/Unit/Capability/Provider/DynamicToolProviderTest.php new file mode 100644 index 00000000..3115429c --- /dev/null +++ b/tests/Unit/Capability/Provider/DynamicToolProviderTest.php @@ -0,0 +1,127 @@ +registry = new Registry(null, new NullLogger()); + } + + public function testProviderRegistrationInRegistry(): void + { + $tool = $this->createTool('test_tool'); + $provider = new TestDynamicToolProvider([$tool]); + + $this->registry->registerDynamicToolProvider($provider); + + $providers = $this->registry->getDynamicToolProviders(); + $this->assertCount(1, $providers); + $this->assertSame($provider, $providers[0]); + } + + public function testToolEnumerationFromDynamicProvider(): void + { + $tool1 = $this->createTool('dynamic_tool_1'); + $tool2 = $this->createTool('dynamic_tool_2'); + $provider = new TestDynamicToolProvider([$tool1, $tool2]); + + $this->registry->registerDynamicToolProvider($provider); + + $page = $this->registry->getTools(); + $tools = $page->references; + + $this->assertCount(2, $tools); + $this->assertArrayHasKey('dynamic_tool_1', $tools); + $this->assertArrayHasKey('dynamic_tool_2', $tools); + $this->assertSame($tool1, $tools['dynamic_tool_1']); + $this->assertSame($tool2, $tools['dynamic_tool_2']); + } + + public function testToolEnumerationFromMixedSources(): void + { + // Register a static tool + $staticTool = $this->createTool('static_tool'); + $this->registry->registerTool($staticTool, fn () => 'static result'); + + // Register a dynamic provider + $dynamicTool = $this->createTool('dynamic_tool'); + $provider = new TestDynamicToolProvider([$dynamicTool]); + $this->registry->registerDynamicToolProvider($provider); + + $page = $this->registry->getTools(); + $tools = $page->references; + + $this->assertCount(2, $tools); + $this->assertArrayHasKey('static_tool', $tools); + $this->assertArrayHasKey('dynamic_tool', $tools); + } + + public function testConflictDetectionStaticVsDynamic(): void + { + // Register a static tool first + $staticTool = $this->createTool('conflicting_tool'); + $this->registry->registerTool($staticTool, fn () => 'static result'); + + // Try to register a dynamic provider with the same tool name + $dynamicTool = $this->createTool('conflicting_tool'); + $provider = new TestDynamicToolProvider([$dynamicTool]); + + $this->expectException(RegistryException::class); + $this->expectExceptionMessage('Dynamic tool provider conflict: tool "conflicting_tool" is already registered as a static tool.'); + + $this->registry->registerDynamicToolProvider($provider); + } + + public function testConflictDetectionDynamicVsDynamic(): void + { + // Register first dynamic provider + $tool1 = $this->createTool('shared_tool'); + $provider1 = new TestDynamicToolProvider([$tool1]); + $this->registry->registerDynamicToolProvider($provider1); + + // Try to register second dynamic provider with the same tool name + $tool2 = $this->createTool('shared_tool'); + $provider2 = new TestDynamicToolProvider([$tool2]); + + $this->expectException(RegistryException::class); + $this->expectExceptionMessage('Dynamic tool provider conflict: tool "shared_tool" is already supported by another provider.'); + + $this->registry->registerDynamicToolProvider($provider2); + } + + private function createTool(string $name): Tool + { + return new Tool( + name: $name, + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'param' => ['type' => 'string'], + ], + 'required' => null, + ], + description: "Test tool: {$name}", + annotations: null, + ); + } +} diff --git a/tests/Unit/Capability/Provider/Fixtures/TestDynamicPromptProvider.php b/tests/Unit/Capability/Provider/Fixtures/TestDynamicPromptProvider.php new file mode 100644 index 00000000..1954a034 --- /dev/null +++ b/tests/Unit/Capability/Provider/Fixtures/TestDynamicPromptProvider.php @@ -0,0 +1,75 @@ + $prompts + * @param array> $completionProviders Map of prompt name to argument->provider map + */ + public function __construct( + private readonly array $prompts = [], + private readonly array $completionProviders = [], + ) { + } + + public function getPrompts(): iterable + { + return $this->prompts; + } + + public function supportsPrompt(string $promptName): bool + { + foreach ($this->prompts as $prompt) { + if ($prompt->name === $promptName) { + return true; + } + } + + return false; + } + + public function getPrompt(string $promptName, array $arguments): mixed + { + foreach ($this->prompts as $prompt) { + if ($prompt->name === $promptName) { + $argsJson = json_encode($arguments); + + return [ + new PromptMessage( + Role::User, + new TextContent("Prompt {$promptName} with arguments: {$argsJson}"), + ), + ]; + } + } + + throw new \RuntimeException("Prompt {$promptName} not found"); + } + + public function getCompletionProviders(string $promptName): array + { + return $this->completionProviders[$promptName] ?? []; + } +} diff --git a/tests/Unit/Capability/Provider/Fixtures/TestDynamicResourceProvider.php b/tests/Unit/Capability/Provider/Fixtures/TestDynamicResourceProvider.php new file mode 100644 index 00000000..31a56609 --- /dev/null +++ b/tests/Unit/Capability/Provider/Fixtures/TestDynamicResourceProvider.php @@ -0,0 +1,65 @@ + $resources + */ + public function __construct( + private readonly array $resources = [], + ) { + } + + public function getResources(): iterable + { + return $this->resources; + } + + public function supportsResource(string $uri): bool + { + foreach ($this->resources as $resource) { + if ($resource->uri === $uri) { + return true; + } + } + + return false; + } + + public function readResource(string $uri): mixed + { + foreach ($this->resources as $resource) { + if ($resource->uri === $uri) { + return [ + new TextResourceContents( + uri: $uri, + mimeType: $resource->mimeType ?? 'text/plain', + text: "Content of resource {$uri}", + ), + ]; + } + } + + throw new \RuntimeException("Resource {$uri} not found"); + } +} diff --git a/tests/Unit/Capability/Provider/Fixtures/TestDynamicResourceTemplateProvider.php b/tests/Unit/Capability/Provider/Fixtures/TestDynamicResourceTemplateProvider.php new file mode 100644 index 00000000..a4da7baf --- /dev/null +++ b/tests/Unit/Capability/Provider/Fixtures/TestDynamicResourceTemplateProvider.php @@ -0,0 +1,70 @@ + $templates + */ + public function __construct( + private readonly array $templates = [], + ) { + } + + public function getResourceTemplates(): iterable + { + return $this->templates; + } + + public function supportsResourceTemplate(string $uriTemplate): bool + { + foreach ($this->templates as $template) { + if ($template->uriTemplate === $uriTemplate) { + return true; + } + } + + return false; + } + + public function readResource(string $uriTemplate, string $uri): mixed + { + foreach ($this->templates as $template) { + if ($template->uriTemplate === $uriTemplate) { + return [ + new TextResourceContents( + uri: $uri, + mimeType: $template->mimeType ?? 'text/plain', + text: "Content from template {$uriTemplate} for URI {$uri}", + ), + ]; + } + } + + throw new \RuntimeException("Resource template {$uriTemplate} not found"); + } + + public function getCompletionProviders(string $uriTemplate): array + { + return []; + } +} diff --git a/tests/Unit/Capability/Provider/Fixtures/TestDynamicToolProvider.php b/tests/Unit/Capability/Provider/Fixtures/TestDynamicToolProvider.php new file mode 100644 index 00000000..22272585 --- /dev/null +++ b/tests/Unit/Capability/Provider/Fixtures/TestDynamicToolProvider.php @@ -0,0 +1,59 @@ + $tools + */ + public function __construct( + private readonly array $tools = [], + ) { + } + + public function getTools(): iterable + { + return $this->tools; + } + + public function supportsTool(string $toolName): bool + { + foreach ($this->tools as $tool) { + if ($tool->name === $toolName) { + return true; + } + } + + return false; + } + + public function executeTool(string $toolName, array $arguments): mixed + { + foreach ($this->tools as $tool) { + if ($tool->name === $toolName) { + return new TextContent("Executed {$toolName} with arguments: ".json_encode($arguments)); + } + } + + throw new \RuntimeException("Tool {$toolName} not found"); + } +} diff --git a/tests/Unit/Server/Handler/Request/CompletionCompleteHandlerTest.php b/tests/Unit/Server/Handler/Request/CompletionCompleteHandlerTest.php new file mode 100644 index 00000000..dc9ece8c --- /dev/null +++ b/tests/Unit/Server/Handler/Request/CompletionCompleteHandlerTest.php @@ -0,0 +1,219 @@ +session = $this->createMock(SessionInterface::class); + } + + public function testCompletionFromDynamicPromptProvider(): void + { + $prompt = new Prompt( + name: 'dynamic_prompt', + description: 'A dynamic prompt', + arguments: [new PromptArgument('format', 'Output format', true)], + ); + + $completionProvider = new ListCompletionProvider(['json', 'yaml', 'xml']); + + $provider = new TestDynamicPromptProvider( + [$prompt], + ['dynamic_prompt' => ['format' => $completionProvider]] + ); + + $registry = new Registry(null, new NullLogger()); + $registry->registerDynamicPromptProvider($provider); + + $handler = new CompletionCompleteHandler($registry); + + $request = $this->createCompletionRequest( + new PromptRefSchema('dynamic_prompt'), + 'format', + 'j' + ); + + $response = $handler->handle($request, $this->session); + + if ($response instanceof Error) { + $this->fail('Expected Response, got Error: '.$response->message); + } + $this->assertInstanceOf(Response::class, $response); + $result = $response->result; + $this->assertInstanceOf(CompletionCompleteResult::class, $result); + $this->assertContains('json', $result->values); + } + + public function testCompletionFallsBackToStaticWhenNoDynamicProvider(): void + { + $prompt = new Prompt( + name: 'static_prompt', + description: 'A static prompt', + arguments: [new PromptArgument('type', 'Type', true)], + ); + + $completionProvider = new ListCompletionProvider(['alpha', 'beta', 'gamma']); + + $registry = new Registry(null, new NullLogger()); + $registry->registerPrompt($prompt, fn () => [], ['type' => $completionProvider]); + + $handler = new CompletionCompleteHandler($registry); + + $request = $this->createCompletionRequest( + new PromptRefSchema('static_prompt'), + 'type', + '' + ); + + $response = $handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response); + $result = $response->result; + $this->assertContains('alpha', $result->values); + $this->assertContains('beta', $result->values); + $this->assertContains('gamma', $result->values); + } + + public function testCompletionReturnsEmptyWhenNoProviderForArgument(): void + { + $prompt = new Prompt( + name: 'partial_prompt', + description: 'A prompt with some completion providers', + arguments: [ + new PromptArgument('with_completion', 'Has completion', true), + new PromptArgument('no_completion', 'No completion', true), + ], + ); + + $completionProvider = new ListCompletionProvider(['a', 'b', 'c']); + + $provider = new TestDynamicPromptProvider( + [$prompt], + ['partial_prompt' => ['with_completion' => $completionProvider]] + ); + + $registry = new Registry(null, new NullLogger()); + $registry->registerDynamicPromptProvider($provider); + + $handler = new CompletionCompleteHandler($registry); + + // Request completion for argument without provider + $request = $this->createCompletionRequest( + new PromptRefSchema('partial_prompt'), + 'no_completion', + 'test' + ); + + $response = $handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response); + $result = $response->result; + $this->assertEmpty($result->values); + } + + public function testCompletionForResourceTemplateStillWorks(): void + { + $template = new ResourceTemplate( + uriTemplate: 'test://entity/{id}', + name: 'test_template', + description: 'A test template', + mimeType: 'application/json', + ); + + $completionProvider = new ListCompletionProvider(['1', '2', '3', '10', '100']); + + $registry = new Registry(null, new NullLogger()); + $registry->registerResourceTemplate( + $template, + fn () => 'content', + ['id' => $completionProvider] + ); + + $handler = new CompletionCompleteHandler($registry); + + $request = $this->createCompletionRequest( + new ResourceRefSchema('test://entity/{id}'), + 'id', + '1' + ); + + $response = $handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response); + $result = $response->result; + $this->assertContains('1', $result->values); + $this->assertContains('10', $result->values); + $this->assertContains('100', $result->values); + } + + public function testCompletionPromptNotFoundReturnsError(): void + { + $registry = new Registry(null, new NullLogger()); + $handler = new CompletionCompleteHandler($registry); + + $request = $this->createCompletionRequest( + new PromptRefSchema('nonexistent_prompt'), + 'arg', + 'test' + ); + + $response = $handler->handle($request, $this->session); + + $this->assertInstanceOf(Error::class, $response); + $this->assertEquals(Error::RESOURCE_NOT_FOUND, $response->code); + } + + private function createCompletionRequest( + PromptRefSchema|ResourceRefSchema $ref, + string $argumentName, + string $argumentValue, + ): CompletionCompleteRequest { + $refArray = $ref instanceof PromptRefSchema + ? ['type' => 'ref/prompt', 'name' => $ref->name] + : ['type' => 'ref/resource', 'uri' => $ref->uri]; + + return CompletionCompleteRequest::fromArray([ + 'jsonrpc' => '2.0', + 'method' => CompletionCompleteRequest::getMethod(), + 'id' => 'test-request-'.uniqid(), + 'params' => [ + 'ref' => $refArray, + 'argument' => [ + 'name' => $argumentName, + 'value' => $argumentValue, + ], + ], + ]); + } +} From d43482790b9f37f7f6b3b31515c351b0e18f3f73 Mon Sep 17 00:00:00 2001 From: Mateu Aguilo Bosch Date: Fri, 5 Dec 2025 11:01:44 +0100 Subject: [PATCH 2/4] fix: PHP 8.1 compatibility for iterator_to_array Replace iterator_to_array() with iterableToArray() helper that handles both arrays and Traversable objects. PHP 8.1's iterator_to_array() only accepts Traversable, but provider interfaces return iterable (array|Traversable). Also fixes code style issues flagged by php-cs-fixer. --- .../DynamicResourceProviderInterface.php | 2 +- src/Capability/Registry.php | 44 +++++++++++-------- src/Capability/Registry/ReferenceHandler.php | 2 - .../Request/CompletionCompleteHandler.php | 1 - 4 files changed, 26 insertions(+), 23 deletions(-) diff --git a/src/Capability/Provider/DynamicResourceProviderInterface.php b/src/Capability/Provider/DynamicResourceProviderInterface.php index 07ce918e..57ddf6cf 100644 --- a/src/Capability/Provider/DynamicResourceProviderInterface.php +++ b/src/Capability/Provider/DynamicResourceProviderInterface.php @@ -23,7 +23,7 @@ interface DynamicResourceProviderInterface { /** - * @return iterable + * @return iterable */ public function getResources(): iterable; diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index 2df5a419..5d0234be 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -270,7 +270,7 @@ private function registerDynamicProvider(object $provider, string $capability): array_map( fn ($item) => $this->assertCapabilityNotRegistered($item->{$config['keyProperty']}, $capability), - iterator_to_array($provider->{$config['itemsGetter']}()), + $this->iterableToArray($provider->{$config['itemsGetter']}()), ); $this->{$config['dynamicProviders']}[] = $provider; @@ -283,23 +283,12 @@ private function assertCapabilityNotRegistered(string $key, string $capability): $label = $config['label']; if (isset($this->{$config['staticRegistry']}[$key])) { - throw RegistryException::invalidParams(\sprintf( - 'Dynamic %s provider conflict: %s "%s" is already registered as a static %s.', - $capability, - $label, - $key, - $capability, - )); + throw RegistryException::invalidParams(\sprintf('Dynamic %s provider conflict: %s "%s" is already registered as a static %s.', $capability, $label, $key, $capability)); } $conflictingProvider = $this->findDynamicProviderByKey($key, $capability); if (null !== $conflictingProvider) { - throw RegistryException::invalidParams(\sprintf( - 'Dynamic %s provider conflict: %s "%s" is already supported by another provider.', - $capability, - $label, - $key, - )); + throw RegistryException::invalidParams(\sprintf('Dynamic %s provider conflict: %s "%s" is already supported by another provider.', $capability, $label, $key)); } } @@ -370,9 +359,9 @@ public function getTools(?int $limit = null, ?string $cursor = null): Page fn (array $acc, DynamicToolProviderInterface $provider) => array_merge( $acc, array_column( - iterator_to_array($provider->getTools()), - null, - 'name', + $this->iterableToArray($provider->getTools()), + null, + 'name', ), ), [], @@ -416,7 +405,7 @@ public function getResources(?int $limit = null, ?string $cursor = null): Page $this->dynamicResourceProviders, fn (array $acc, DynamicResourceProviderInterface $provider) => array_merge( $acc, - array_column(iterator_to_array($provider->getResources()), null, 'uri'), + array_column($this->iterableToArray($provider->getResources()), null, 'uri'), ), [], ), @@ -516,7 +505,7 @@ public function getPrompts(?int $limit = null, ?string $cursor = null): Page $this->dynamicPromptProviders, fn (array $acc, DynamicPromptProviderInterface $provider) => array_merge( $acc, - array_column(iterator_to_array($provider->getPrompts()), null, 'name'), + array_column($this->iterableToArray($provider->getPrompts()), null, 'name'), ), [], ), @@ -803,4 +792,21 @@ private function paginateResults(array $items, int $limit, ?string $cursor = nul return array_values(\array_slice($items, $offset, $limit)); } + + /** + * Convert an iterable to an array. + * + * PHP 8.1 compatibility: iterator_to_array() only accepts Traversable in PHP 8.1, + * but interfaces return iterable which can be an array. + * + * @template T + * + * @param iterable $items + * + * @return array + */ + private function iterableToArray(iterable $items): array + { + return \is_array($items) ? $items : iterator_to_array($items); + } } diff --git a/src/Capability/Registry/ReferenceHandler.php b/src/Capability/Registry/ReferenceHandler.php index e9670cf6..c2ef8852 100644 --- a/src/Capability/Registry/ReferenceHandler.php +++ b/src/Capability/Registry/ReferenceHandler.php @@ -61,8 +61,6 @@ public function handle(ElementReference $reference, array $arguments): mixed /** * Resolves a handler to a callable, optionally returning an instance. * - * @param \Closure|array|string $handler - * * @return array{0: callable, 1: ?object} */ private function resolveHandler(\Closure|array|string $handler): array diff --git a/src/Server/Handler/Request/CompletionCompleteHandler.php b/src/Server/Handler/Request/CompletionCompleteHandler.php index 201c9dda..2bd5d760 100644 --- a/src/Server/Handler/Request/CompletionCompleteHandler.php +++ b/src/Server/Handler/Request/CompletionCompleteHandler.php @@ -20,7 +20,6 @@ use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\PromptReference; use Mcp\Schema\Request\CompletionCompleteRequest; -use Mcp\Schema\ResourceReference; use Mcp\Schema\Result\CompletionCompleteResult; use Mcp\Server\Session\SessionInterface; use Psr\Container\ContainerInterface; From 765b924e56a58671119702190dda64ba2076878f Mon Sep 17 00:00:00 2001 From: Mateu Aguilo Bosch Date: Fri, 5 Dec 2025 11:21:15 +0100 Subject: [PATCH 3/4] fix: replace Handler type alias with explicit PHPDoc types Remove @phpstan-import-type Handler and replace @param Handler annotations with explicit types: - callable|array{0: class-string|object, 1: string}|string for tools - \Closure|array{0: class-string|object, 1: string}|string for others This fixes PHPStan errors where the Handler type alias didn't match the native parameter types, and resolves missingType.iterableValue errors by specifying the array structure. --- src/Capability/Registry/ElementReference.php | 6 ++-- .../Registry/Loader/ArrayLoader.php | 13 +++---- src/Capability/Registry/PromptReference.php | 6 ++-- src/Capability/Registry/ReferenceHandler.php | 2 ++ src/Capability/Registry/ResourceReference.php | 4 +-- .../Registry/ResourceTemplateReference.php | 6 ++-- src/Capability/Registry/ToolReference.php | 4 +-- src/Capability/RegistryInterface.php | 15 ++++---- src/Server/Builder.php | 35 +++++++++---------- 9 files changed, 37 insertions(+), 54 deletions(-) diff --git a/src/Capability/Registry/ElementReference.php b/src/Capability/Registry/ElementReference.php index 37341dd5..018d4a7c 100644 --- a/src/Capability/Registry/ElementReference.php +++ b/src/Capability/Registry/ElementReference.php @@ -14,15 +14,13 @@ /** * Base class for element references with default passthrough argument preparation. * - * @phpstan-type Handler \Closure|array{0: object|string, 1: string}|string|object - * * @author Kyrian Obikwelu */ class ElementReference implements ArgumentPreparationInterface { /** - * @param Handler $handler The handler can be a Closure, array method reference, - * string function/class name, or a callable object (implementing __invoke) + * @param object|array{0: class-string|object, 1: string}|string $handler The handler can be a Closure, array method reference, + * string function/class name, or a callable object (implementing __invoke) */ public function __construct( public readonly object|array|string $handler, diff --git a/src/Capability/Registry/Loader/ArrayLoader.php b/src/Capability/Registry/Loader/ArrayLoader.php index fef17263..39f59a32 100644 --- a/src/Capability/Registry/Loader/ArrayLoader.php +++ b/src/Capability/Registry/Loader/ArrayLoader.php @@ -18,7 +18,6 @@ use Mcp\Capability\Discovery\DocBlockParser; use Mcp\Capability\Discovery\HandlerResolver; use Mcp\Capability\Discovery\SchemaGenerator; -use Mcp\Capability\Registry\ElementReference; use Mcp\Capability\RegistryInterface; use Mcp\Exception\ConfigurationException; use Mcp\Schema\Annotations; @@ -35,14 +34,12 @@ /** * @author Antoine Bluchet - * - * @phpstan-import-type Handler from ElementReference */ final class ArrayLoader implements LoaderInterface { /** * @param array{ - * handler: Handler, + * handler: callable|array{0: class-string|object, 1: string}|string, * name: ?string, * description: ?string, * annotations: ?ToolAnnotations, @@ -50,7 +47,7 @@ final class ArrayLoader implements LoaderInterface * meta: ?array * }[] $tools * @param array{ - * handler: Handler, + * handler: \Closure|array{0: class-string|object, 1: string}|string, * uri: string, * name: ?string, * description: ?string, @@ -61,7 +58,7 @@ final class ArrayLoader implements LoaderInterface * meta: ?array * }[] $resources * @param array{ - * handler: Handler, + * handler: \Closure|array{0: class-string|object, 1: string}|string, * uriTemplate: string, * name: ?string, * description: ?string, @@ -70,7 +67,7 @@ final class ArrayLoader implements LoaderInterface * meta: ?array * }[] $resourceTemplates * @param array{ - * handler: Handler, + * handler: \Closure|array{0: class-string|object, 1: string}|string, * name: ?string, * description: ?string, * icons: ?Icon[], @@ -271,7 +268,7 @@ public function load(RegistryInterface $registry): void } /** - * @param Handler $handler + * @param \Closure|array{0: class-string|object, 1: string}|string $handler */ private function getHandlerDescription(\Closure|array|string $handler): string { diff --git a/src/Capability/Registry/PromptReference.php b/src/Capability/Registry/PromptReference.php index 5c6333de..c739ac98 100644 --- a/src/Capability/Registry/PromptReference.php +++ b/src/Capability/Registry/PromptReference.php @@ -16,8 +16,6 @@ use Mcp\Schema\Prompt; /** - * @phpstan-import-type Handler from ElementReference - * * @author Kyrian Obikwelu */ class PromptReference extends ElementReference @@ -25,8 +23,8 @@ class PromptReference extends ElementReference use ReflectionArgumentPreparationTrait; /** - * @param Handler $handler - * @param array $completionProviders + * @param \Closure|array{0: class-string|object, 1: string}|string $handler + * @param array $completionProviders */ public function __construct( public readonly Prompt $prompt, diff --git a/src/Capability/Registry/ReferenceHandler.php b/src/Capability/Registry/ReferenceHandler.php index c2ef8852..253f28c1 100644 --- a/src/Capability/Registry/ReferenceHandler.php +++ b/src/Capability/Registry/ReferenceHandler.php @@ -61,6 +61,8 @@ public function handle(ElementReference $reference, array $arguments): mixed /** * Resolves a handler to a callable, optionally returning an instance. * + * @param \Closure|array{0: class-string|object, 1: string}|string $handler + * * @return array{0: callable, 1: ?object} */ private function resolveHandler(\Closure|array|string $handler): array diff --git a/src/Capability/Registry/ResourceReference.php b/src/Capability/Registry/ResourceReference.php index 6f38378d..31d4c513 100644 --- a/src/Capability/Registry/ResourceReference.php +++ b/src/Capability/Registry/ResourceReference.php @@ -16,8 +16,6 @@ use Mcp\Schema\Resource; /** - * @phpstan-import-type Handler from ElementReference - * * @author Kyrian Obikwelu */ class ResourceReference extends ElementReference @@ -25,7 +23,7 @@ class ResourceReference extends ElementReference use ReflectionArgumentPreparationTrait; /** - * @param Handler $handler + * @param callable|array{0: class-string|object, 1: string}|string $handler */ public function __construct( public readonly Resource $schema, diff --git a/src/Capability/Registry/ResourceTemplateReference.php b/src/Capability/Registry/ResourceTemplateReference.php index 07cb2f54..cb8c565b 100644 --- a/src/Capability/Registry/ResourceTemplateReference.php +++ b/src/Capability/Registry/ResourceTemplateReference.php @@ -16,8 +16,6 @@ use Mcp\Schema\ResourceTemplate; /** - * @phpstan-import-type Handler from ElementReference - * * @author Kyrian Obikwelu */ class ResourceTemplateReference extends ElementReference @@ -27,8 +25,8 @@ class ResourceTemplateReference extends ElementReference private readonly UriTemplateMatcher $uriTemplateMatcher; /** - * @param Handler $handler - * @param array $completionProviders + * @param callable|array{0: class-string|object, 1: string}|string $handler + * @param array $completionProviders */ public function __construct( public readonly ResourceTemplate $resourceTemplate, diff --git a/src/Capability/Registry/ToolReference.php b/src/Capability/Registry/ToolReference.php index 13c7f6ab..1126dc3c 100644 --- a/src/Capability/Registry/ToolReference.php +++ b/src/Capability/Registry/ToolReference.php @@ -17,8 +17,6 @@ use Mcp\Schema\Tool; /** - * @phpstan-import-type Handler from ElementReference - * * @author Kyrian Obikwelu */ class ToolReference extends ElementReference @@ -26,7 +24,7 @@ class ToolReference extends ElementReference use ReflectionArgumentPreparationTrait; /** - * @param Handler $handler + * @param callable|array{0: class-string|object, 1: string}|string $handler */ public function __construct( public readonly Tool $tool, diff --git a/src/Capability/RegistryInterface.php b/src/Capability/RegistryInterface.php index 264b583e..8c7bdbb6 100644 --- a/src/Capability/RegistryInterface.php +++ b/src/Capability/RegistryInterface.php @@ -20,7 +20,6 @@ use Mcp\Capability\Registry\DynamicResourceReference; use Mcp\Capability\Registry\DynamicResourceTemplateReference; use Mcp\Capability\Registry\DynamicToolReference; -use Mcp\Capability\Registry\ElementReference; use Mcp\Capability\Registry\PromptReference; use Mcp\Capability\Registry\ResourceReference; use Mcp\Capability\Registry\ResourceTemplateReference; @@ -35,8 +34,6 @@ use Mcp\Schema\Tool; /** - * @phpstan-import-type Handler from ElementReference - * * @author Kyrian Obikwelu * @author Christopher Hertel */ @@ -45,22 +42,22 @@ interface RegistryInterface /** * Registers a tool with its handler. * - * @param Handler $handler + * @param callable|array{0: class-string|object, 1: string}|string $handler */ public function registerTool(Tool $tool, callable|array|string $handler, bool $isManual = false): void; /** * Registers a resource with its handler. * - * @param Handler $handler + * @param callable|array{0: class-string|object, 1: string}|string $handler */ public function registerResource(Resource $resource, callable|array|string $handler, bool $isManual = false): void; /** * Registers a resource template with its handler and completion providers. * - * @param Handler $handler - * @param array $completionProviders + * @param callable|array{0: class-string|object, 1: string}|string $handler + * @param array $completionProviders */ public function registerResourceTemplate( ResourceTemplate $template, @@ -72,8 +69,8 @@ public function registerResourceTemplate( /** * Registers a prompt with its handler and completion providers. * - * @param Handler $handler - * @param array $completionProviders + * @param callable|array{0: class-string|object, 1: string}|string $handler + * @param array $completionProviders */ public function registerPrompt( Prompt $prompt, diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 4f398739..60a6cafc 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -17,7 +17,6 @@ use Mcp\Capability\Provider\DynamicToolProviderInterface; 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; @@ -44,8 +43,6 @@ use Psr\SimpleCache\CacheInterface; /** - * @phpstan-import-type Handler from ElementReference - * * @author Kyrian Obikwelu */ final class Builder @@ -86,7 +83,7 @@ final class Builder /** * @var array{ - * handler: Handler, + * handler: callable|array{0: class-string|object, 1: string}|string, * name: ?string, * description: ?string, * annotations: ?ToolAnnotations, @@ -98,7 +95,7 @@ final class Builder /** * @var array{ - * handler: Handler, + * handler: \Closure|array{0: class-string|object, 1: string}|string, * uri: string, * name: ?string, * description: ?string, @@ -113,7 +110,7 @@ final class Builder /** * @var array{ - * handler: Handler, + * handler: \Closure|array{0: class-string|object, 1: string}|string, * uriTemplate: string, * name: ?string, * description: ?string, @@ -126,7 +123,7 @@ final class Builder /** * @var array{ - * handler: Handler, + * handler: \Closure|array{0: class-string|object, 1: string}|string, * name: ?string, * description: ?string, * icons: ?Icon[], @@ -350,10 +347,10 @@ 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 callable|array{0: class-string|object, 1: string}|string $handler + * @param array|null $inputSchema + * @param ?Icon[] $icons + * @param array|null $meta */ public function addTool( callable|array|string $handler, @@ -380,9 +377,9 @@ public function addTool( /** * Manually registers a resource handler. * - * @param Handler $handler - * @param ?Icon[] $icons - * @param array|null $meta + * @param \Closure|array{0: class-string|object, 1: string}|string $handler + * @param ?Icon[] $icons + * @param array|null $meta */ public function addResource( \Closure|array|string $handler, @@ -413,8 +410,8 @@ public function addResource( /** * Manually registers a resource template handler. * - * @param Handler $handler - * @param array|null $meta + * @param \Closure|array{0: class-string|object, 1: string}|string $handler + * @param array|null $meta */ public function addResourceTemplate( \Closure|array|string $handler, @@ -441,9 +438,9 @@ public function addResourceTemplate( /** * Manually registers a prompt handler. * - * @param Handler $handler - * @param ?Icon[] $icons - * @param array|null $meta + * @param \Closure|array{0: class-string|object, 1: string}|string $handler + * @param ?Icon[] $icons + * @param array|null $meta */ public function addPrompt( \Closure|array|string $handler, From 09b5304ff4491e09d0a9157c92acdbd552ad4245 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateu=20Aguil=C3=B3=20Bosch?= Date: Fri, 5 Dec 2025 17:10:54 +0100 Subject: [PATCH 4/4] chore: fix attribution. I can spell my own name... --- src/Capability/Registry/ArgumentPreparationInterface.php | 2 +- src/Capability/Registry/DynamicArgumentPreparationTrait.php | 2 +- src/Capability/Registry/DynamicPromptReference.php | 2 +- src/Capability/Registry/DynamicResourceReference.php | 2 +- src/Capability/Registry/DynamicResourceTemplateReference.php | 2 +- src/Capability/Registry/DynamicToolReference.php | 2 +- src/Capability/Registry/ReflectionArgumentPreparationTrait.php | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Capability/Registry/ArgumentPreparationInterface.php b/src/Capability/Registry/ArgumentPreparationInterface.php index 82531ae1..8500ec65 100644 --- a/src/Capability/Registry/ArgumentPreparationInterface.php +++ b/src/Capability/Registry/ArgumentPreparationInterface.php @@ -12,7 +12,7 @@ namespace Mcp\Capability\Registry; /** - * @author Mateu Aguilo Bosch + * @author Mateu Aguiló Bosch */ interface ArgumentPreparationInterface { diff --git a/src/Capability/Registry/DynamicArgumentPreparationTrait.php b/src/Capability/Registry/DynamicArgumentPreparationTrait.php index 62ae9352..b7601a61 100644 --- a/src/Capability/Registry/DynamicArgumentPreparationTrait.php +++ b/src/Capability/Registry/DynamicArgumentPreparationTrait.php @@ -14,7 +14,7 @@ /** * Passes raw arguments array directly to the handler. * - * @author Mateu Aguilo Bosch + * @author Mateu Aguiló Bosch */ trait DynamicArgumentPreparationTrait { diff --git a/src/Capability/Registry/DynamicPromptReference.php b/src/Capability/Registry/DynamicPromptReference.php index a39aa007..368a2b00 100644 --- a/src/Capability/Registry/DynamicPromptReference.php +++ b/src/Capability/Registry/DynamicPromptReference.php @@ -19,7 +19,7 @@ use Mcp\Server\ClientGateway; /** - * @author Mateu Aguilo Bosch + * @author Mateu Aguiló Bosch */ class DynamicPromptReference extends ElementReference implements ClientAwareInterface { diff --git a/src/Capability/Registry/DynamicResourceReference.php b/src/Capability/Registry/DynamicResourceReference.php index 4887972d..382716e9 100644 --- a/src/Capability/Registry/DynamicResourceReference.php +++ b/src/Capability/Registry/DynamicResourceReference.php @@ -19,7 +19,7 @@ use Mcp\Server\ClientGateway; /** - * @author Mateu Aguilo Bosch + * @author Mateu Aguiló Bosch */ class DynamicResourceReference extends ElementReference implements ClientAwareInterface { diff --git a/src/Capability/Registry/DynamicResourceTemplateReference.php b/src/Capability/Registry/DynamicResourceTemplateReference.php index ab86c57d..1ec6c60b 100644 --- a/src/Capability/Registry/DynamicResourceTemplateReference.php +++ b/src/Capability/Registry/DynamicResourceTemplateReference.php @@ -19,7 +19,7 @@ use Mcp\Server\ClientGateway; /** - * @author Mateu Aguilo Bosch + * @author Mateu Aguiló Bosch */ class DynamicResourceTemplateReference extends ElementReference implements ClientAwareInterface { diff --git a/src/Capability/Registry/DynamicToolReference.php b/src/Capability/Registry/DynamicToolReference.php index 1d02a2f6..2264c3e5 100644 --- a/src/Capability/Registry/DynamicToolReference.php +++ b/src/Capability/Registry/DynamicToolReference.php @@ -19,7 +19,7 @@ use Mcp\Server\ClientGateway; /** - * @author Mateu Aguilo Bosch + * @author Mateu Aguiló Bosch */ class DynamicToolReference extends ElementReference implements ClientAwareInterface { diff --git a/src/Capability/Registry/ReflectionArgumentPreparationTrait.php b/src/Capability/Registry/ReflectionArgumentPreparationTrait.php index d0043ee7..4744dc19 100644 --- a/src/Capability/Registry/ReflectionArgumentPreparationTrait.php +++ b/src/Capability/Registry/ReflectionArgumentPreparationTrait.php @@ -17,7 +17,7 @@ /** * Uses reflection to match named parameters and perform type casting. * - * @author Mateu Aguilo Bosch + * @author Kyrian Obikwelu */ trait ReflectionArgumentPreparationTrait {