Skip to content

Commit ab4f686

Browse files
authored
refactor: put formatters into dedicated classes (#224)
Split formatting logic from Reference classes into dedicated formatter classes: - ToolResultFormatter: Formats tool execution results into Content items - PromptResultFormatter: Formats prompt results into PromptMessage items - ResourceResultFormatter: Formats resource results into ResourceContents items This refactor: - Eliminates 122 lines of code duplication between ResourceReference and ResourceTemplateReference (identical formatting logic) - Improves code organization by separating formatting concerns from capability reference management - Makes formatters independently testable and reusable - Reduces Reference classes by ~450 lines while maintaining all functionality Changes: - Create src/Capability/Formatter/* with three formatter classes - Update Reference classes to delegate to formatters instead of inline logic - Add comprehensive test coverage for all formatter classes - All existing tests pass; no behavioral changes
1 parent 48af7bd commit ab4f686

File tree

10 files changed

+727
-595
lines changed

10 files changed

+727
-595
lines changed
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
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

Comments
 (0)