diff --git a/CHANGELOG.md b/CHANGELOG.md index 01bf9a90..3e8fd215 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to `mcp/sdk` will be documented in this file. +0.8.0 +----- + +* Add `Mcp\Schema\Content\ResourceLink` for the spec's `resource_link` content block (protocol revision 2025-06-18+), letting tool results and prompt messages reference a resource by URI/name without embedding its contents. Accepted anywhere `resource` (`EmbeddedResource`) content is (de)serialized: `CallToolResult::fromArray()`, `PromptMessage::fromArray()`, and `PromptResultFormatter`. + 0.7.0 ----- diff --git a/docs/mcp-elements.md b/docs/mcp-elements.md index 0f8c9eb2..3617a2fc 100644 --- a/docs/mcp-elements.md +++ b/docs/mcp-elements.md @@ -107,7 +107,7 @@ public function returnVoid(): void { /* no return */ } // Empty conten For fine control over output formatting: ```php -use Mcp\Schema\Content\{TextContent, ImageContent, AudioContent, EmbeddedResource}; +use Mcp\Schema\Content\{TextContent, ImageContent, AudioContent, ResourceLink, EmbeddedResource}; public function getFormattedCode(): TextContent { @@ -142,6 +142,17 @@ public function getEmbeddedResource(): EmbeddedResource resource: ['uri' => 'file://data.json', 'text' => 'File content'] ); } + +public function getResourceLink(): ResourceLink +{ + // Reference a resource by URI without embedding its contents, e.g. when + // a tool result would otherwise need to inline many or large resources. + return new ResourceLink( + uri: 'file://data.json', + name: 'data.json', + mimeType: 'application/json' + ); +} ``` #### Multiple Content Items diff --git a/examples/server/discovery-userprofile/McpElements.php b/examples/server/discovery-userprofile/McpElements.php index 8ab6c7d4..c8532544 100644 --- a/examples/server/discovery-userprofile/McpElements.php +++ b/examples/server/discovery-userprofile/McpElements.php @@ -18,6 +18,9 @@ use Mcp\Capability\Attribute\McpTool; use Mcp\Exception\PromptGetException; use Mcp\Exception\ResourceReadException; +use Mcp\Schema\Content\Content; +use Mcp\Schema\Content\ResourceLink; +use Mcp\Schema\Content\TextContent; use Psr\Log\LoggerInterface; /** @@ -87,6 +90,44 @@ public function listUserIds(): array return array_keys($this->users); } + /** + * Looks up a user and returns a reference to their profile resource. + * + * Rather than embedding the full profile (as `resources/read` on + * `user://{userId}/profile` would), this returns a `resource_link` block + * pointing at that resource template so the caller can fetch it + * separately if needed. This mirrors how a tool like a search returning + * many hits would reference each matching resource by URI instead of + * inlining every one of them. + * + * @param string $userId the ID of the user to look up + * + * @return Content[] a short summary plus a resource_link to the user's profile + */ + #[McpTool(name: 'lookup_user')] + public function lookupUser( + #[CompletionProvider(values: ['101', '102', '103'])] + string $userId, + ): array { + $this->logger->info('Executing tool: lookup_user', ['userId' => $userId]); + + if (!isset($this->users[$userId])) { + return [new TextContent("User ID {$userId} not found.")]; + } + + $user = $this->users[$userId]; + + return [ + new TextContent("Found user {$user['name']} ({$user['role']})."), + new ResourceLink( + uri: "user://{$userId}/profile", + name: 'user_profile', + description: "Full profile for {$user['name']}.", + mimeType: 'application/json', + ), + ]; + } + /** * Sends a welcome message to a user. * (This is a placeholder - in a real app, it might queue an email). diff --git a/src/Capability/Formatter/PromptResultFormatter.php b/src/Capability/Formatter/PromptResultFormatter.php index 871bded7..b1a8ba48 100644 --- a/src/Capability/Formatter/PromptResultFormatter.php +++ b/src/Capability/Formatter/PromptResultFormatter.php @@ -18,6 +18,7 @@ use Mcp\Schema\Content\EmbeddedResource; use Mcp\Schema\Content\ImageContent; use Mcp\Schema\Content\PromptMessage; +use Mcp\Schema\Content\ResourceLink; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Content\TextResourceContents; use Mcp\Schema\Enum\Role; @@ -142,18 +143,19 @@ private function formatMessage(mixed $message, ?int $index = null): PromptMessag /** * Formats content into a proper Content object. */ - private function formatContent(mixed $content, ?int $index = null): TextContent|ImageContent|AudioContent|EmbeddedResource + private function formatContent(mixed $content, ?int $index = null): TextContent|ImageContent|AudioContent|ResourceLink|EmbeddedResource { $indexStr = null !== $index ? " at index {$index}" : ''; if ($content instanceof Content) { if ( $content instanceof TextContent || $content instanceof ImageContent - || $content instanceof AudioContent || $content instanceof EmbeddedResource + || $content instanceof AudioContent || $content instanceof ResourceLink + || $content instanceof EmbeddedResource ) { return $content; } - throw new RuntimeException("Invalid Content type{$indexStr}. PromptMessage only supports TextContent, ImageContent, AudioContent, or EmbeddedResource."); + throw new RuntimeException("Invalid Content type{$indexStr}. PromptMessage only supports TextContent, ImageContent, AudioContent, ResourceLink, or EmbeddedResource."); } if (\is_string($content)) { @@ -180,7 +182,7 @@ private function formatContent(mixed $content, ?int $index = null): TextContent| * * @param array $content */ - private function formatTypedContent(array $content, ?int $index = null): TextContent|ImageContent|AudioContent|EmbeddedResource + private function formatTypedContent(array $content, ?int $index = null): TextContent|ImageContent|AudioContent|ResourceLink|EmbeddedResource { $indexStr = null !== $index ? " at index {$index}" : ''; $type = $content['type']; @@ -190,6 +192,7 @@ private function formatTypedContent(array $content, ?int $index = null): TextCon 'image' => $this->formatImageContent($content, $indexStr), 'audio' => $this->formatAudioContent($content, $indexStr), 'resource' => $this->formatResourceContent($content, $indexStr), + 'resource_link' => $this->formatResourceLinkContent($content, $indexStr), default => throw new RuntimeException("Invalid content type '{$type}'{$indexStr}."), }; } @@ -264,4 +267,19 @@ private function formatResourceContent(array $content, string $indexStr): Embedd return new EmbeddedResource($resourceObj); } + + /** + * @param array $content + */ + private function formatResourceLinkContent(array $content, string $indexStr): ResourceLink + { + if (!isset($content['uri']) || !\is_string($content['uri'])) { + throw new RuntimeException("Invalid 'resource_link' content{$indexStr}: Missing or invalid 'uri' string."); + } + if (!isset($content['name']) || !\is_string($content['name'])) { + throw new RuntimeException("Invalid 'resource_link' content{$indexStr}: Missing or invalid 'name' string."); + } + + return new ResourceLink($content['uri'], $content['name']); + } } diff --git a/src/Capability/Registry/ToolReference.php b/src/Capability/Registry/ToolReference.php index 36c0835f..beec1827 100644 --- a/src/Capability/Registry/ToolReference.php +++ b/src/Capability/Registry/ToolReference.php @@ -68,6 +68,17 @@ public function formatResult(mixed $toolExecutionResult): array public function extractStructuredContent(mixed $toolExecutionResult): ?array { if (\is_array($toolExecutionResult)) { + foreach ($toolExecutionResult as $item) { + if ($item instanceof Content) { + // Content items are already reflected in the result's `content` + // array; a raw array holding one or more of them isn't + // structured data and, if it were serialized as-is, could + // produce a `structuredContent` value that isn't a JSON object + // (e.g. a list), which the spec doesn't allow. + return null; + } + } + return $toolExecutionResult; } diff --git a/src/Schema/Content/PromptMessage.php b/src/Schema/Content/PromptMessage.php index 075d1136..d0b8f769 100644 --- a/src/Schema/Content/PromptMessage.php +++ b/src/Schema/Content/PromptMessage.php @@ -20,11 +20,12 @@ * @phpstan-import-type TextContentData from TextContent * @phpstan-import-type ImageContentData from ImageContent * @phpstan-import-type AudioContentData from AudioContent + * @phpstan-import-type ResourceLinkData from ResourceLink * @phpstan-import-type EmbeddedResourceData from EmbeddedResource * * @phpstan-type PromptMessageData array{ * role: string, - * content: TextContentData|ImageContentData|AudioContentData|EmbeddedResourceData, + * content: TextContentData|ImageContentData|AudioContentData|ResourceLinkData|EmbeddedResourceData, * } * * @author Kyrian Obikwelu @@ -34,12 +35,12 @@ class PromptMessage extends Content /** * Create a new PromptMessage instance. * - * @param Role $role The role of the message - * @param TextContent|ImageContent|AudioContent|EmbeddedResource $content The content of the message + * @param Role $role The role of the message + * @param TextContent|ImageContent|AudioContent|ResourceLink|EmbeddedResource $content The content of the message */ public function __construct( public readonly Role $role, - public readonly TextContent|ImageContent|AudioContent|EmbeddedResource $content, + public readonly TextContent|ImageContent|AudioContent|ResourceLink|EmbeddedResource $content, ) { parent::__construct('prompt'); } @@ -64,6 +65,7 @@ public static function fromArray(array $data): self 'image' => ImageContent::fromArray($contentData), 'audio' => AudioContent::fromArray($contentData), 'resource' => EmbeddedResource::fromArray($contentData), + 'resource_link' => ResourceLink::fromArray($contentData), default => throw new InvalidArgumentException(\sprintf('Invalid content type "%s" for PromptMessage.', $contentType)), }; @@ -75,7 +77,7 @@ public static function fromArray(array $data): self * * @return array{ * role: string, - * content: TextContent|ImageContent|AudioContent|EmbeddedResource + * content: TextContent|ImageContent|AudioContent|ResourceLink|EmbeddedResource * } */ public function jsonSerialize(): array diff --git a/src/Schema/Content/ResourceLink.php b/src/Schema/Content/ResourceLink.php new file mode 100644 index 00000000..874cc85a --- /dev/null +++ b/src/Schema/Content/ResourceLink.php @@ -0,0 +1,146 @@ +, + * } + * + * @author Alex Rothberg + */ +class ResourceLink extends Content +{ + /** + * @param string $uri the URI of this resource + * @param string $name a short identifier for this resource + * @param ?string $title optional human-readable title for display in UI + * @param ?string $description a description of what this resource represents. This can be used by clients to improve the LLM's understanding of available resources + * @param ?string $mimeType the MIME type of this resource, if known + * @param ?Annotations $annotations optional annotations for the client + * @param ?int $size the size of the raw resource content, in bytes (before base64 encoding or any tokenization), if known + * @param ?Icon[] $icons optional icons representing the resource + * @param ?array $meta optional metadata + */ + public function __construct( + public readonly string $uri, + public readonly string $name, + public readonly ?string $title = null, + public readonly ?string $description = null, + public readonly ?string $mimeType = null, + public readonly ?Annotations $annotations = null, + public readonly ?int $size = null, + public readonly ?array $icons = null, + public readonly ?array $meta = null, + ) { + parent::__construct('resource_link'); + } + + /** + * @param ResourceLinkData $data + */ + public static function fromArray(array $data): self + { + if (($data['type'] ?? null) !== 'resource_link') { + throw new InvalidArgumentException('Invalid type for ResourceLink.'); + } + if (empty($data['uri']) || !\is_string($data['uri'])) { + throw new InvalidArgumentException('Invalid or missing "uri" in ResourceLink data.'); + } + if (empty($data['name']) || !\is_string($data['name'])) { + throw new InvalidArgumentException('Invalid or missing "name" in ResourceLink data.'); + } + if (isset($data['_meta']) && !\is_array($data['_meta'])) { + throw new InvalidArgumentException('Invalid "_meta" in ResourceLink data.'); + } + + return new self( + uri: $data['uri'], + name: $data['name'], + title: isset($data['title']) && \is_string($data['title']) ? $data['title'] : null, + description: $data['description'] ?? null, + mimeType: $data['mimeType'] ?? null, + annotations: isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null, + size: isset($data['size']) ? (int) $data['size'] : null, + icons: isset($data['icons']) && \is_array($data['icons']) ? array_map(Icon::fromArray(...), $data['icons']) : null, + meta: $data['_meta'] ?? null, + ); + } + + /** + * @return array{ + * type: 'resource_link', + * uri: string, + * name: string, + * title?: string, + * description?: string, + * mimeType?: string, + * annotations?: Annotations, + * size?: int, + * icons?: Icon[], + * _meta?: array, + * } + */ + public function jsonSerialize(): array + { + $data = [ + 'type' => $this->type, + 'uri' => $this->uri, + 'name' => $this->name, + ]; + if (null !== $this->title) { + $data['title'] = $this->title; + } + if (null !== $this->description) { + $data['description'] = $this->description; + } + if (null !== $this->mimeType) { + $data['mimeType'] = $this->mimeType; + } + if (null !== $this->annotations) { + $data['annotations'] = $this->annotations; + } + if (null !== $this->size) { + $data['size'] = $this->size; + } + if (null !== $this->icons) { + $data['icons'] = $this->icons; + } + if (null !== $this->meta) { + $data['_meta'] = $this->meta; + } + + return $data; + } +} diff --git a/src/Schema/Result/CallToolResult.php b/src/Schema/Result/CallToolResult.php index bfbc9fab..e1c41f88 100644 --- a/src/Schema/Result/CallToolResult.php +++ b/src/Schema/Result/CallToolResult.php @@ -16,6 +16,7 @@ use Mcp\Schema\Content\Content; use Mcp\Schema\Content\EmbeddedResource; use Mcp\Schema\Content\ImageContent; +use Mcp\Schema\Content\ResourceLink; use Mcp\Schema\Content\TextContent; use Mcp\Schema\JsonRpc\ResultInterface; @@ -100,6 +101,7 @@ public static function fromArray(array $data): self 'image' => ImageContent::fromArray($item), 'audio' => AudioContent::fromArray($item), 'resource' => EmbeddedResource::fromArray($item), + 'resource_link' => ResourceLink::fromArray($item), default => throw new InvalidArgumentException(\sprintf('Invalid content type in CallToolResult data: "%s".', $item['type'] ?? null)), }; } diff --git a/tests/Inspector/Http/HttpDiscoveryUserProfileTest.php b/tests/Inspector/Http/HttpDiscoveryUserProfileTest.php index 985b01b7..353230a7 100644 --- a/tests/Inspector/Http/HttpDiscoveryUserProfileTest.php +++ b/tests/Inspector/Http/HttpDiscoveryUserProfileTest.php @@ -17,6 +17,22 @@ public static function provideMethods(): array { return [ ...parent::provideMethods(), + 'Lookup User Tool' => [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'lookup_user', + 'toolArgs' => ['userId' => '"101"'], + ], + 'testName' => 'lookup_user', + ], + 'Lookup User Tool (Not Found)' => [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'lookup_user', + 'toolArgs' => ['userId' => '"999"'], + ], + 'testName' => 'lookup_user_not_found', + ], 'Send Welcome Tool' => [ 'method' => 'tools/call', 'options' => [ diff --git a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-lookup_user.json b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-lookup_user.json new file mode 100644 index 00000000..1399cd26 --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-lookup_user.json @@ -0,0 +1,16 @@ +{ + "content": [ + { + "type": "text", + "text": "Found user Alice (admin)." + }, + { + "name": "user_profile", + "uri": "user://101/profile", + "description": "Full profile for Alice.", + "mimeType": "application/json", + "type": "resource_link" + } + ], + "isError": false +} diff --git a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-lookup_user_not_found.json b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-lookup_user_not_found.json new file mode 100644 index 00000000..211521cf --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-lookup_user_not_found.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "User ID 999 not found." + } + ], + "isError": false +} diff --git a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_list.json b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_list.json index 205ad0ce..08b8198d 100644 --- a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_list.json +++ b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_list.json @@ -23,6 +23,22 @@ ] } }, + { + "name": "lookup_user", + "description": "Looks up a user and returns a reference to their profile resource.\n\nRather than embedding the full profile (as `resources/read` on\n`user://{userId}/profile` would), this returns a `resource_link` block\npointing at that resource template so the caller can fetch it\nseparately if needed. This mirrors how a tool like a search returning\nmany hits would reference each matching resource by URI instead of\ninlining every one of them.", + "inputSchema": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "the ID of the user to look up" + } + }, + "required": [ + "userId" + ] + } + }, { "name": "send_welcome", "description": "Sends a welcome message to a user.\n\n(This is a placeholder - in a real app, it might queue an email).", diff --git a/tests/Unit/Capability/Formatter/PromptResultFormatterTest.php b/tests/Unit/Capability/Formatter/PromptResultFormatterTest.php index 53d6dc72..d86d2fe9 100644 --- a/tests/Unit/Capability/Formatter/PromptResultFormatterTest.php +++ b/tests/Unit/Capability/Formatter/PromptResultFormatterTest.php @@ -13,6 +13,7 @@ use Mcp\Capability\Formatter\PromptResultFormatter; use Mcp\Schema\Content\PromptMessage; +use Mcp\Schema\Content\ResourceLink; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Enum\Role; use PHPUnit\Framework\TestCase; @@ -27,6 +28,29 @@ public function testFormatPromptMessage(): void $this->assertSame($message, $result[0]); } + public function testFormatPromptMessageWithResourceLinkContent(): void + { + $message = new PromptMessage(Role::User, new ResourceLink('file:///a.png', 'a.png')); + $result = (new PromptResultFormatter())->format($message); + $this->assertCount(1, $result); + $this->assertSame($message, $result[0]); + } + + public function testFormatRoleContentArrayWithResourceLinkContent(): void + { + $result = (new PromptResultFormatter())->format([ + [ + 'role' => 'user', + 'content' => ['type' => 'resource_link', 'uri' => 'file:///a.png', 'name' => 'a.png'], + ], + ]); + $this->assertCount(1, $result); + $this->assertSame(Role::User, $result[0]->role); + $this->assertInstanceOf(ResourceLink::class, $result[0]->content); + $this->assertSame('file:///a.png', $result[0]->content->uri); + $this->assertSame('a.png', $result[0]->content->name); + } + public function testFormatUserAssistantShorthand(): void { $result = (new PromptResultFormatter())->format([ diff --git a/tests/Unit/Capability/RegistryTest.php b/tests/Unit/Capability/RegistryTest.php index c8aa5a7f..3b2d7d7b 100644 --- a/tests/Unit/Capability/RegistryTest.php +++ b/tests/Unit/Capability/RegistryTest.php @@ -22,6 +22,8 @@ use Mcp\Exception\PromptNotFoundException; use Mcp\Exception\ResourceNotFoundException; use Mcp\Exception\ToolNotFoundException; +use Mcp\Schema\Content\ResourceLink; +use Mcp\Schema\Content\TextContent; use Mcp\Schema\Prompt; use Mcp\Schema\ResourceDefinition; use Mcp\Schema\ResourceTemplate; @@ -536,6 +538,20 @@ public function testExtractStructuredContentReturnsArrayDirectlyForArrayOutputSc ], $structuredContent); } + public function testExtractStructuredContentReturnsNullForArrayOfContentItems(): void + { + $tool = $this->createValidTool('lookup_thing', null); + $toolReturnValue = [ + new TextContent('Found it.'), + new ResourceLink(uri: 'thing://1', name: 'thing_1'), + ]; + + $this->registry->registerTool($tool, static fn () => $toolReturnValue); + + $toolRef = $this->registry->getTool('lookup_thing'); + $this->assertNull($toolRef->extractStructuredContent($toolReturnValue)); + } + public function testConfiguredLoaderIsNotRunUntilFirstRead(): void { $loader = $this->createMock(LoaderInterface::class); diff --git a/tests/Unit/Schema/Content/PromptMessageTest.php b/tests/Unit/Schema/Content/PromptMessageTest.php new file mode 100644 index 00000000..8808d412 --- /dev/null +++ b/tests/Unit/Schema/Content/PromptMessageTest.php @@ -0,0 +1,84 @@ + 'user', + 'content' => [ + 'type' => 'resource_link', + 'uri' => 'file:///project/src/main.rs', + 'name' => 'main.rs', + ], + ]); + + $this->assertSame(Role::User, $message->role); + $this->assertInstanceOf(ResourceLink::class, $message->content); + $this->assertSame('file:///project/src/main.rs', $message->content->uri); + $this->assertSame('main.rs', $message->content->name); + } + + public function testJsonSerializeIncludesResourceLinkContent(): void + { + $message = new PromptMessage(Role::Assistant, new ResourceLink('file:///a.png', 'a.png')); + + $this->assertSame([ + 'role' => 'assistant', + 'content' => [ + 'type' => 'resource_link', + 'uri' => 'file:///a.png', + 'name' => 'a.png', + ], + ], json_decode(json_encode($message), true)); + } + + public function testRoundTripWithResourceLink(): void + { + $original = new PromptMessage(Role::User, new ResourceLink('file:///a.png', 'a.png', mimeType: 'image/png')); + + $decoded = json_decode(json_encode($original), true); + $rehydrated = PromptMessage::fromArray($decoded); + + $this->assertSame(Role::User, $rehydrated->role); + $this->assertInstanceOf(ResourceLink::class, $rehydrated->content); + $this->assertSame('file:///a.png', $rehydrated->content->uri); + $this->assertSame('image/png', $rehydrated->content->mimeType); + } + + public function testFromArrayRejectsUnknownContentType(): void + { + $this->expectException(InvalidArgumentException::class); + + /* @phpstan-ignore argument.type */ + PromptMessage::fromArray([ + 'role' => 'user', + 'content' => ['type' => 'not-a-real-type'], + ]); + } + + public function testConstructorAcceptsResourceLinkContent(): void + { + $link = new ResourceLink('file:///a.png', 'a.png'); + $message = new PromptMessage(Role::User, $link); + + $this->assertSame($link, $message->content); + } +} diff --git a/tests/Unit/Schema/Content/ResourceLinkTest.php b/tests/Unit/Schema/Content/ResourceLinkTest.php new file mode 100644 index 00000000..3e643774 --- /dev/null +++ b/tests/Unit/Schema/Content/ResourceLinkTest.php @@ -0,0 +1,231 @@ +assertSame('resource_link', $link->type); + $this->assertSame(self::VALID_URI, $link->uri); + $this->assertSame('main.rs', $link->name); + $this->assertNull($link->title); + $this->assertNull($link->description); + $this->assertNull($link->mimeType); + $this->assertNull($link->annotations); + $this->assertNull($link->size); + $this->assertNull($link->icons); + $this->assertNull($link->meta); + } + + public function testConstructorWithAllFields(): void + { + $annotations = new Annotations([Role::User], 0.5); + $icons = [new Icon('https://example.com/icon.png')]; + + $link = new ResourceLink( + uri: self::VALID_URI, + name: 'main.rs', + title: 'Main Source File', + description: 'Primary application entry point', + mimeType: 'text/x-rust', + annotations: $annotations, + size: 1024, + icons: $icons, + meta: ['origin' => 'test'], + ); + + $this->assertSame(self::VALID_URI, $link->uri); + $this->assertSame('main.rs', $link->name); + $this->assertSame('Main Source File', $link->title); + $this->assertSame('Primary application entry point', $link->description); + $this->assertSame('text/x-rust', $link->mimeType); + $this->assertSame($annotations, $link->annotations); + $this->assertSame(1024, $link->size); + $this->assertSame($icons, $link->icons); + $this->assertSame(['origin' => 'test'], $link->meta); + } + + public function testJsonSerializeMinimal(): void + { + $link = new ResourceLink(self::VALID_URI, 'main.rs'); + + $this->assertSame([ + 'type' => 'resource_link', + 'uri' => self::VALID_URI, + 'name' => 'main.rs', + ], $link->jsonSerialize()); + } + + public function testJsonSerializeWithAllFields(): void + { + $annotations = new Annotations([Role::User], 0.5); + $icons = [new Icon('https://example.com/icon.png')]; + + $link = new ResourceLink( + uri: self::VALID_URI, + name: 'main.rs', + title: 'Main Source File', + description: 'Primary application entry point', + mimeType: 'text/x-rust', + annotations: $annotations, + size: 1024, + icons: $icons, + meta: ['origin' => 'test'], + ); + + $data = $link->jsonSerialize(); + + $this->assertSame('resource_link', $data['type']); + $this->assertSame(self::VALID_URI, $data['uri']); + $this->assertSame('main.rs', $data['name']); + $this->assertSame('Main Source File', $data['title']); + $this->assertSame('Primary application entry point', $data['description']); + $this->assertSame('text/x-rust', $data['mimeType']); + $this->assertSame($annotations, $data['annotations']); + $this->assertSame(1024, $data['size']); + $this->assertSame($icons, $data['icons']); + $this->assertSame(['origin' => 'test'], $data['_meta']); + } + + public function testOptionalFieldsOmittedWhenNull(): void + { + $link = new ResourceLink(self::VALID_URI, 'main.rs'); + $data = $link->jsonSerialize(); + + $this->assertArrayNotHasKey('title', $data); + $this->assertArrayNotHasKey('description', $data); + $this->assertArrayNotHasKey('mimeType', $data); + $this->assertArrayNotHasKey('annotations', $data); + $this->assertArrayNotHasKey('size', $data); + $this->assertArrayNotHasKey('icons', $data); + $this->assertArrayNotHasKey('_meta', $data); + } + + public function testFromArrayMinimal(): void + { + $link = ResourceLink::fromArray([ + 'type' => 'resource_link', + 'uri' => self::VALID_URI, + 'name' => 'main.rs', + ]); + + $this->assertSame(self::VALID_URI, $link->uri); + $this->assertSame('main.rs', $link->name); + $this->assertNull($link->title); + $this->assertNull($link->annotations); + $this->assertNull($link->icons); + $this->assertNull($link->meta); + } + + public function testFromArrayWithAllFields(): void + { + $link = ResourceLink::fromArray([ + 'type' => 'resource_link', + 'uri' => self::VALID_URI, + 'name' => 'main.rs', + 'title' => 'Main Source File', + 'description' => 'Primary application entry point', + 'mimeType' => 'text/x-rust', + 'annotations' => ['audience' => ['user'], 'priority' => 0.5], + 'size' => 1024, + 'icons' => [['src' => 'https://example.com/icon.png']], + '_meta' => ['origin' => 'test'], + ]); + + $this->assertSame('Main Source File', $link->title); + $this->assertSame('Primary application entry point', $link->description); + $this->assertSame('text/x-rust', $link->mimeType); + $this->assertInstanceOf(Annotations::class, $link->annotations); + $this->assertSame(1024, $link->size); + $this->assertCount(1, $link->icons); + $this->assertInstanceOf(Icon::class, $link->icons[0]); + $this->assertSame(['origin' => 'test'], $link->meta); + } + + public function testRoundTripThroughJsonSerializeAndFromArray(): void + { + $original = new ResourceLink( + uri: self::VALID_URI, + name: 'main.rs', + title: 'Main Source File', + description: 'Primary application entry point', + mimeType: 'text/x-rust', + annotations: new Annotations([Role::User], 0.5), + size: 1024, + icons: [new Icon('https://example.com/icon.png')], + meta: ['origin' => 'test'], + ); + + $decoded = json_decode(json_encode($original), true); + $rehydrated = ResourceLink::fromArray($decoded); + + $this->assertSame($original->uri, $rehydrated->uri); + $this->assertSame($original->name, $rehydrated->name); + $this->assertSame($original->title, $rehydrated->title); + $this->assertSame($original->description, $rehydrated->description); + $this->assertSame($original->mimeType, $rehydrated->mimeType); + $this->assertSame($original->size, $rehydrated->size); + $this->assertSame($original->meta, $rehydrated->meta); + $this->assertEquals($original->annotations, $rehydrated->annotations); + } + + public function testFromArrayRejectsWrongType(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid type for ResourceLink.'); + + /* @phpstan-ignore argument.type */ + ResourceLink::fromArray([ + 'type' => 'resource', + 'uri' => self::VALID_URI, + 'name' => 'main.rs', + ]); + } + + #[DataProvider('provideInvalidData')] + public function testFromArrayRejectsInvalidData(array $input, string $expectedExceptionMessage): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($expectedExceptionMessage); + + ResourceLink::fromArray($input); + } + + public static function provideInvalidData(): iterable + { + yield 'missing uri' => [ + ['type' => 'resource_link', 'name' => 'main.rs'], + 'Invalid or missing "uri" in ResourceLink data.', + ]; + yield 'missing name' => [ + ['type' => 'resource_link', 'uri' => self::VALID_URI], + 'Invalid or missing "name" in ResourceLink data.', + ]; + yield 'invalid _meta' => [ + ['type' => 'resource_link', 'uri' => self::VALID_URI, 'name' => 'main.rs', '_meta' => 'not-an-array'], + 'Invalid "_meta" in ResourceLink data.', + ]; + } +} diff --git a/tests/Unit/Schema/Result/CallToolResultTest.php b/tests/Unit/Schema/Result/CallToolResultTest.php new file mode 100644 index 00000000..f7c2b6ff --- /dev/null +++ b/tests/Unit/Schema/Result/CallToolResultTest.php @@ -0,0 +1,112 @@ + [ + [ + 'type' => 'resource_link', + 'uri' => 'file:///project/src/main.rs', + 'name' => 'main.rs', + 'mimeType' => 'text/x-rust', + ], + ], + 'isError' => false, + ]); + + $this->assertCount(1, $result->content); + $this->assertInstanceOf(ResourceLink::class, $result->content[0]); + $this->assertSame('file:///project/src/main.rs', $result->content[0]->uri); + $this->assertSame('main.rs', $result->content[0]->name); + $this->assertSame('text/x-rust', $result->content[0]->mimeType); + } + + public function testFromArrayDeserializesMixedContentTypes(): void + { + $result = CallToolResult::fromArray([ + 'content' => [ + ['type' => 'text', 'text' => 'search results'], + ['type' => 'resource_link', 'uri' => 'file:///a.png', 'name' => 'a.png'], + ['type' => 'resource_link', 'uri' => 'file:///b.png', 'name' => 'b.png'], + ], + ]); + + $this->assertCount(3, $result->content); + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertInstanceOf(ResourceLink::class, $result->content[1]); + $this->assertInstanceOf(ResourceLink::class, $result->content[2]); + } + + public function testFromArrayRejectsUnknownContentType(): void + { + $this->expectException(InvalidArgumentException::class); + + CallToolResult::fromArray([ + 'content' => [ + ['type' => 'not-a-real-type'], + ], + ]); + } + + public function testJsonSerializeIncludesResourceLinkContent(): void + { + $result = new CallToolResult([ + new ResourceLink('file:///project/src/main.rs', 'main.rs'), + ]); + + $data = $result->jsonSerialize(); + + $this->assertSame([ + 'type' => 'resource_link', + 'uri' => 'file:///project/src/main.rs', + 'name' => 'main.rs', + ], $data['content'][0]->jsonSerialize()); + } + + public function testRoundTripWithResourceLinkAlongsideOtherContentTypes(): void + { + $original = new CallToolResult([ + new TextContent('25 results found'), + new ResourceLink('file:///a.png', 'a.png', mimeType: 'image/png'), + new ImageContent(base64_encode('binary'), 'image/png'), + new AudioContent(base64_encode('binary'), 'audio/mpeg'), + EmbeddedResource::fromText('file:///readme.txt', 'hello'), + ]); + + $decoded = json_decode(json_encode($original), true); + $rehydrated = CallToolResult::fromArray($decoded); + + $this->assertCount(5, $rehydrated->content); + $this->assertInstanceOf(TextContent::class, $rehydrated->content[0]); + $this->assertInstanceOf(ResourceLink::class, $rehydrated->content[1]); + $this->assertInstanceOf(ImageContent::class, $rehydrated->content[2]); + $this->assertInstanceOf(AudioContent::class, $rehydrated->content[3]); + $this->assertInstanceOf(EmbeddedResource::class, $rehydrated->content[4]); + + $this->assertSame('file:///a.png', $rehydrated->content[1]->uri); + $this->assertSame('a.png', $rehydrated->content[1]->name); + $this->assertSame('image/png', $rehydrated->content[1]->mimeType); + } +}