Add support for icons and website url (#141)

This commit is contained in:
Christopher Hertel 2025-11-09 14:53:36 +01:00 • committed by GitHub
commit 5804ba9773
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 279 additions and 22 deletions

View file

@ -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'))

View file

@ -13,12 +13,13 @@ namespace Mcp\Schema\Enum;
/**
* Available protocol versions for MCP.
*
* @author Illia Vasylevskyi<ineersa@gmail.com>
*/
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';
}

91
src/Schema/Icon.php Normal file
View file

@ -0,0 +1,91 @@
<?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;
use Mcp\Exception\InvalidArgumentException;
/**
* A url pointing to an icon URL or a base64-encoded data URI.
*
* @phpstan-type IconData array{
* src: string,
* mimeType?: string,
* sizes?: string[],
* }
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
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;
}
}

View file

@ -16,14 +16,21 @@ use Mcp\Exception\InvalidArgumentException;
/**
* Describes the name and version of an MCP implementation.
*
* @phpstan-import-type IconData from Icon
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/
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 @@ class Implementation implements \JsonSerializable
* @param array{
* name: string,
* version: string,
* description?: string,
* icons?: IconData[],
* websiteUrl?: string,
* } $data
*/
public static function fromArray(array $data): self
@ -42,13 +52,30 @@ class Implementation implements \JsonSerializable
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 @@ class Implementation implements \JsonSerializable
$data['description'] = $this->description;
}
if (null !== $this->icons) {
$data['icons'] = $this->icons;
}
if (null !== $this->websiteUrl) {
$data['websiteUrl'] = $this->websiteUrl;
}
return $data;
}
}

View file

@ -17,11 +17,13 @@ use Mcp\Exception\InvalidArgumentException;
* 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<string, mixed>
* }
*
@ -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<string, mixed> $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 @@ class Prompt implements \JsonSerializable
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 @@ class Prompt implements \JsonSerializable
* name: string,
* description?: string,
* arguments?: array<PromptArgument>,
* icons?: Icon[],
* _meta?: array<string, mixed>
* }
*/
@ -92,6 +98,9 @@ class Prompt implements \JsonSerializable
if (null !== $this->arguments) {
$data['arguments'] = $this->arguments;
}
if (null !== $this->icons) {
$data['icons'] = $this->icons;
}
if (null !== $this->meta) {
$data['_meta'] = $this->meta;
}

View file

@ -17,15 +17,17 @@ use Mcp\Exception\InvalidArgumentException;
* 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<string, mixed>
* description?: string,
* mimeType?: string,
* annotations?: AnnotationsData,
* size?: int,
* icons?: IconData[],
* _meta?: array<string, mixed>,
* }
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
@ -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<string, mixed> $meta Optional metadata
*
* This can be used by Hosts to display file sizes and estimate context window usage
@ -61,6 +64,7 @@ class Resource implements \JsonSerializable
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 @@ class Resource implements \JsonSerializable
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 @@ class Resource implements \JsonSerializable
* mimeType?: string,
* annotations?: Annotations,
* size?: int,
* icons?: Icon[],
* _meta?: array<string, mixed>
* }
*/
@ -127,6 +133,9 @@ class Resource implements \JsonSerializable
if (null !== $this->size) {
$data['size'] = $this->size;
}
if (null !== $this->icons) {
$data['icons'] = $this->icons;
}
if (null !== $this->meta) {
$data['_meta'] = $this->meta;
}

View file

@ -17,6 +17,7 @@ use Mcp\Exception\InvalidArgumentException;
* 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 @@ use Mcp\Exception\InvalidArgumentException;
* inputSchema: ToolInputSchema,
* description?: string|null,
* annotations?: ToolAnnotationsData,
* icons?: IconData[],
* _meta?: array<string, mixed>
* }
*
@ -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<string, mixed> $meta Optional metadata
*/
public function __construct(
@ -49,6 +52,7 @@ class Tool implements \JsonSerializable
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 @@ class Tool implements \JsonSerializable
$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 @@ class Tool implements \JsonSerializable
* inputSchema: ToolInputSchema,
* description?: string,
* annotations?: ToolAnnotations,
* icons?: Icon[],
* _meta?: array<string, mixed>
* }
*/
@ -104,6 +110,9 @@ class Tool implements \JsonSerializable
if (null !== $this->annotations) {
$data['annotations'] = $this->annotations;
}
if (null !== $this->icons) {
$data['icons'] = $this->icons;
}
if (null !== $this->meta) {
$data['_meta'] = $this->meta;
}

View file

@ -21,6 +21,7 @@ use Mcp\Capability\Registry\ReferenceHandler;
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 @@ final class Builder
return $this;
}
public function setProtocolVersion(?ProtocolVersion $protocolVersion): self
public function setProtocolVersion(ProtocolVersion $protocolVersion): self
{
$this->protocolVersion = $protocolVersion;

View file

@ -18,7 +18,7 @@ use Symfony\Component\Process\Process;
abstract class InspectorSnapshotTestCase extends TestCase
{
private const INSPECTOR_VERSION = '0.16.8';
private const INSPECTOR_VERSION = '0.17.2';
/** @param array<string, mixed> $options */
#[DataProvider('provideMethods')]

View file

@ -0,0 +1,88 @@
<?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\Tests\Unit\Schema;
use Mcp\Exception\InvalidArgumentException;
use Mcp\Schema\Icon;
use PHPUnit\Framework\TestCase;
class IconTest extends TestCase
{
public function testValidConstructor()
{
$icon = new Icon('https://www.php.net/images/logos/php-logo-white.svg', 'image/svg+xml', ['any']);
$this->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);
}
}