Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

All notable changes to `mcp/sdk` will be documented in this file.

0.8.0
-----

* Prompt generators returning content as typed arrays (`['type' => 'text', ...]` etc.) no longer lose the optional fields: `annotations` on every content type, and `_meta` and an explicit `mimeType` on embedded resource contents, now carry through to the resulting `PromptMessage` instead of being silently dropped. A missing resource `mimeType` still defaults to `text/plain`/`application/octet-stream` as before.
* Add `annotations` support to `ImageContent` (constructor, `fromArray()`, `fromFile()`, `fromString()`, `jsonSerialize()`), matching `TextContent` and `AudioContent`.

0.7.0
-----

Expand Down
95 changes: 18 additions & 77 deletions src/Capability/Formatter/PromptResultFormatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -178,90 +177,32 @@ private function formatContent(mixed $content, ?int $index = null): TextContent|
/**
* 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
{
$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));
}

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).");
}
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 ('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';
}

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'.");
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);
}

return new EmbeddedResource($resourceObj);
}
}
46 changes: 34 additions & 12 deletions src/Schema/Content/ImageContent.php
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -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');
}
Expand All @@ -49,18 +55,23 @@ public static function fromArray(array $data): self
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));
Expand All @@ -69,25 +80,36 @@ public static function fromFile(string $path, ?string $mimeType = null): self
$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;
}
}
142 changes: 142 additions & 0 deletions tests/Unit/Capability/Formatter/PromptResultFormatterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
namespace Mcp\Tests\Unit\Capability\Formatter;

use Mcp\Capability\Formatter\PromptResultFormatter;
use Mcp\Exception\RuntimeException;
use Mcp\Schema\Content\AudioContent;
use Mcp\Schema\Content\EmbeddedResource;
use Mcp\Schema\Content\ImageContent;
use Mcp\Schema\Content\PromptMessage;
use Mcp\Schema\Content\TextContent;
use Mcp\Schema\Enum\Role;
Expand Down Expand Up @@ -46,4 +50,142 @@ public function testFormatRoleContentArray(): void
$this->assertCount(1, $result);
$this->assertSame(Role::User, $result[0]->role);
}

public function testFormatTypedTextContentPreservesAnnotations(): void
{
$result = (new PromptResultFormatter())->format([
[
'role' => 'user',
'content' => [
'type' => 'text',
'text' => 'Hello',
'annotations' => ['audience' => ['user'], 'priority' => 0.5],
],
],
]);

$content = $result[0]->content;
$this->assertInstanceOf(TextContent::class, $content);
$this->assertSame('Hello', $content->text);
$this->assertNotNull($content->annotations);
$this->assertSame([Role::User], $content->annotations->audience);
$this->assertSame(0.5, $content->annotations->priority);
}

public function testFormatTypedImageContentPreservesAnnotations(): void
{
$result = (new PromptResultFormatter())->format([
[
'role' => 'user',
'content' => [
'type' => 'image',
'data' => base64_encode('binary'),
'mimeType' => 'image/png',
'annotations' => ['audience' => ['assistant']],
],
],
]);

$content = $result[0]->content;
$this->assertInstanceOf(ImageContent::class, $content);
$this->assertSame('image/png', $content->mimeType);
$this->assertNotNull($content->annotations);
$this->assertSame([Role::Assistant], $content->annotations->audience);
}

public function testFormatTypedAudioContentPreservesAnnotations(): void
{
$result = (new PromptResultFormatter())->format([
[
'role' => 'user',
'content' => [
'type' => 'audio',
'data' => base64_encode('binary'),
'mimeType' => 'audio/mpeg',
'annotations' => ['priority' => 1.0],
],
],
]);

$content = $result[0]->content;
$this->assertInstanceOf(AudioContent::class, $content);
$this->assertNotNull($content->annotations);
$this->assertSame(1.0, $content->annotations->priority);
}

public function testFormatTypedResourceContentPreservesOptionalFields(): void
{
$result = (new PromptResultFormatter())->format([
[
'role' => 'user',
'content' => [
'type' => 'resource',
'resource' => [
'uri' => 'file://data.json',
'mimeType' => 'application/json',
'text' => '{"key": "value"}',
'_meta' => ['origin' => 'test'],
],
'annotations' => ['audience' => ['user']],
],
],
]);

$content = $result[0]->content;
$this->assertInstanceOf(EmbeddedResource::class, $content);
$this->assertSame('application/json', $content->resource->mimeType);
$this->assertSame(['origin' => 'test'], $content->resource->meta);
$this->assertNotNull($content->annotations);
$this->assertSame([Role::User], $content->annotations->audience);
}

public function testFormatTypedTextResourceContentDefaultsMimeType(): void
{
$result = (new PromptResultFormatter())->format([
[
'role' => 'user',
'content' => [
'type' => 'resource',
'resource' => ['uri' => 'file://a.txt', 'text' => 'plain'],
],
],
]);

$this->assertSame('text/plain', $result[0]->content->resource->mimeType);
}

public function testFormatTypedBlobResourceContentDefaultsMimeType(): void
{
$result = (new PromptResultFormatter())->format([
[
'role' => 'user',
'content' => [
'type' => 'resource',
'resource' => ['uri' => 'file://a.bin', 'blob' => base64_encode('binary')],
],
],
]);

$this->assertSame('application/octet-stream', $result[0]->content->resource->mimeType);
}

public function testFormatTypedContentRejectsInvalidDataWithIndexContext(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage("Invalid 'text' content at index 0");

(new PromptResultFormatter())->format([
['role' => 'user', 'content' => ['type' => 'text']],
]);
}

public function testFormatTypedContentRejectsUnknownType(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage("Invalid content type 'video' at index 0.");

(new PromptResultFormatter())->format([
['role' => 'user', 'content' => ['type' => 'video']],
]);
}
}
Loading