From fcc92a855cf581ab099f6a92a8d6e7622d3b045b Mon Sep 17 00:00:00 2001 From: Dayna Blackwell Date: Sat, 9 May 2026 17:10:15 -0700 Subject: [PATCH] Add title field to Resource and ResourceTemplate The MCP specification (2025-11-25) defines an optional title field for Resource and ResourceTemplate as a human-readable display label, distinct from name (a short identifier). Tool and Prompt already implement this field; Resource and ResourceTemplate were missing it. Changes: - Add title parameter to Resource and ResourceTemplate constructors - Add title to fromArray(), jsonSerialize(), and PHPStan type arrays - Add title to McpResource and McpResourceTemplate attributes - Pass title through Discoverer when constructing schema objects - Add tests for deserialization, serialization, and null omission Fixes #296 --- src/Capability/Attribute/McpResource.php | 14 ++++---- .../Attribute/McpResourceTemplate.php | 8 +++-- src/Capability/Discovery/Discoverer.php | 3 +- src/Schema/Resource.php | 18 ++++++---- src/Schema/ResourceTemplate.php | 18 +++++++--- tests/Unit/Schema/ResourceTemplateTest.php | 35 +++++++++++++++++++ tests/Unit/Schema/ResourceTest.php | 35 +++++++++++++++++++ 7 files changed, 110 insertions(+), 21 deletions(-) diff --git a/src/Capability/Attribute/McpResource.php b/src/Capability/Attribute/McpResource.php index 80f309ca..0f516576 100644 --- a/src/Capability/Attribute/McpResource.php +++ b/src/Capability/Attribute/McpResource.php @@ -24,18 +24,20 @@ class McpResource { /** - * @param string $uri The specific URI identifying this resource instance. Must be unique within the server. - * @param ?string $name A human-readable name for this resource. If null, a default might be generated from the method name. - * @param ?string $description An optional description of the resource. Defaults to class DocBlock summary. + * @param string $uri the specific URI identifying this resource instance + * @param ?string $name a short identifier for this resource; defaults to the method name + * @param ?string $title optional human-readable title for display in UI + * @param ?string $description optional description; defaults to class DocBlock summary * @param ?string $mimeType the MIME type, if known and constant for this resource * @param ?int $size the size in bytes, if known and constant - * @param Annotations|null $annotations optional annotations describing the resource - * @param ?Icon[] $icons Optional list of icon URLs representing the resource - * @param ?array $meta Optional metadata + * @param ?Annotations $annotations optional annotations describing the resource + * @param ?Icon[] $icons optional icons representing the resource + * @param ?array $meta optional metadata */ public function __construct( public string $uri, public ?string $name = null, + public ?string $title = null, public ?string $description = null, public ?string $mimeType = null, public ?int $size = null, diff --git a/src/Capability/Attribute/McpResourceTemplate.php b/src/Capability/Attribute/McpResourceTemplate.php index 0269f93a..6a2044d8 100644 --- a/src/Capability/Attribute/McpResourceTemplate.php +++ b/src/Capability/Attribute/McpResourceTemplate.php @@ -24,15 +24,17 @@ class McpResourceTemplate { /** * @param string $uriTemplate the URI template string (RFC 6570) - * @param ?string $name A human-readable name for the template type. If null, a default might be generated from the method name. - * @param ?string $description Optional description. Defaults to class DocBlock summary. + * @param ?string $name a short identifier for the template type; defaults to the method name + * @param ?string $title optional human-readable title for display in UI + * @param ?string $description optional description; defaults to class DocBlock summary * @param ?string $mimeType optional default MIME type for matching resources * @param ?Annotations $annotations optional annotations describing the resource template - * @param ?array $meta Optional metadata + * @param ?array $meta optional metadata */ public function __construct( public string $uriTemplate, public ?string $name = null, + public ?string $title = null, public ?string $description = null, public ?string $mimeType = null, public ?Annotations $annotations = null, diff --git a/src/Capability/Discovery/Discoverer.php b/src/Capability/Discovery/Discoverer.php index b3216087..8a7d093b 100644 --- a/src/Capability/Discovery/Discoverer.php +++ b/src/Capability/Discovery/Discoverer.php @@ -254,6 +254,7 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun $resource = new Resource( $instance->uri, $name, + $instance->title, $description, $instance->mimeType, $instance->annotations, @@ -293,7 +294,7 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun $mimeType = $instance->mimeType; $annotations = $instance->annotations; $meta = $instance->meta ?? null; - $resourceTemplate = new ResourceTemplate($instance->uriTemplate, $name, $description, $mimeType, $annotations, $meta); + $resourceTemplate = new ResourceTemplate($instance->uriTemplate, $name, $instance->title, $description, $mimeType, $annotations, $meta); $completionProviders = $this->getCompletionProviders($method); $resourceTemplates[$instance->uriTemplate] = new ResourceTemplateReference($resourceTemplate, [$className, $methodName], false, $completionProviders); ++$discoveredCount['resourceTemplates']; diff --git a/src/Schema/Resource.php b/src/Schema/Resource.php index 28b8ae5e..00fc267a 100644 --- a/src/Schema/Resource.php +++ b/src/Schema/Resource.php @@ -22,6 +22,7 @@ * @phpstan-type ResourceData array{ * uri: string, * name: string, + * title?: string, * description?: string, * mimeType?: string, * annotations?: AnnotationsData, @@ -47,19 +48,19 @@ class Resource implements \JsonSerializable /** * @param string $uri the URI of this resource - * @param string $name A human-readable name for this resource. This can be used by clients to populate UI elements. - * @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. It can be thought of like a "hint" to the model. + * @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 (i.e., before base64 encoding or any tokenization), if known. + * @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 - * - * This can be used by Hosts to display file sizes and estimate context window usage + * @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, @@ -94,6 +95,7 @@ public static function fromArray(array $data): self 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, @@ -107,6 +109,7 @@ public static function fromArray(array $data): self * @return array{ * uri: string, * name: string, + * title?: string, * description?: string, * mimeType?: string, * annotations?: Annotations, @@ -121,6 +124,9 @@ public function jsonSerialize(): array 'uri' => $this->uri, 'name' => $this->name, ]; + if (null !== $this->title) { + $data['title'] = $this->title; + } if (null !== $this->description) { $data['description'] = $this->description; } diff --git a/src/Schema/ResourceTemplate.php b/src/Schema/ResourceTemplate.php index a19d0c76..46f173ca 100644 --- a/src/Schema/ResourceTemplate.php +++ b/src/Schema/ResourceTemplate.php @@ -21,6 +21,7 @@ * @phpstan-type ResourceTemplateData array{ * uriTemplate: string, * name: string, + * title?: string, * description?: string|null, * mimeType?: string|null, * annotations?: AnnotationsData|null, @@ -44,15 +45,17 @@ class ResourceTemplate implements \JsonSerializable /** * @param string $uriTemplate a URI template (according to RFC 6570) that can be used to construct resource URIs - * @param string $name A human-readable name for the type of resource this template refers to. This can be used by clients to populate UI elements. - * @param string|null $description This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. - * @param string|null $mimeType The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type. - * @param Annotations|null $annotations optional annotations for the client - * @param ?array $meta Optional metadata + * @param string $name a short identifier for this resource template type + * @param ?string $title optional human-readable title for display in UI + * @param ?string $description a description to help the LLM understand available resources + * @param ?string $mimeType the MIME type for all resources that match this template, if uniform + * @param ?Annotations $annotations optional annotations for the client + * @param ?array $meta optional metadata */ public function __construct( public readonly string $uriTemplate, public readonly string $name, + public readonly ?string $title = null, public readonly ?string $description = null, public readonly ?string $mimeType = null, public readonly ?Annotations $annotations = null, @@ -85,6 +88,7 @@ public static function fromArray(array $data): self return new self( uriTemplate: $data['uriTemplate'], 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, @@ -96,6 +100,7 @@ public static function fromArray(array $data): self * @return array{ * uriTemplate: string, * name: string, + * title?: string, * description?: string, * mimeType?: string, * annotations?: Annotations, @@ -108,6 +113,9 @@ public function jsonSerialize(): array 'uriTemplate' => $this->uriTemplate, 'name' => $this->name, ]; + if (null !== $this->title) { + $data['title'] = $this->title; + } if (null !== $this->description) { $data['description'] = $this->description; } diff --git a/tests/Unit/Schema/ResourceTemplateTest.php b/tests/Unit/Schema/ResourceTemplateTest.php index 54feaf45..3e2908dc 100644 --- a/tests/Unit/Schema/ResourceTemplateTest.php +++ b/tests/Unit/Schema/ResourceTemplateTest.php @@ -75,10 +75,45 @@ public function testFromArrayValid(): void $this->assertInstanceOf(ResourceTemplate::class, $resource); $this->assertSame(self::VALID_URI, $resource->uriTemplate); $this->assertSame('list-books', $resource->name); + $this->assertNull($resource->title); $this->assertNull($resource->description); $this->assertNull($resource->meta); } + public function testTitleFromArray(): void + { + $resource = ResourceTemplate::fromArray([ + 'uriTemplate' => self::VALID_URI, + 'name' => 'list-books', + 'title' => 'Book Listing', + ]); + + $this->assertSame('Book Listing', $resource->title); + } + + public function testTitleSerialization(): void + { + $resource = new ResourceTemplate( + uriTemplate: self::VALID_URI, + name: 'list-books', + title: 'Book Listing', + ); + + $data = $resource->jsonSerialize(); + $this->assertSame('Book Listing', $data['title']); + } + + public function testTitleOmittedWhenNull(): void + { + $resource = new ResourceTemplate( + uriTemplate: self::VALID_URI, + name: 'list-books', + ); + + $data = $resource->jsonSerialize(); + $this->assertArrayNotHasKey('title', $data); + } + #[DataProvider('provideInvalidResources')] public function testFromArrayInvalid(array $input, string $expectedExceptionMessage): void { diff --git a/tests/Unit/Schema/ResourceTest.php b/tests/Unit/Schema/ResourceTest.php index c6c8562b..7a2d9a1d 100644 --- a/tests/Unit/Schema/ResourceTest.php +++ b/tests/Unit/Schema/ResourceTest.php @@ -77,10 +77,45 @@ public function testFromArrayValid(): void $this->assertInstanceOf(Resource::class, $resource); $this->assertSame(self::VALID_URI, $resource->uri); $this->assertSame('list-books', $resource->name); + $this->assertNull($resource->title); $this->assertNull($resource->description); $this->assertNull($resource->meta); } + public function testTitleFromArray(): void + { + $resource = Resource::fromArray([ + 'uri' => self::VALID_URI, + 'name' => 'list-books', + 'title' => 'Book Listing', + ]); + + $this->assertSame('Book Listing', $resource->title); + } + + public function testTitleSerialization(): void + { + $resource = new Resource( + uri: self::VALID_URI, + name: 'list-books', + title: 'Book Listing', + ); + + $data = $resource->jsonSerialize(); + $this->assertSame('Book Listing', $data['title']); + } + + public function testTitleOmittedWhenNull(): void + { + $resource = new Resource( + uri: self::VALID_URI, + name: 'list-books', + ); + + $data = $resource->jsonSerialize(); + $this->assertArrayNotHasKey('title', $data); + } + #[DataProvider('provideInvalidResources')] public function testFromArrayInvalid(array $input, string $expectedExceptionMessage): void {