[Capability] Preserve optional content fields in PromptResultFormatter (#406)

* Preserve optional content fields in PromptResultFormatter

The formatter's typed-content path rebuilt each Content object from only
its required fields, silently dropping everything else a prompt returned
(annotations on every type, _meta and explicit mimeType on embedded
resource contents). Delegate to the schema classes' fromArray() instead,
keeping the historical mimeType defaulting and RuntimeException-with-
index error contract.

ImageContent was the only content class without annotations support, so
it gains the same constructor/fromArray()/jsonSerialize() handling as
TextContent and AudioContent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Reword CHANGELOG entry to be user-facing

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Christopher Hertel 2026-08-14 22:47:26 +02:00 • committed by GitHub
commit 8bae3c054e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 283 additions and 89 deletions

View file

@ -11,15 +11,14 @@
namespace Mcp\Capability\Formatter;
use Mcp\Exception\InvalidArgumentException;
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\Schema\Content\PromptMessage;
use Mcp\Schema\Content\TextContent;
use Mcp\Schema\Content\TextResourceContents;
use Mcp\Schema\Enum\Role;
/**
@ -178,6 +177,9 @@ final class PromptResultFormatter
/**
* Formats typed content arrays into Content objects.
*
* Delegates to the schema classes' fromArray() so optional fields
* (annotations, mimeType, _meta, ...) carry over instead of being dropped.
*
* @param array<string, mixed> $content
*/
private function formatTypedContent(array $content, ?int $index = null): TextContent|ImageContent|AudioContent|EmbeddedResource
@ -185,83 +187,22 @@ final class PromptResultFormatter
$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<string, mixed> $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));
if ('resource' === $type && isset($content['resource']) && \is_array($content['resource']) && !isset($content['resource']['mimeType'])) {
// EmbeddedResource::fromArray() leaves a missing mimeType unset; this
// formatter has always defaulted it, so keep that for compatibility.
$content['resource']['mimeType'] = isset($content['resource']['text']) ? 'text/plain' : 'application/octet-stream';
}
return new TextContent($content['text']);
}
/**
* @param array<string, mixed> $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).");
try {
return match ($type) {
'text' => TextContent::fromArray($content),
'image' => ImageContent::fromArray($content),
'audio' => AudioContent::fromArray($content),
'resource' => EmbeddedResource::fromArray($content),
default => throw new RuntimeException("Invalid content type '{$type}'{$indexStr}."),
};
} catch (InvalidArgumentException $e) {
throw new RuntimeException("Invalid '{$type}' content{$indexStr}: {$e->getMessage()}", 0, $e);
}
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<string, mixed> $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<string, mixed> $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);
}
}

View file

@ -12,12 +12,16 @@
namespace Mcp\Schema\Content;
use Mcp\Exception\InvalidArgumentException;
use Mcp\Schema\Annotations;
/**
* @phpstan-import-type AnnotationsData from Annotations
*
* @phpstan-type ImageContentData array{
* type: 'image',
* data: string,
* mimeType: string
* mimeType: string,
* annotations?: AnnotationsData,
* }
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
@ -27,12 +31,14 @@ class ImageContent extends Content
/**
* Create a new ImageContent instance.
*
* @param string $data Base64-encoded image data
* @param string $mimeType The MIME type of the image
* @param string $data Base64-encoded image data
* @param string $mimeType The MIME type of the image
* @param ?Annotations $annotations Optional annotations describing the content
*/
public function __construct(
public readonly string $data,
public readonly string $mimeType,
public readonly ?Annotations $annotations = null,
) {
parent::__construct('image');
}
@ -49,18 +55,23 @@ class ImageContent extends Content
throw new InvalidArgumentException('Missing or invalid "mimeType" in ImageContent data.');
}
return new self($data['data'], $data['mimeType']);
return new self(
$data['data'],
$data['mimeType'],
isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null
);
}
/**
* Create a new ImageContent from a file path.
*
* @param string $path Path to the image file
* @param string|null $mimeType Optional MIME type override
* @param string $path Path to the image file
* @param string|null $mimeType Optional MIME type override
* @param ?Annotations $annotations Optional annotations describing the content
*
* @throws InvalidArgumentException If the file doesn't exist
*/
public static function fromFile(string $path, ?string $mimeType = null): self
public static function fromFile(string $path, ?string $mimeType = null, ?Annotations $annotations = null): self
{
if (!file_exists($path)) {
throw new InvalidArgumentException(\sprintf('Image file not found: "%s".', $path));
@ -69,25 +80,36 @@ class ImageContent extends Content
$data = base64_encode(file_get_contents($path));
$detectedMime = $mimeType ?? mime_content_type($path) ?: 'image/png';
return new self($data, $detectedMime);
return new self($data, $detectedMime, $annotations);
}
public static function fromString(string $data, string $mimeType): self
public static function fromString(string $data, string $mimeType, ?Annotations $annotations = null): self
{
return new self(base64_encode($data), $mimeType);
return new self(base64_encode($data), $mimeType, $annotations);
}
/**
* Convert the content to an array.
*
* @return ImageContentData
* @return array{
* type: 'image',
* data: string,
* mimeType: string,
* annotations?: Annotations,
* }
*/
public function jsonSerialize(): array
{
return [
$result = [
'type' => $this->type,
'data' => $this->data,
'mimeType' => $this->mimeType,
];
if (null !== $this->annotations) {
$result['annotations'] = $this->annotations;
}
return $result;
}
}