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..330590ee --- /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 try to convert to JSON with a warning + */ + 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/Registry/PromptReference.php b/src/Capability/Registry/PromptReference.php index cff89241..5de3a195 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; /** @@ -55,228 +47,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/ResourceReference.php b/src/Capability/Registry/ResourceReference.php index 252e2086..d65f461e 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; /** @@ -45,10 +42,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 +55,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->resource->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->resource->meta); } } diff --git a/src/Capability/Registry/ResourceTemplateReference.php b/src/Capability/Registry/ResourceTemplateReference.php index 88104c9d..ef2d915a 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; /** @@ -78,10 +75,8 @@ public function extractVariables(string $uri): array * * @return array 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 @@ -93,125 +88,7 @@ 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)); + return (new ResourceResultFormatter())->format($readResult, $uri, $mimeType, $this->resourceTemplate->meta); } private function compileTemplate(): void @@ -233,31 +110,4 @@ private function compileTemplate(): void $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, '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/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); + } +}