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/2025-11-25/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: /**
90: * @param array<string, mixed> $data
91: */
92: #[\Override]
93: public static function fromArray(array $data): static
94: {
95: Assert::that($data)->hasOffset('src', '"icons" missing the required "src" key.');
96:
97: $src = $data['src'];
98: Assert::that($src)->isString('"icons.src" must be a string, {type} given.');
99:
100: $mimeType = $data['mimeType'] ?? null;
101: Assert::that($mimeType)->nullOr()->isString('"icons.mimeType" must be a string or null, {type} given.');
102:
103: $sizes = null;
104:
105: if (isset($data['sizes'])) {
106: Assert::that($data['sizes'])
107: ->isList('"icons.sizes" must be a list of strings or null, {type} given.')
108: ->values()->isString('each "icons.sizes" must be a string, {type} given.')
109: ;
110: $sizes = $data['sizes'];
111: }
112:
113: $theme = $data['theme'] ?? null;
114: Assert::that($theme)->nullOr()->isString('"icons.theme" must be a string or null, {type} given.');
115:
116: return new self($src, $mimeType, $sizes, $theme);
117: }
118:
119: #[\Override]
120: public function toArray(): array
121: {
122: $data = ['src' => $this->src];
123:
124: if (null !== $this->mimeType) {
125: $data['mimeType'] = $this->mimeType;
126: }
127:
128: if (null !== $this->sizes) {
129: $data['sizes'] = $this->sizes;
130: }
131:
132: if (null !== $this->theme) {
133: $data['theme'] = $this->theme;
134: }
135:
136: return $data;
137: }
138:
139: #[\Override]
140: public function jsonSerialize(): array
141: {
142: return $this->toArray();
143: }
144: }
145: