diff --git a/examples/schema-showcase/server.php b/examples/schema-showcase/server.php index b908ceb..86c2bf7 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 d2d4443..b580e70 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 0000000..b3cdd72 --- /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 6fc5124..22d58a6 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 96cffcd..0a2723e 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[], * } * * @author Kyrian Obikwelu @@ -29,14 +31,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 $name the name of the prompt or prompt 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 */ public function __construct( public readonly string $name, public readonly ?string $description = null, public readonly ?array $arguments = null, + public readonly ?array $icons = null, ) { if (null !== $this->arguments) { foreach ($this->arguments as $arg) { @@ -63,7 +67,8 @@ public static function fromArray(array $data): self return new self( name: $data['name'], description: $data['description'] ?? null, - arguments: $arguments + arguments: $arguments, + icons: isset($data['icons']) && \is_array($data['icons']) ? array_map(Icon::fromArray(...), $data['icons']) : null, ); } @@ -71,7 +76,8 @@ public static function fromArray(array $data): self * @return array{ * name: string, * description?: string, - * arguments?: array + * arguments?: array, + * icons?: Icon[], * } */ public function jsonSerialize(): array @@ -83,6 +89,9 @@ public function jsonSerialize(): array if (null !== $this->arguments) { $data['arguments'] = $this->arguments; } + if (null !== $this->icons) { + $data['icons'] = $this->icons; + } return $data; } diff --git a/src/Schema/Resource.php b/src/Schema/Resource.php index ca6fa11..8a80bf6 100644 --- a/src/Schema/Resource.php +++ b/src/Schema/Resource.php @@ -17,14 +17,15 @@ * 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, + * description?: string, + * mimeType?: string, + * annotations?: AnnotationsData, + * size?: int, * } * * @author Kyrian Obikwelu @@ -43,14 +44,15 @@ class Resource implements \JsonSerializable private const URI_PATTERN = '/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s]*$/'; /** - * @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 $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 $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 * - * This can be used by Hosts to display file sizes and estimate context window usage. + * This can be used by Hosts to display file sizes and estimate context window usage */ public function __construct( public readonly string $uri, @@ -59,6 +61,7 @@ public function __construct( public readonly ?string $mimeType = null, public readonly ?Annotations $annotations = null, public readonly ?int $size = null, + public readonly ?array $icons = null, ) { if (!preg_match(self::RESOURCE_NAME_PATTERN, $name)) { throw new InvalidArgumentException('Invalid resource name: must contain only alphanumeric characters, underscores, and hyphens.'); @@ -86,7 +89,8 @@ public static function fromArray(array $data): self 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 + size: isset($data['size']) ? (int) $data['size'] : null, + icons: isset($data['icons']) && \is_array($data['icons']) ? array_map(Icon::fromArray(...), $data['icons']) : null, ); } @@ -98,6 +102,7 @@ public static function fromArray(array $data): self * mimeType?: string, * annotations?: Annotations, * size?: int, + * icons?: Icon[], * } */ public function jsonSerialize(): array @@ -118,6 +123,9 @@ public function jsonSerialize(): array if (null !== $this->size) { $data['size'] = $this->size; } + if (null !== $this->icons) { + $data['icons'] = $this->icons; + } return $data; } diff --git a/src/Schema/Tool.php b/src/Schema/Tool.php index 29646ef..e834d92 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[], * } * * @author Kyrian Obikwelu @@ -35,18 +37,20 @@ class Tool implements \JsonSerializable { /** - * @param string $name the name of the tool - * @param string|null $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 string $name the name 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 $annotations optional additional tool information + * @param ?Icon[] $icons optional icons representing the tool */ public function __construct( public readonly string $name, public readonly array $inputSchema, public readonly ?string $description, public readonly ?ToolAnnotations $annotations, + public readonly ?array $icons = null, ) { if (!isset($inputSchema['type']) || 'object' !== $inputSchema['type']) { throw new InvalidArgumentException('Tool inputSchema must be a JSON Schema of type "object".'); @@ -75,7 +79,8 @@ public static function fromArray(array $data): self $data['name'], $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['annotations']) && \is_array($data['annotations']) ? ToolAnnotations::fromArray($data['annotations']) : null, + isset($data['icons']) && \is_array($data['icons']) ? array_map(Icon::fromArray(...), $data['icons']) : null, ); } @@ -85,6 +90,7 @@ public static function fromArray(array $data): self * inputSchema: ToolInputSchema, * description?: string, * annotations?: ToolAnnotations, + * icons?: Icon[], * } */ public function jsonSerialize(): array @@ -99,6 +105,9 @@ public function jsonSerialize(): array if (null !== $this->annotations) { $data['annotations'] = $this->annotations; } + if (null !== $this->icons) { + $data['icons'] = $this->icons; + } return $data; } diff --git a/src/Server/Builder.php b/src/Server/Builder.php index f432716..a438af0 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; @@ -141,10 +142,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; } @@ -291,7 +299,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 c833530..b28a9c1 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 0000000..f390617 --- /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); + } +}