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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----

Expand Down
13 changes: 12 additions & 1 deletion docs/mcp-elements.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions examples/server/discovery-userprofile/McpElements.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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).
Expand Down
26 changes: 22 additions & 4 deletions src/Capability/Formatter/PromptResultFormatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)) {
Expand All @@ -180,7 +182,7 @@ private function formatContent(mixed $content, ?int $index = null): TextContent|
*
* @param array<string, mixed> $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'];
Expand All @@ -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}."),
};
}
Expand Down Expand Up @@ -264,4 +267,19 @@ private function formatResourceContent(array $content, string $indexStr): Embedd

return new EmbeddedResource($resourceObj);
}

/**
* @param array<string, mixed> $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']);
}
}
11 changes: 11 additions & 0 deletions src/Capability/Registry/ToolReference.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
12 changes: 7 additions & 5 deletions src/Schema/Content/PromptMessage.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <koshnawaza@gmail.com>
Expand All @@ -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');
}
Expand All @@ -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)),
};

Expand All @@ -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
Expand Down
146 changes: 146 additions & 0 deletions src/Schema/Content/ResourceLink.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
<?php

/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Mcp\Schema\Content;

use Mcp\Exception\InvalidArgumentException;
use Mcp\Schema\Annotations;
use Mcp\Schema\Icon;

/**
* Represents a link to a resource, without embedding its contents.
*
* A resource that the server is capable of reading, included in a prompt or
* tool call result. Unlike `EmbeddedResource`, this points to a resource
* without including its full content, allowing clients to fetch it separately.
*
* @phpstan-import-type AnnotationsData from Annotations
* @phpstan-import-type IconData from Icon
*
* @phpstan-type ResourceLinkData array{
* type: 'resource_link',
* uri: string,
* name: string,
* title?: string,
* description?: string,
* mimeType?: string,
* annotations?: AnnotationsData,
* size?: int,
* icons?: IconData[],
* _meta?: array<string, mixed>,
* }
*
* @author Alex Rothberg <cancan101@users.noreply.github.com>
*/
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<string, mixed> $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<string, mixed>,
* }
*/
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;
}
}
2 changes: 2 additions & 0 deletions src/Schema/Result/CallToolResult.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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)),
};
}
Expand Down
Loading