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: * Capabilities a client may support. Known capabilities are defined here, in this schema, but this is
20: * not a closed set: any client can define its own, additional capabilities.
21: *
22: * @phpstan-type ElicitationCapability array{form?: array<string, mixed>, url?: array<string, mixed>}
23: * @phpstan-type ExperimentalCapability array<string, array<string, mixed>>
24: * @phpstan-type ExtensionsCapability array<string, array<string, mixed>>
25: *
26: * @implements Arrayable<array{
27: * elicitation?: ElicitationCapability,
28: * experimental?: ExperimentalCapability,
29: * extensions?: ExtensionsCapability,
30: * ...<string, mixed>,
31: * }>
32: *
33: * @see https://modelcontextprotocol.io/specification/2026-07-28/schema#clientcapabilities
34: */
35: final readonly class ClientCapabilities implements Arrayable
36: {
37: /**
38: * @param null|ElicitationCapability $elicitation
39: * @param null|ExperimentalCapability $experimental
40: * @param null|ExtensionsCapability $extensions
41: * @param array<string, mixed> $extras Capabilities outside the set this schema names
42: */
43: public function __construct(
44: public ?array $elicitation = null,
45: public ?array $experimental = null,
46: public ?array $extensions = null,
47: public array $extras = [],
48: ) {
49: }
50:
51: #[\Override]
52: public static function fromArray(array $data): static
53: {
54: $elicitation = self::extractElicitation($data);
55: $experimental = self::extractExperimental($data);
56: $extensions = self::extractExtensions($data);
57:
58: unset($data['elicitation'], $data['experimental'], $data['extensions']);
59:
60: return new self(
61: elicitation: $elicitation,
62: experimental: $experimental,
63: extensions: $extensions,
64: extras: $data,
65: );
66: }
67:
68: #[\Override]
69: public function toArray(): array
70: {
71: $data = [];
72:
73: if (null !== $this->elicitation) {
74: $data['elicitation'] = $this->elicitation;
75: }
76:
77: if (null !== $this->experimental) {
78: $data['experimental'] = $this->experimental;
79: }
80:
81: if (null !== $this->extensions) {
82: $data['extensions'] = $this->extensions;
83: }
84:
85: $data += $this->extras;
86:
87: return $data;
88: }
89:
90: #[\Override]
91: public function jsonSerialize(): array|\stdClass
92: {
93: $data = $this->toArray();
94:
95: if ([] === $data) {
96: return new \stdClass();
97: }
98:
99: foreach ($data as $key => $value) {
100: if (\is_array($value)) {
101: $data[$key] = [] === $value ? new \stdClass() : self::normalizeEmptyObjects($value);
102: }
103: }
104:
105: return $data;
106: }
107:
108: /**
109: * Substitutes `\stdClass` for empty arrays so `json_encode` emits `{}`. Safe
110: * because every capability slot is spec-typed as an object (no list-typed leaves).
111: *
112: * @param array<array-key, mixed> $data
113: *
114: * @return array<array-key, mixed>
115: */
116: private static function normalizeEmptyObjects(array $data): array
117: {
118: foreach ($data as $key => $value) {
119: if (\is_array($value)) {
120: $data[$key] = [] === $value ? new \stdClass() : self::normalizeEmptyObjects($value);
121: }
122: }
123:
124: return $data;
125: }
126:
127: /**
128: * @param array<string, mixed> $data
129: *
130: * @return null|ElicitationCapability
131: */
132: private static function extractElicitation(array $data): ?array
133: {
134: $value = $data['elicitation'] ?? null;
135:
136: if (null === $value) {
137: return null;
138: }
139:
140: Assert::that($value)
141: ->isArray('"capabilities.elicitation" must be an object, {type} given.')
142: ->isMap('"capabilities.elicitation" must be a string-keyed object.')
143: ;
144:
145: $elicitation = [];
146:
147: if (\array_key_exists('form', $value)) {
148: Assert::that($value['form'])
149: ->isArray('"capabilities.elicitation.form" must be an object, {type} given.')
150: ->isMap('"capabilities.elicitation.form" must be a string-keyed object.')
151: ;
152: $elicitation['form'] = $value['form'];
153: }
154:
155: if (\array_key_exists('url', $value)) {
156: Assert::that($value['url'])
157: ->isArray('"capabilities.elicitation.url" must be an object, {type} given.')
158: ->isMap('"capabilities.elicitation.url" must be a string-keyed object.')
159: ;
160: $elicitation['url'] = $value['url'];
161: }
162:
163: return $elicitation;
164: }
165:
166: /**
167: * @param array<string, mixed> $data
168: *
169: * @return null|ExperimentalCapability
170: */
171: private static function extractExperimental(array $data): ?array
172: {
173: $value = $data['experimental'] ?? null;
174:
175: if (null === $value) {
176: return null;
177: }
178:
179: Assert::that($value)
180: ->isArray('"capabilities.experimental" must be an object, {type} given.')
181: ->isMap('"capabilities.experimental" must be a string-keyed object.')
182: ;
183:
184: $experimental = [];
185:
186: foreach ($value as $extKey => $extValue) {
187: Assert::that($extValue)
188: ->isArray(\sprintf('"capabilities.experimental.%s" must be an object, {type} given.', $extKey))
189: ->isMap(\sprintf('"capabilities.experimental.%s" must be a string-keyed object.', $extKey))
190: ;
191: $experimental[$extKey] = $extValue;
192: }
193:
194: return $experimental;
195: }
196:
197: /**
198: * @param array<string, mixed> $data
199: *
200: * @return null|ExtensionsCapability
201: */
202: private static function extractExtensions(array $data): ?array
203: {
204: $value = $data['extensions'] ?? null;
205:
206: if (null === $value) {
207: return null;
208: }
209:
210: Assert::that($value)
211: ->isArray('"capabilities.extensions" must be an object, {type} given.')
212: ->isMap('"capabilities.extensions" must be a string-keyed object.')
213: ;
214:
215: $extensions = [];
216:
217: foreach ($value as $extKey => $extValue) {
218: Assert::that($extValue)
219: ->isArray(\sprintf('"capabilities.extensions.%s" must be an object, {type} given.', $extKey))
220: ->isMap(\sprintf('"capabilities.extensions.%s" must be a string-keyed object.', $extKey))
221: ;
222: $extensions[$extKey] = $extValue;
223: }
224:
225: return $extensions;
226: }
227: }
228: