From 58a0725e943c5fae05aa38f71f3cd656bcc487a3 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 8 Nov 2025 00:37:31 +0100 Subject: [PATCH] Add support for icons and website url --- examples/schema-showcase/server.php | 9 +- src/Schema/Enum/ProtocolVersion.php | 5 +- src/Schema/Icon.php | 91 +++++++++++++++++++ src/Schema/Implementation.php | 37 +++++++- src/Schema/Prompt.php | 13 ++- src/Schema/Resource.php | 27 ++++-- src/Schema/Tool.php | 13 ++- src/Server/Builder.php | 16 +++- tests/Inspector/InspectorSnapshotTestCase.php | 2 +- tests/Unit/Schema/IconTest.php | 88 ++++++++++++++++++ 10 files changed, 279 insertions(+), 22 deletions(-) create mode 100644 src/Schema/Icon.php create mode 100644 tests/Unit/Schema/IconTest.php diff --git a/examples/schema-showcase/server.php b/examples/schema-showcase/server.php index b908cebd..86c2bf77 100644 --- a/examples/schema-showcase/server.php +++ b/examples/schema-showcase/server.php @@ -13,11 +13,18 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); +use Mcp\Schema\Icon; use Mcp\Server; use Mcp\Server\Session\FileSessionStore; $server = Server::builder() - ->setServerInfo('Schema Showcase', '1.0.0') + ->setServerInfo( + 'Schema Showcase', + '1.0.0', + 'A showcase server demonstrating MCP schema capabilities.', + [new Icon('https://www.php.net/images/logos/php-logo-white.svg', 'image/svg+xml', ['any'])], + 'https://github.com/modelcontextprotocol/php-sdk', + ) ->setContainer(container()) ->setLogger(logger()) ->setSession(new FileSessionStore(__DIR__.'/sessions')) diff --git a/src/Schema/Enum/ProtocolVersion.php b/src/Schema/Enum/ProtocolVersion.php index d2d44436..b580e709 100644 --- a/src/Schema/Enum/ProtocolVersion.php +++ b/src/Schema/Enum/ProtocolVersion.php @@ -13,12 +13,13 @@ /** * Available protocol versions for MCP. + * + * @author Illia Vasylevskyi */ enum ProtocolVersion: string { case V2024_11_05 = '2024-11-05'; - case V2025_03_26 = '2025-03-26'; - case V2025_06_18 = '2025-06-18'; + case V2025_11_25 = '2025-11-25'; } diff --git a/src/Schema/Icon.php b/src/Schema/Icon.php new file mode 100644 index 00000000..b3cdd722 --- /dev/null +++ b/src/Schema/Icon.php @@ -0,0 +1,91 @@ + + */ +class Icon implements \JsonSerializable +{ + /** + * @param string $src a standard URI pointing to an icon resource + * @param ?string $mimeType optional override if the server's MIME type is missing or generic + * @param ?string[] $sizes optional array of strings that specify sizes at which the icon can be used. + * Each string should be in WxH format (e.g., `"48x48"`, `"96x96"`) or `"any"` for + * scalable formats like SVG. + */ + public function __construct( + public readonly string $src, + public readonly ?string $mimeType = null, + public readonly ?array $sizes = null, + ) { + if (empty($src)) { + throw new InvalidArgumentException('Icon "src" must be a non-empty string.'); + } + if (!preg_match('#^(https?://|data:)#', $src)) { + throw new InvalidArgumentException('Icon "src" must be a valid URL or data URI.'); + } + + if (null !== $sizes) { + foreach ($sizes as $size) { + if (!\is_string($size)) { + throw new InvalidArgumentException('Each size in "sizes" must be a string.'); + } + if (!preg_match('/^(any|\d+x\d+)$/', $size)) { + throw new InvalidArgumentException(\sprintf('Invalid size format "%s" in "sizes". Expected "WxH" or "any".', $size)); + } + } + } + } + + /** + * @param IconData $data + */ + public static function fromArray(array $data): self + { + if (empty($data['src']) || !\is_string($data['src'])) { + throw new InvalidArgumentException('Invalid or missing "src" in Icon data.'); + } + + return new self($data['src'], $data['mimeTypes'] ?? null, $data['sizes'] ?? null); + } + + /** + * @return IconData + */ + public function jsonSerialize(): array + { + $data = [ + 'src' => $this->src, + ]; + + if (null !== $this->mimeType) { + $data['mimeType'] = $this->mimeType; + } + + if (null !== $this->sizes) { + $data['sizes'] = $this->sizes; + } + + return $data; + } +} diff --git a/src/Schema/Implementation.php b/src/Schema/Implementation.php index 6fc51242..22d58a6d 100644 --- a/src/Schema/Implementation.php +++ b/src/Schema/Implementation.php @@ -16,14 +16,21 @@ /** * Describes the name and version of an MCP implementation. * + * @phpstan-import-type IconData from Icon + * * @author Kyrian Obikwelu */ class Implementation implements \JsonSerializable { + /** + * @param ?Icon[] $icons + */ public function __construct( public readonly string $name = 'app', public readonly string $version = 'dev', public readonly ?string $description = null, + public readonly ?array $icons = null, + public readonly ?string $websiteUrl = null, ) { } @@ -31,6 +38,9 @@ public function __construct( * @param array{ * name: string, * version: string, + * description?: string, + * icons?: IconData[], + * websiteUrl?: string, * } $data */ public static function fromArray(array $data): self @@ -42,13 +52,30 @@ public static function fromArray(array $data): self throw new InvalidArgumentException('Invalid or missing "version" in Implementation data.'); } - return new self($data['name'], $data['version'], $data['description'] ?? null); + if (isset($data['icons'])) { + if (!\is_array($data['icons'])) { + throw new InvalidArgumentException('Invalid "icons" in Implementation data; expected an array.'); + } + + $data['icons'] = array_map(Icon::fromArray(...), $data['icons']); + } + + return new self( + $data['name'], + $data['version'], + $data['description'] ?? null, + $data['icons'] ?? null, + $data['websiteUrl'] ?? null, + ); } /** * @return array{ * name: string, * version: string, + * description?: string, + * icons?: Icon[], + * websiteUrl?: string, * } */ public function jsonSerialize(): array @@ -62,6 +89,14 @@ public function jsonSerialize(): array $data['description'] = $this->description; } + if (null !== $this->icons) { + $data['icons'] = $this->icons; + } + + if (null !== $this->websiteUrl) { + $data['websiteUrl'] = $this->websiteUrl; + } + return $data; } } diff --git a/src/Schema/Prompt.php b/src/Schema/Prompt.php index c61efcd1..0fe41586 100644 --- a/src/Schema/Prompt.php +++ b/src/Schema/Prompt.php @@ -17,11 +17,13 @@ * A prompt or prompt template that the server offers. * * @phpstan-import-type PromptArgumentData from PromptArgument + * @phpstan-import-type IconData from Icon * * @phpstan-type PromptData array{ * name: string, * description?: string, * arguments?: PromptArgumentData[], + * icons?: IconData[], * _meta?: array * } * @@ -31,14 +33,16 @@ class Prompt implements \JsonSerializable { /** * @param string $name the name of the prompt or prompt template - * @param string|null $description an optional description of what this prompt provides - * @param PromptArgument[]|null $arguments A list of arguments for templating. Null if not a template. + * @param ?string $description an optional description of what this prompt provides + * @param ?PromptArgument[] $arguments A list of arguments for templating. Null if not a template. + * @param ?Icon[] $icons optional icons representing the prompt * @param ?array $meta Optional metadata */ public function __construct( public readonly string $name, public readonly ?string $description = null, public readonly ?array $arguments = null, + public readonly ?array $icons = null, public readonly ?array $meta = null, ) { if (null !== $this->arguments) { @@ -71,6 +75,7 @@ public static function fromArray(array $data): self name: $data['name'], description: $data['description'] ?? null, arguments: $arguments, + icons: isset($data['icons']) && \is_array($data['icons']) ? array_map(Icon::fromArray(...), $data['icons']) : null, meta: isset($data['_meta']) ? $data['_meta'] : null ); } @@ -80,6 +85,7 @@ public static function fromArray(array $data): self * name: string, * description?: string, * arguments?: array, + * icons?: Icon[], * _meta?: array * } */ @@ -92,6 +98,9 @@ public function jsonSerialize(): array if (null !== $this->arguments) { $data['arguments'] = $this->arguments; } + if (null !== $this->icons) { + $data['icons'] = $this->icons; + } if (null !== $this->meta) { $data['_meta'] = $this->meta; } diff --git a/src/Schema/Resource.php b/src/Schema/Resource.php index 36ac5938..ac33ed4d 100644 --- a/src/Schema/Resource.php +++ b/src/Schema/Resource.php @@ -17,15 +17,17 @@ * A known resource that the server is capable of reading. * * @phpstan-import-type AnnotationsData from Annotations + * @phpstan-import-type IconData from Icon * * @phpstan-type ResourceData array{ * uri: string, * name: string, - * description?: string|null, - * mimeType?: string|null, - * annotations?: AnnotationsData|null, - * size?: int|null, - * _meta?: array + * description?: string, + * mimeType?: string, + * annotations?: AnnotationsData, + * size?: int, + * icons?: IconData[], + * _meta?: array, * } * * @author Kyrian Obikwelu @@ -46,10 +48,11 @@ 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|null $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|null $mimeType the MIME type of this resource, if known - * @param Annotations|null $annotations optional annotations for the client - * @param int|null $size The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known. + * @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 $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 ?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 @@ -61,6 +64,7 @@ public function __construct( 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, ) { if (!preg_match(self::RESOURCE_NAME_PATTERN, $name)) { @@ -94,6 +98,7 @@ public static function fromArray(array $data): self 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: isset($data['_meta']) ? $data['_meta'] : null ); } @@ -106,6 +111,7 @@ public static function fromArray(array $data): self * mimeType?: string, * annotations?: Annotations, * size?: int, + * icons?: Icon[], * _meta?: array * } */ @@ -127,6 +133,9 @@ public function jsonSerialize(): array if (null !== $this->size) { $data['size'] = $this->size; } + if (null !== $this->icons) { + $data['icons'] = $this->icons; + } if (null !== $this->meta) { $data['_meta'] = $this->meta; } diff --git a/src/Schema/Tool.php b/src/Schema/Tool.php index c3613074..3a4e8193 100644 --- a/src/Schema/Tool.php +++ b/src/Schema/Tool.php @@ -17,6 +17,7 @@ * Definition for a tool the client can call. * * @phpstan-import-type ToolAnnotationsData from ToolAnnotations + * @phpstan-import-type IconData from Icon * * @phpstan-type ToolInputSchema array{ * type: 'object', @@ -28,6 +29,7 @@ * inputSchema: ToolInputSchema, * description?: string|null, * annotations?: ToolAnnotationsData, + * icons?: IconData[], * _meta?: array * } * @@ -37,11 +39,12 @@ class Tool implements \JsonSerializable { /** * @param string $name the name of the tool - * @param string|null $description A human-readable description of the tool. + * @param ?string $description A human-readable description of the tool. * This can be used by clients to improve the LLM's understanding of * available tools. It can be thought of like a "hint" to the model. * @param ToolInputSchema $inputSchema a JSON Schema object (as a PHP array) defining the expected 'arguments' for the tool - * @param ToolAnnotations|null $annotations optional additional tool information + * @param ?ToolAnnotations $annotations optional additional tool information + * @param ?Icon[] $icons optional icons representing the tool * @param ?array $meta Optional metadata */ public function __construct( @@ -49,6 +52,7 @@ public function __construct( public readonly array $inputSchema, public readonly ?string $description, public readonly ?ToolAnnotations $annotations, + public readonly ?array $icons = null, public readonly ?array $meta = null, ) { if (!isset($inputSchema['type']) || 'object' !== $inputSchema['type']) { @@ -79,6 +83,7 @@ public static function fromArray(array $data): self $data['inputSchema'], isset($data['description']) && \is_string($data['description']) ? $data['description'] : null, isset($data['annotations']) && \is_array($data['annotations']) ? ToolAnnotations::fromArray($data['annotations']) : null, + isset($data['icons']) && \is_array($data['icons']) ? array_map(Icon::fromArray(...), $data['icons']) : null, isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null ); } @@ -89,6 +94,7 @@ public static function fromArray(array $data): self * inputSchema: ToolInputSchema, * description?: string, * annotations?: ToolAnnotations, + * icons?: Icon[], * _meta?: array * } */ @@ -104,6 +110,9 @@ public function jsonSerialize(): array if (null !== $this->annotations) { $data['annotations'] = $this->annotations; } + if (null !== $this->icons) { + $data['icons'] = $this->icons; + } if (null !== $this->meta) { $data['_meta'] = $this->meta; } diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 4c633d0a..9cc92f6a 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -21,6 +21,7 @@ use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\Annotations; use Mcp\Schema\Enum\ProtocolVersion; +use Mcp\Schema\Icon; use Mcp\Schema\Implementation; use Mcp\Schema\ServerCapabilities; use Mcp\Schema\ToolAnnotations; @@ -145,10 +146,17 @@ final class Builder /** * Sets the server's identity. Required. + * + * @param ?Icon[] $icons */ - public function setServerInfo(string $name, string $version, ?string $description = null): self - { - $this->serverInfo = new Implementation(trim($name), trim($version), $description); + public function setServerInfo( + string $name, + string $version, + ?string $description = null, + ?array $icons = null, + ?string $websiteUrl = null, + ): self { + $this->serverInfo = new Implementation(trim($name), trim($version), $description, $icons, $websiteUrl); return $this; } @@ -295,7 +303,7 @@ public function setDiscovery( return $this; } - public function setProtocolVersion(?ProtocolVersion $protocolVersion): self + public function setProtocolVersion(ProtocolVersion $protocolVersion): self { $this->protocolVersion = $protocolVersion; diff --git a/tests/Inspector/InspectorSnapshotTestCase.php b/tests/Inspector/InspectorSnapshotTestCase.php index c8335308..b28a9c1b 100644 --- a/tests/Inspector/InspectorSnapshotTestCase.php +++ b/tests/Inspector/InspectorSnapshotTestCase.php @@ -18,7 +18,7 @@ abstract class InspectorSnapshotTestCase extends TestCase { - private const INSPECTOR_VERSION = '0.16.8'; + private const INSPECTOR_VERSION = '0.17.2'; /** @param array $options */ #[DataProvider('provideMethods')] diff --git a/tests/Unit/Schema/IconTest.php b/tests/Unit/Schema/IconTest.php new file mode 100644 index 00000000..f3906176 --- /dev/null +++ b/tests/Unit/Schema/IconTest.php @@ -0,0 +1,88 @@ +assertSame('https://www.php.net/images/logos/php-logo-white.svg', $icon->src); + $this->assertSame('image/svg+xml', $icon->mimeType); + $this->assertSame('any', $icon->sizes[0]); + } + + public function testConstructorWithMultipleSizes() + { + $icon = new Icon('https://example.com/icon.png', 'image/png', ['48x48', '96x96']); + + $this->assertCount(2, $icon->sizes); + $this->assertSame(['48x48', '96x96'], $icon->sizes); + } + + public function testConstructorWithAnySizes() + { + $icon = new Icon('https://example.com/icon.svg', 'image/png', ['any']); + + $this->assertSame(['any'], $icon->sizes); + } + + public function testConstructorWithNullOptionalFields() + { + $icon = new Icon('https://example.com/icon.png'); + + $this->assertSame('https://example.com/icon.png', $icon->src); + $this->assertNull($icon->mimeType); + $this->assertNull($icon->sizes); + } + + public function testInvalidSizesFormatThrowsException() + { + $this->expectException(InvalidArgumentException::class); + + new Icon('https://example.com/icon.png', 'image/png', ['invalid-size']); + } + + public function testInvalidPixelSizesFormatThrowsException() + { + $this->expectException(InvalidArgumentException::class); + + new Icon('https://example.com/icon.png', 'image/png', ['180x48x48']); + } + + public function testEmptySrcThrowsException() + { + $this->expectException(InvalidArgumentException::class); + + new Icon('', 'image/png', ['48x48']); + } + + public function testInvalidSrcThrowsException() + { + $this->expectException(InvalidArgumentException::class); + + new Icon('not-a-url', 'image/png', ['48x48']); + } + + public function testValidDataUriSrc() + { + $dataUri = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA'; + $icon = new Icon($dataUri, 'image/png', ['48x48']); + + $this->assertSame($dataUri, $icon->src); + } +}