diff --git a/CHANGELOG.md b/CHANGELOG.md index 01bf9a90..1bb454cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ----- diff --git a/src/Capability/Formatter/PromptResultFormatter.php b/src/Capability/Formatter/PromptResultFormatter.php index 871bded7..92ae78c2 100644 --- a/src/Capability/Formatter/PromptResultFormatter.php +++ b/src/Capability/Formatter/PromptResultFormatter.php @@ -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 @@ 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 $content */ private function formatTypedContent(array $content, ?int $index = null): TextContent|ImageContent|AudioContent|EmbeddedResource @@ -185,83 +187,22 @@ private function formatTypedContent(array $content, ?int $index = null): TextCon $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 ('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); } } diff --git a/src/Schema/Content/ImageContent.php b/src/Schema/Content/ImageContent.php index b64d007c..9e2cbffd 100644 --- a/src/Schema/Content/ImageContent.php +++ b/src/Schema/Content/ImageContent.php @@ -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 @@ -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 @@ 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)); @@ -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; } } diff --git a/tests/Unit/Capability/Formatter/PromptResultFormatterTest.php b/tests/Unit/Capability/Formatter/PromptResultFormatterTest.php index 53d6dc72..85c443cf 100644 --- a/tests/Unit/Capability/Formatter/PromptResultFormatterTest.php +++ b/tests/Unit/Capability/Formatter/PromptResultFormatterTest.php @@ -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; @@ -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']], + ]); + } } diff --git a/tests/Unit/Schema/Content/ImageContentTest.php b/tests/Unit/Schema/Content/ImageContentTest.php new file mode 100644 index 00000000..b1672021 --- /dev/null +++ b/tests/Unit/Schema/Content/ImageContentTest.php @@ -0,0 +1,87 @@ +assertNull($content->annotations); + } + + public function testConstructorAcceptsAnnotations(): void + { + $annotations = new Annotations([Role::User], 0.5); + $content = new ImageContent(base64_encode('binary'), 'image/png', $annotations); + + $this->assertSame($annotations, $content->annotations); + } + + public function testFromArrayDeserializesAnnotations(): void + { + $content = ImageContent::fromArray([ + 'type' => 'image', + 'data' => base64_encode('binary'), + 'mimeType' => 'image/png', + 'annotations' => ['audience' => ['user'], 'priority' => 0.5], + ]); + + $this->assertNotNull($content->annotations); + $this->assertSame([Role::User], $content->annotations->audience); + $this->assertSame(0.5, $content->annotations->priority); + } + + public function testJsonSerializeOmitsNullAnnotations(): void + { + $content = new ImageContent(base64_encode('binary'), 'image/png'); + + $this->assertArrayNotHasKey('annotations', $content->jsonSerialize()); + } + + public function testJsonSerializeIncludesAnnotations(): void + { + $annotations = new Annotations([Role::User], 0.5); + $content = new ImageContent(base64_encode('binary'), 'image/png', $annotations); + + $data = $content->jsonSerialize(); + + $this->assertSame($annotations, $data['annotations']); + } + + public function testRoundTripWithAnnotations(): void + { + $original = new ImageContent(base64_encode('binary'), 'image/png', new Annotations([Role::User], 0.5)); + + $decoded = json_decode(json_encode($original), true); + $rehydrated = ImageContent::fromArray($decoded); + + $this->assertSame($original->data, $rehydrated->data); + $this->assertSame($original->mimeType, $rehydrated->mimeType); + $this->assertEquals($original->annotations, $rehydrated->annotations); + } + + public function testFromStringAcceptsAnnotations(): void + { + $annotations = new Annotations([Role::User]); + $content = ImageContent::fromString('binary', 'image/png', $annotations); + + $this->assertSame(base64_encode('binary'), $content->data); + $this->assertSame($annotations, $content->annotations); + } +}