|
| 1 | +<?php |
| 2 | + |
| 3 | +/* |
| 4 | + * This file is part of the official PHP MCP SDK. |
| 5 | + * |
| 6 | + * A collaboration between Symfony and the PHP Foundation. |
| 7 | + * |
| 8 | + * For the full copyright and license information, please view the LICENSE |
| 9 | + * file that was distributed with this source code. |
| 10 | + */ |
| 11 | + |
| 12 | +namespace Mcp\Capability\Formatter; |
| 13 | + |
| 14 | +use Mcp\Exception\RuntimeException; |
| 15 | +use Mcp\Schema\Content\AudioContent; |
| 16 | +use Mcp\Schema\Content\BlobResourceContents; |
| 17 | +use Mcp\Schema\Content\Content; |
| 18 | +use Mcp\Schema\Content\EmbeddedResource; |
| 19 | +use Mcp\Schema\Content\ImageContent; |
| 20 | +use Mcp\Schema\Content\PromptMessage; |
| 21 | +use Mcp\Schema\Content\TextContent; |
| 22 | +use Mcp\Schema\Content\TextResourceContents; |
| 23 | +use Mcp\Schema\Enum\Role; |
| 24 | + |
| 25 | +/** |
| 26 | + * @author Kyrian Obikwelu <koshnawaza@gmail.com> |
| 27 | + * @author Mateu Aguiló Bosch <mateu@mateuaguilo.com> |
| 28 | + */ |
| 29 | +final class PromptResultFormatter |
| 30 | +{ |
| 31 | + /** |
| 32 | + * Formats the raw result of a prompt generator into an array of MCP PromptMessages. |
| 33 | + * |
| 34 | + * @param mixed $promptGenerationResult expected: array of message structures |
| 35 | + * |
| 36 | + * @return PromptMessage[] array of PromptMessage objects |
| 37 | + * |
| 38 | + * @throws \RuntimeException if the result cannot be formatted |
| 39 | + * @throws \JsonException if JSON encoding fails |
| 40 | + */ |
| 41 | + public function format(mixed $promptGenerationResult): array |
| 42 | + { |
| 43 | + if ($promptGenerationResult instanceof PromptMessage) { |
| 44 | + return [$promptGenerationResult]; |
| 45 | + } |
| 46 | + |
| 47 | + if (!\is_array($promptGenerationResult)) { |
| 48 | + throw new RuntimeException('Prompt generator method must return an array of messages.'); |
| 49 | + } |
| 50 | + |
| 51 | + if (empty($promptGenerationResult)) { |
| 52 | + return []; |
| 53 | + } |
| 54 | + |
| 55 | + if (\is_array($promptGenerationResult)) { |
| 56 | + $allArePromptMessages = true; |
| 57 | + $hasPromptMessages = false; |
| 58 | + |
| 59 | + foreach ($promptGenerationResult as $item) { |
| 60 | + if ($item instanceof PromptMessage) { |
| 61 | + $hasPromptMessages = true; |
| 62 | + } else { |
| 63 | + $allArePromptMessages = false; |
| 64 | + } |
| 65 | + } |
| 66 | + |
| 67 | + if ($allArePromptMessages && $hasPromptMessages) { |
| 68 | + return $promptGenerationResult; |
| 69 | + } |
| 70 | + |
| 71 | + if ($hasPromptMessages) { |
| 72 | + $result = []; |
| 73 | + foreach ($promptGenerationResult as $index => $item) { |
| 74 | + if ($item instanceof PromptMessage) { |
| 75 | + $result[] = $item; |
| 76 | + } else { |
| 77 | + $result = array_merge($result, $this->format($item)); |
| 78 | + } |
| 79 | + } |
| 80 | + |
| 81 | + return $result; |
| 82 | + } |
| 83 | + |
| 84 | + if (!array_is_list($promptGenerationResult)) { |
| 85 | + if (isset($promptGenerationResult['user']) || isset($promptGenerationResult['assistant'])) { |
| 86 | + $result = []; |
| 87 | + if (isset($promptGenerationResult['user'])) { |
| 88 | + $userContent = $this->formatContent($promptGenerationResult['user']); |
| 89 | + $result[] = new PromptMessage(Role::User, $userContent); |
| 90 | + } |
| 91 | + if (isset($promptGenerationResult['assistant'])) { |
| 92 | + $assistantContent = $this->formatContent($promptGenerationResult['assistant']); |
| 93 | + $result[] = new PromptMessage(Role::Assistant, $assistantContent); |
| 94 | + } |
| 95 | + |
| 96 | + return $result; |
| 97 | + } |
| 98 | + |
| 99 | + if (isset($promptGenerationResult['role']) && isset($promptGenerationResult['content'])) { |
| 100 | + return [$this->formatMessage($promptGenerationResult)]; |
| 101 | + } |
| 102 | + |
| 103 | + throw new RuntimeException('Associative array must contain either role/content keys or user/assistant keys.'); |
| 104 | + } |
| 105 | + |
| 106 | + $formattedMessages = []; |
| 107 | + foreach ($promptGenerationResult as $index => $message) { |
| 108 | + if ($message instanceof PromptMessage) { |
| 109 | + $formattedMessages[] = $message; |
| 110 | + } else { |
| 111 | + $formattedMessages[] = $this->formatMessage($message, $index); |
| 112 | + } |
| 113 | + } |
| 114 | + |
| 115 | + return $formattedMessages; |
| 116 | + } |
| 117 | + |
| 118 | + throw new RuntimeException('Invalid prompt generation result format.'); |
| 119 | + } |
| 120 | + |
| 121 | + /** |
| 122 | + * Formats a single message into a PromptMessage. |
| 123 | + */ |
| 124 | + private function formatMessage(mixed $message, ?int $index = null): PromptMessage |
| 125 | + { |
| 126 | + $indexStr = null !== $index ? " at index {$index}" : ''; |
| 127 | + |
| 128 | + if (!\is_array($message) || !\array_key_exists('role', $message) || !\array_key_exists('content', $message)) { |
| 129 | + throw new RuntimeException("Invalid message format{$indexStr}. Expected an array with 'role' and 'content' keys."); |
| 130 | + } |
| 131 | + |
| 132 | + $role = $message['role'] instanceof Role ? $message['role'] : Role::tryFrom($message['role']); |
| 133 | + if (null === $role) { |
| 134 | + throw new RuntimeException("Invalid role '{$message['role']}' in prompt message{$indexStr}. Only 'user' or 'assistant' are supported."); |
| 135 | + } |
| 136 | + |
| 137 | + $content = $this->formatContent($message['content'], $index); |
| 138 | + |
| 139 | + return new PromptMessage($role, $content); |
| 140 | + } |
| 141 | + |
| 142 | + /** |
| 143 | + * Formats content into a proper Content object. |
| 144 | + */ |
| 145 | + private function formatContent(mixed $content, ?int $index = null): TextContent|ImageContent|AudioContent|EmbeddedResource |
| 146 | + { |
| 147 | + $indexStr = null !== $index ? " at index {$index}" : ''; |
| 148 | + |
| 149 | + if ($content instanceof Content) { |
| 150 | + if ( |
| 151 | + $content instanceof TextContent || $content instanceof ImageContent |
| 152 | + || $content instanceof AudioContent || $content instanceof EmbeddedResource |
| 153 | + ) { |
| 154 | + return $content; |
| 155 | + } |
| 156 | + throw new RuntimeException("Invalid Content type{$indexStr}. PromptMessage only supports TextContent, ImageContent, AudioContent, or EmbeddedResource."); |
| 157 | + } |
| 158 | + |
| 159 | + if (\is_string($content)) { |
| 160 | + return new TextContent($content); |
| 161 | + } |
| 162 | + |
| 163 | + if (\is_array($content) && isset($content['type'])) { |
| 164 | + return $this->formatTypedContent($content, $index); |
| 165 | + } |
| 166 | + |
| 167 | + if (\is_scalar($content) || null === $content) { |
| 168 | + $stringContent = null === $content ? '(null)' : (\is_bool($content) ? ($content ? 'true' : 'false') : (string) $content); |
| 169 | + |
| 170 | + return new TextContent($stringContent); |
| 171 | + } |
| 172 | + |
| 173 | + $jsonContent = json_encode($content, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR); |
| 174 | + |
| 175 | + return new TextContent($jsonContent); |
| 176 | + } |
| 177 | + |
| 178 | + /** |
| 179 | + * Formats typed content arrays into Content objects. |
| 180 | + * |
| 181 | + * @param array<string, mixed> $content |
| 182 | + */ |
| 183 | + private function formatTypedContent(array $content, ?int $index = null): TextContent|ImageContent|AudioContent|EmbeddedResource |
| 184 | + { |
| 185 | + $indexStr = null !== $index ? " at index {$index}" : ''; |
| 186 | + $type = $content['type']; |
| 187 | + |
| 188 | + return match ($type) { |
| 189 | + 'text' => $this->formatTextContent($content, $indexStr), |
| 190 | + 'image' => $this->formatImageContent($content, $indexStr), |
| 191 | + 'audio' => $this->formatAudioContent($content, $indexStr), |
| 192 | + 'resource' => $this->formatResourceContent($content, $indexStr), |
| 193 | + default => throw new RuntimeException("Invalid content type '{$type}'{$indexStr}."), |
| 194 | + }; |
| 195 | + } |
| 196 | + |
| 197 | + /** |
| 198 | + * @param array<string, mixed> $content |
| 199 | + */ |
| 200 | + private function formatTextContent(array $content, string $indexStr): TextContent |
| 201 | + { |
| 202 | + if (!isset($content['text']) || !\is_string($content['text'])) { |
| 203 | + throw new RuntimeException(\sprintf('Invalid "text" content%s: Missing or invalid "text" string.', $indexStr)); |
| 204 | + } |
| 205 | + |
| 206 | + return new TextContent($content['text']); |
| 207 | + } |
| 208 | + |
| 209 | + /** |
| 210 | + * @param array<string, mixed> $content |
| 211 | + */ |
| 212 | + private function formatImageContent(array $content, string $indexStr): ImageContent |
| 213 | + { |
| 214 | + if (!isset($content['data']) || !\is_string($content['data'])) { |
| 215 | + throw new RuntimeException("Invalid 'image' content{$indexStr}: Missing or invalid 'data' string (base64)."); |
| 216 | + } |
| 217 | + if (!isset($content['mimeType']) || !\is_string($content['mimeType'])) { |
| 218 | + throw new RuntimeException("Invalid 'image' content{$indexStr}: Missing or invalid 'mimeType' string."); |
| 219 | + } |
| 220 | + |
| 221 | + return new ImageContent($content['data'], $content['mimeType']); |
| 222 | + } |
| 223 | + |
| 224 | + /** |
| 225 | + * @param array<string, mixed> $content |
| 226 | + */ |
| 227 | + private function formatAudioContent(array $content, string $indexStr): AudioContent |
| 228 | + { |
| 229 | + if (!isset($content['data']) || !\is_string($content['data'])) { |
| 230 | + throw new RuntimeException("Invalid 'audio' content{$indexStr}: Missing or invalid 'data' string (base64)."); |
| 231 | + } |
| 232 | + if (!isset($content['mimeType']) || !\is_string($content['mimeType'])) { |
| 233 | + throw new RuntimeException("Invalid 'audio' content{$indexStr}: Missing or invalid 'mimeType' string."); |
| 234 | + } |
| 235 | + |
| 236 | + return new AudioContent($content['data'], $content['mimeType']); |
| 237 | + } |
| 238 | + |
| 239 | + /** |
| 240 | + * @param array<string, mixed> $content |
| 241 | + */ |
| 242 | + private function formatResourceContent(array $content, string $indexStr): EmbeddedResource |
| 243 | + { |
| 244 | + if (!isset($content['resource']) || !\is_array($content['resource'])) { |
| 245 | + throw new RuntimeException("Invalid 'resource' content{$indexStr}: Missing or invalid 'resource' object."); |
| 246 | + } |
| 247 | + |
| 248 | + $resource = $content['resource']; |
| 249 | + if (!isset($resource['uri']) || !\is_string($resource['uri'])) { |
| 250 | + throw new RuntimeException("Invalid resource{$indexStr}: Missing or invalid 'uri'."); |
| 251 | + } |
| 252 | + |
| 253 | + if (isset($resource['text']) && \is_string($resource['text'])) { |
| 254 | + $resourceObj = new TextResourceContents($resource['uri'], $resource['mimeType'] ?? 'text/plain', $resource['text']); |
| 255 | + } elseif (isset($resource['blob']) && \is_string($resource['blob'])) { |
| 256 | + $resourceObj = new BlobResourceContents( |
| 257 | + $resource['uri'], |
| 258 | + $resource['mimeType'] ?? 'application/octet-stream', |
| 259 | + $resource['blob'] |
| 260 | + ); |
| 261 | + } else { |
| 262 | + throw new RuntimeException("Invalid resource{$indexStr}: Must contain 'text' or 'blob'."); |
| 263 | + } |
| 264 | + |
| 265 | + return new EmbeddedResource($resourceObj); |
| 266 | + } |
| 267 | +} |
0 commit comments