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: continue;
102: }
103:
104: if ([] === $value) {
105: $data[$key] = new \stdClass();
106:
107: continue;
108: }
109:
110: // A vendor capability's interior is schema-less, so its nested empty arrays stay lists.
111: if (! \array_key_exists($key, $this->extras)) {
112: $data[$key] = $this->normalizeEmptyObjects($value);
113: }
114: }
115:
116: return $data;
117: }
118:
119: /**
120: * Substitutes `\stdClass` for empty arrays so `json_encode` emits `{}`.
121: *
122: * @param array<array-key, mixed> $data
123: *
124: * @return array<array-key, mixed>
125: */
126: private function normalizeEmptyObjects(array $data): array
127: {
128: foreach ($data as $key => $value) {
129: if (\is_array($value)) {
130: $data[$key] = [] === $value ? new \stdClass() : $this->normalizeEmptyObjects($value);
131: }
132: }
133:
134: return $data;
135: }
136:
137: /**
138: * @param array<string, mixed> $data
139: *
140: * @return null|ElicitationCapability
141: */
142: private static function extractElicitation(array $data): ?array
143: {
144: $value = $data['elicitation'] ?? null;
145:
146: if (null === $value) {
147: return null;
148: }
149:
150: Assert::that($value)
151: ->isArray('"capabilities.elicitation" must be an object, {type} given.')
152: ->isMap('"capabilities.elicitation" must be a string-keyed object.')
153: ;
154:
155: $elicitation = [];
156:
157: if (\array_key_exists('form', $value)) {
158: Assert::that($value['form'])
159: ->isArray('"capabilities.elicitation.form" must be an object, {type} given.')
160: ->isMap('"capabilities.elicitation.form" must be a string-keyed object.')
161: ;
162: $elicitation['form'] = $value['form'];
163: }
164:
165: if (\array_key_exists('url', $value)) {
166: Assert::that($value['url'])
167: ->isArray('"capabilities.elicitation.url" must be an object, {type} given.')
168: ->isMap('"capabilities.elicitation.url" must be a string-keyed object.')
169: ;
170: $elicitation['url'] = $value['url'];
171: }
172:
173: return $elicitation;
174: }
175:
176: /**
177: * @param array<string, mixed> $data
178: *
179: * @return null|ExperimentalCapability
180: */
181: private static function extractExperimental(array $data): ?array
182: {
183: $value = $data['experimental'] ?? null;
184:
185: if (null === $value) {
186: return null;
187: }
188:
189: Assert::that($value)
190: ->isArray('"capabilities.experimental" must be an object, {type} given.')
191: ->isMap('"capabilities.experimental" must be a string-keyed object.')
192: ;
193:
194: $experimental = [];
195:
196: foreach ($value as $extKey => $extValue) {
197: Assert::that($extValue)
198: ->isArray(\sprintf('"capabilities.experimental.%s" must be an object, {type} given.', $extKey))
199: ->isMap(\sprintf('"capabilities.experimental.%s" must be a string-keyed object.', $extKey))
200: ;
201: $experimental[$extKey] = $extValue;
202: }
203:
204: return $experimental;
205: }
206:
207: /**
208: * @param array<string, mixed> $data
209: *
210: * @return null|ExtensionsCapability
211: */
212: private static function extractExtensions(array $data): ?array
213: {
214: $value = $data['extensions'] ?? null;
215:
216: if (null === $value) {
217: return null;
218: }
219:
220: Assert::that($value)
221: ->isArray('"capabilities.extensions" must be an object, {type} given.')
222: ->isMap('"capabilities.extensions" must be a string-keyed object.')
223: ;
224:
225: $extensions = [];
226:
227: foreach ($value as $extKey => $extValue) {
228: Assert::that($extValue)
229: ->isArray(\sprintf('"capabilities.extensions.%s" must be an object, {type} given.', $extKey))
230: ->isMap(\sprintf('"capabilities.extensions.%s" must be a string-keyed object.', $extKey))
231: ;
232: $extensions[$extKey] = $extValue;
233: }
234:
235: return $extensions;
236: }
237: }
238: