1: <?php
2:
3: declare(strict_types=1);
4:
5: /**
6: * This file is part of the Nexus MCP SDK package.
7: *
8: * (c) 2026 John Paul E. Balandan, CPA <paulbalandan@gmail.com>
9: *
10: * For the full copyright and license information, please view
11: * the LICENSE file that was distributed with this source code.
12: */
13:
14: namespace Nexus\Mcp\Core\Schema;
15:
16: use Nexus\Assert\Assert;
17:
18: /**
19: * An optionally-sized icon that can be displayed in a user interface.
20: *
21: * @implements Arrayable<array{
22: * src: non-empty-string,
23: * mimeType?: non-empty-string,
24: * sizes?: list<non-empty-string>,
25: * theme?: 'dark'|'light',
26: * }>
27: *
28: * @see https://modelcontextprotocol.io/specification/draft/schema#icon
29: */
30: final readonly class Icon implements Arrayable
31: {
32: /**
33: * @var non-empty-string
34: */
35: public string $src;
36:
37: /**
38: * @var null|non-empty-string
39: */
40: public ?string $mimeType;
41:
42: /**
43: * @var null|list<non-empty-string>
44: */
45: public ?array $sizes;
46:
47: /**
48: * @var null|'dark'|'light'
49: */
50: public ?string $theme;
51:
52: /**
53: * @param null|list<string> $sizes
54: */
55: public function __construct(string $src, ?string $mimeType = null, ?array $sizes = null, ?string $theme = null)
56: {
57: Assert::that($src)
58: ->isNonEmptyString('"icons.src" must be a non-empty string.')
59: ->matchesRegularExpression(
60: '/\A(?:https?:\/\/\S+|data:[^;]+;base64,[A-Za-z0-9+\/]+={0,2})\z/',
61: '"icons.src" must be a valid HTTP/HTTPS URL or a data URI with base64-encoded data.',
62: )
63: ;
64: Assert::that($mimeType)
65: ->nullOr()
66: ->isNonEmptyString('"icons.mimeType" must be a non-empty string or null.')
67: ->matchesRegularExpression(
68: '/\A[a-zA-Z][a-zA-Z!#$&^_.+-]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&^_.+-]*\z/',
69: '"icons.mimeType" must be a valid MIME type in the format "type/subtype".',
70: )
71: ;
72:
73: if (null !== $sizes) {
74: Assert::that($sizes)
75: ->values()
76: ->isNonEmptyString('each "icons.sizes" must be a non-empty string.')
77: ->matchesRegularExpression('/\A(\d+x\d+|any)\z/', 'each "icons.sizes" must be in the format "WIDTHxHEIGHT" or "any".')
78: ;
79: }
80:
81: Assert::that($theme)->nullOr()->isOneOf(['light', 'dark'], '"icons.theme" must be one of "light", "dark".');
82:
83: $this->src = $src;
84: $this->mimeType = $mimeType;
85: $this->sizes = $sizes;
86: $this->theme = $theme;
87: }
88:
89: #[\Override]
90: public static function fromArray(array $data): static
91: {
92: Assert::that($data)->hasOffset('src', '"icons" is missing the required "src" key.');
93:
94: $src = $data['src'];
95: Assert::that($src)->isString('"icons.src" must be a string, {type} given.');
96:
97: $mimeType = $data['mimeType'] ?? null;
98: Assert::that($mimeType)->nullOr()->isString('"icons.mimeType" must be a string or null, {type} given.');
99:
100: $sizes = null;
101:
102: if (isset($data['sizes'])) {
103: Assert::that($data['sizes'])
104: ->isList('"icons.sizes" must be a list of strings or null, {type} given.')
105: ->values()->isString('each "icons.sizes" must be a string, {type} given.')
106: ;
107: $sizes = $data['sizes'];
108: }
109:
110: $theme = $data['theme'] ?? null;
111: Assert::that($theme)->nullOr()->isString('"icons.theme" must be a string or null, {type} given.');
112:
113: return new self(src: $src, mimeType: $mimeType, sizes: $sizes, theme: $theme);
114: }
115:
116: #[\Override]
117: public function toArray(): array
118: {
119: $data = ['src' => $this->src];
120:
121: if (null !== $this->mimeType) {
122: $data['mimeType'] = $this->mimeType;
123: }
124:
125: if (null !== $this->sizes) {
126: $data['sizes'] = $this->sizes;
127: }
128:
129: if (null !== $this->theme) {
130: $data['theme'] = $this->theme;
131: }
132:
133: return $data;
134: }
135:
136: #[\Override]
137: public function jsonSerialize(): array
138: {
139: return $this->toArray();
140: }
141: }
142: