* Add resource_link content block The spec's `resource_link` content type (protocol revision 2025-06-18, still present in 2025-11-25) had no corresponding schema class: it lets a tool result or prompt message reference a resource by URI/name without embedding its full contents, the way `EmbeddedResource` does. Without it, tools returning many or large resource references (e.g. search results over a DAM) are stuck smuggling URIs through text/structured content. Add `Mcp\Schema\Content\ResourceLink`, mirroring the spec's `Resource` shape (uri, name, title, description, mimeType, annotations, size, icons, _meta) with a fixed `type: "resource_link"`, following the conventions of the sibling `EmbeddedResource`/`ResourceDefinition` classes. Register it everywhere `resource` content blocks are (de)serialized by type per the spec's `ContentBlock` union: - `CallToolResult::fromArray()` - `PromptMessage` (constructor type, `fromArray()`, phpstan types) - `PromptResultFormatter` (typed-array and direct-instance paths) `SamplingMessage`/`CreateSamplingMessageResult` are intentionally left unchanged: the spec's `SamplingMessageContentBlock` does not include `ResourceLink`. Fixes #399 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add resource_link example coverage to discovery-userprofile Add a lookup_user tool to the discovery-userprofile example: given a userId, it returns a short text summary plus a resource_link pointing at the existing user://{userId}/profile resource template, instead of embedding the profile. This mirrors the PR's motivating use case (a tool referencing many/large resources by URI rather than inlining them) and exercises resource_link through the example's own inspector snapshot tests (tools_call-lookup_user[_not_found], tools_list). Wiring this up surfaced a real bug: ToolReference::extractStructuredContent() passed any raw array return value straight through as structuredContent, even when it held Content instances (e.g. the TextContent/ResourceLink pair above). That serializes to a JSON list, which fails the spec's requirement that structuredContent be an object - the inspector's MCP client rejected the tool call outright until fixed. Skip structuredContent extraction whenever the raw result contains a Content item, since those are already represented in `content` and were never meant to double as structured data. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Alex Rothberg <51059+cancan101@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
193 lines
6.4 KiB
PHP
193 lines
6.4 KiB
PHP
<?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\Example\Server\DiscoveryUserProfile;
|
|
|
|
use Mcp\Capability\Attribute\CompletionProvider;
|
|
use Mcp\Capability\Attribute\McpPrompt;
|
|
use Mcp\Capability\Attribute\McpResource;
|
|
use Mcp\Capability\Attribute\McpResourceTemplate;
|
|
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;
|
|
|
|
/**
|
|
* @phpstan-type User array{name: string, email: string, role: string}
|
|
*/
|
|
final class McpElements
|
|
{
|
|
/**
|
|
* Simulate a simple user database.
|
|
*
|
|
* @var array<int, User>
|
|
*/
|
|
private array $users = [
|
|
'101' => ['name' => 'Alice', 'email' => 'alice@example.com', 'role' => 'admin'],
|
|
'102' => ['name' => 'Bob', 'email' => 'bob@example.com', 'role' => 'user'],
|
|
'103' => ['name' => 'Charlie', 'email' => 'charlie@example.com', 'role' => 'user'],
|
|
];
|
|
|
|
public function __construct(
|
|
private readonly LoggerInterface $logger,
|
|
) {
|
|
$this->logger->debug('DiscoveryUserProfile McpElements instantiated.');
|
|
}
|
|
|
|
/**
|
|
* Retrieves the profile data for a specific user.
|
|
*
|
|
* @param string $userId the ID of the user (from URI)
|
|
*
|
|
* @return User user profile data
|
|
*
|
|
* @throws ResourceReadException if the user is not found
|
|
*/
|
|
#[McpResourceTemplate(
|
|
uriTemplate: 'user://{userId}/profile',
|
|
name: 'user_profile',
|
|
description: 'Get profile information for a specific user ID.',
|
|
mimeType: 'application/json'
|
|
)]
|
|
public function getUserProfile(
|
|
#[CompletionProvider(values: ['101', '102', '103'])]
|
|
string $userId,
|
|
): array {
|
|
$this->logger->info('Reading resource: user profile', ['userId' => $userId]);
|
|
if (!isset($this->users[$userId])) {
|
|
throw new ResourceReadException("User not found for ID: {$userId}");
|
|
}
|
|
|
|
return $this->users[$userId];
|
|
}
|
|
|
|
/**
|
|
* Retrieves a list of all known user IDs.
|
|
*
|
|
* @return int[] list of user IDs
|
|
*/
|
|
#[McpResource(
|
|
uri: 'user://list/ids',
|
|
name: 'user_id_list',
|
|
description: 'Provides a list of all available user IDs.',
|
|
mimeType: 'application/json'
|
|
)]
|
|
public function listUserIds(): array
|
|
{
|
|
$this->logger->info('Reading resource: user ID list');
|
|
|
|
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).
|
|
*
|
|
* @param string $userId the ID of the user to message
|
|
* @param string|null $customMessage an optional custom message part
|
|
*
|
|
* @return array<string, bool|string> status of the operation
|
|
*/
|
|
#[McpTool(name: 'send_welcome')]
|
|
public function sendWelcomeMessage(string $userId, ?string $customMessage = null): array
|
|
{
|
|
$this->logger->info('Executing tool: send_welcome', ['userId' => $userId]);
|
|
if (!isset($this->users[$userId])) {
|
|
return ['success' => false, 'error' => "User ID {$userId} not found."];
|
|
}
|
|
$user = $this->users[$userId];
|
|
$message = "Welcome, {$user['name']}!";
|
|
if ($customMessage) {
|
|
$message .= ' '.$customMessage;
|
|
}
|
|
// Simulate sending
|
|
$this->logger->info("Simulated sending message to {$user['email']}: {$message}");
|
|
|
|
return ['success' => true, 'message_sent' => $message];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, bool|string>
|
|
*/
|
|
#[McpTool(name: 'test_tool_without_params')]
|
|
public function testToolWithoutParams(): array
|
|
{
|
|
return ['success' => true, 'message' => 'Test tool without params'];
|
|
}
|
|
|
|
/**
|
|
* Generates a prompt to write a bio for a user.
|
|
*
|
|
* @param string $userId the user ID to generate the bio for
|
|
* @param string $tone Desired tone (e.g., 'formal', 'casual').
|
|
*
|
|
* @return array<string, string>[] prompt messages
|
|
*
|
|
* @throws PromptGetException if user not found
|
|
*/
|
|
#[McpPrompt(name: 'generate_bio_prompt')]
|
|
public function generateBio(
|
|
#[CompletionProvider(provider: UserIdCompletionProvider::class)]
|
|
string $userId,
|
|
string $tone = 'professional',
|
|
): array {
|
|
$this->logger->info('Executing prompt: generate_bio', ['userId' => $userId, 'tone' => $tone]);
|
|
if (!isset($this->users[$userId])) {
|
|
throw new PromptGetException("User not found for bio prompt: {$userId}");
|
|
}
|
|
$user = $this->users[$userId];
|
|
|
|
return [
|
|
['role' => 'user', 'content' => "Write a short, {$tone} biography for {$user['name']} (Role: {$user['role']}, Email: {$user['email']}). Highlight their role within the system."],
|
|
];
|
|
}
|
|
}
|