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 RootsCapability array{listChanged?: bool}
25: * @phpstan-type SamplingCapability array{context?: array<string, mixed>, tools?: array<string, mixed>}
26: * @phpstan-type ClientTasksCapability array{
27: * cancel?: array<string, mixed>,
28: * list?: array<string, mixed>,
29: * requests?: array{
30: * elicitation?: array{create?: array<string, mixed>},
31: * sampling?: array{createMessage?: array<string, mixed>},
32: * },
33: * }
34: *
35: * @implements Arrayable<array{
36: * elicitation?: ElicitationCapability,
37: * experimental?: ExperimentalCapability,
38: * roots?: RootsCapability,
39: * sampling?: SamplingCapability,
40: * tasks?: ClientTasksCapability,
41: * }>
42: *
43: * @see https://modelcontextprotocol.io/specification/2025-11-25/schema#clientcapabilities
44: */
45: final readonly class ClientCapabilities implements Arrayable
46: {
47: /**
48: * @param null|ElicitationCapability $elicitation
49: * @param null|ExperimentalCapability $experimental
50: * @param null|RootsCapability $roots
51: * @param null|SamplingCapability $sampling
52: * @param null|ClientTasksCapability $tasks
53: */
54: public function __construct(
55: public ?array $elicitation = null,
56: public ?array $experimental = null,
57: public ?array $roots = null,
58: public ?array $sampling = null,
59: public ?array $tasks = null,
60: ) {
61: }
62:
63: /**
64: * @param array<string, mixed> $data
65: */
66: #[\Override]
67: public static function fromArray(array $data): static
68: {
69: return new self(
70: self::extractElicitation($data),
71: self::extractExperimental($data),
72: self::extractRoots($data),
73: self::extractSampling($data),
74: self::extractTasks($data),
75: );
76: }
77:
78: #[\Override]
79: public function toArray(): array
80: {
81: $data = [];
82:
83: if (null !== $this->elicitation) {
84: $data['elicitation'] = $this->elicitation;
85: }
86:
87: if (null !== $this->experimental) {
88: $data['experimental'] = $this->experimental;
89: }
90:
91: if (null !== $this->roots) {
92: $data['roots'] = $this->roots;
93: }
94:
95: if (null !== $this->sampling) {
96: $data['sampling'] = $this->sampling;
97: }
98:
99: if (null !== $this->tasks) {
100: $data['tasks'] = $this->tasks;
101: }
102:
103: return $data;
104: }
105:
106: #[\Override]
107: public function jsonSerialize(): array|\stdClass
108: {
109: $data = $this->toArray();
110:
111: if ([] === $data) {
112: return new \stdClass();
113: }
114:
115: foreach ($data as $key => $value) {
116: if (\is_array($value)) {
117: $data[$key] = [] === $value ? new \stdClass() : self::normalizeEmptyObjects($value);
118: }
119: }
120:
121: return $data;
122: }
123:
124: /**
125: * Substitutes `\stdClass` for empty arrays so `json_encode` emits `{}`. Safe
126: * because every capability slot is spec-typed as an object (no list-typed leaves).
127: *
128: * @param array<array-key, mixed> $data
129: *
130: * @return array<array-key, mixed>
131: */
132: private static function normalizeEmptyObjects(array $data): array
133: {
134: foreach ($data as $key => $value) {
135: if (\is_array($value)) {
136: $data[$key] = [] === $value ? new \stdClass() : self::normalizeEmptyObjects($value);
137: }
138: }
139:
140: return $data;
141: }
142:
143: /**
144: * @param array<string, mixed> $data
145: *
146: * @return null|ElicitationCapability
147: */
148: private static function extractElicitation(array $data): ?array
149: {
150: $value = $data['elicitation'] ?? null;
151:
152: if (null === $value) {
153: return null;
154: }
155:
156: Assert::that($value)
157: ->isArray('"capabilities.elicitation" must be an object, {type} given.')
158: ->isMap('"capabilities.elicitation" must be a string-keyed object.')
159: ;
160:
161: $elicitation = [];
162:
163: if (\array_key_exists('form', $value)) {
164: Assert::that($value['form'])
165: ->isArray('"capabilities.elicitation.form" must be an object, {type} given.')
166: ->isMap('"capabilities.elicitation.form" must be a string-keyed object.')
167: ;
168: $elicitation['form'] = $value['form'];
169: }
170:
171: if (\array_key_exists('url', $value)) {
172: Assert::that($value['url'])
173: ->isArray('"capabilities.elicitation.url" must be an object, {type} given.')
174: ->isMap('"capabilities.elicitation.url" must be a string-keyed object.')
175: ;
176: $elicitation['url'] = $value['url'];
177: }
178:
179: return $elicitation;
180: }
181:
182: /**
183: * @param array<string, mixed> $data
184: *
185: * @return null|ExperimentalCapability
186: */
187: private static function extractExperimental(array $data): ?array
188: {
189: $value = $data['experimental'] ?? null;
190:
191: if (null === $value) {
192: return null;
193: }
194:
195: Assert::that($value)
196: ->isArray('"capabilities.experimental" must be an object, {type} given.')
197: ->isMap('"capabilities.experimental" must be a string-keyed object.')
198: ;
199:
200: $experimental = [];
201:
202: foreach ($value as $extKey => $extValue) {
203: Assert::that($extValue)
204: ->isArray(\sprintf('"capabilities.experimental.%s" must be an object, {type} given.', $extKey))
205: ->isMap(\sprintf('"capabilities.experimental.%s" must be a string-keyed object.', $extKey))
206: ;
207: $experimental[$extKey] = $extValue;
208: }
209:
210: return $experimental;
211: }
212:
213: /**
214: * @param array<string, mixed> $data
215: *
216: * @return null|RootsCapability
217: */
218: private static function extractRoots(array $data): ?array
219: {
220: $value = $data['roots'] ?? null;
221:
222: if (null === $value) {
223: return null;
224: }
225:
226: Assert::that($value)
227: ->isArray('"capabilities.roots" must be an object, {type} given.')
228: ->isMap('"capabilities.roots" must be a string-keyed object.')
229: ;
230:
231: $roots = [];
232:
233: if (\array_key_exists('listChanged', $value)) {
234: Assert::that($value['listChanged'])
235: ->isBool('"capabilities.roots.listChanged" must be a boolean, {type} given.')
236: ;
237: $roots['listChanged'] = $value['listChanged'];
238: }
239:
240: return $roots;
241: }
242:
243: /**
244: * @param array<string, mixed> $data
245: *
246: * @return null|SamplingCapability
247: */
248: private static function extractSampling(array $data): ?array
249: {
250: $value = $data['sampling'] ?? null;
251:
252: if (null === $value) {
253: return null;
254: }
255:
256: Assert::that($value)
257: ->isArray('"capabilities.sampling" must be an object, {type} given.')
258: ->isMap('"capabilities.sampling" must be a string-keyed object.')
259: ;
260:
261: $sampling = [];
262:
263: if (\array_key_exists('context', $value)) {
264: Assert::that($value['context'])
265: ->isArray('"capabilities.sampling.context" must be an object, {type} given.')
266: ->isMap('"capabilities.sampling.context" must be a string-keyed object.')
267: ;
268: $sampling['context'] = $value['context'];
269: }
270:
271: if (\array_key_exists('tools', $value)) {
272: Assert::that($value['tools'])
273: ->isArray('"capabilities.sampling.tools" must be an object, {type} given.')
274: ->isMap('"capabilities.sampling.tools" must be a string-keyed object.')
275: ;
276: $sampling['tools'] = $value['tools'];
277: }
278:
279: return $sampling;
280: }
281:
282: /**
283: * @param array<string, mixed> $data
284: *
285: * @return null|ClientTasksCapability
286: */
287: private static function extractTasks(array $data): ?array
288: {
289: $value = $data['tasks'] ?? null;
290:
291: if (null === $value) {
292: return null;
293: }
294:
295: Assert::that($value)
296: ->isArray('"capabilities.tasks" must be an object, {type} given.')
297: ->isMap('"capabilities.tasks" must be a string-keyed object.')
298: ;
299:
300: $tasks = [];
301:
302: if (\array_key_exists('cancel', $value)) {
303: Assert::that($value['cancel'])
304: ->isArray('"capabilities.tasks.cancel" must be an object, {type} given.')
305: ->isMap('"capabilities.tasks.cancel" must be a string-keyed object.')
306: ;
307: $tasks['cancel'] = $value['cancel'];
308: }
309:
310: if (\array_key_exists('list', $value)) {
311: Assert::that($value['list'])
312: ->isArray('"capabilities.tasks.list" must be an object, {type} given.')
313: ->isMap('"capabilities.tasks.list" must be a string-keyed object.')
314: ;
315: $tasks['list'] = $value['list'];
316: }
317:
318: if (\array_key_exists('requests', $value)) {
319: $tasks['requests'] = self::extractTasksRequests($value['requests']);
320: }
321:
322: return $tasks;
323: }
324:
325: /**
326: * @return array{
327: * elicitation?: array{create?: array<string, mixed>},
328: * sampling?: array{createMessage?: array<string, mixed>},
329: * }
330: */
331: private static function extractTasksRequests(mixed $value): array
332: {
333: Assert::that($value)
334: ->isArray('"capabilities.tasks.requests" must be an object, {type} given.')
335: ->isMap('"capabilities.tasks.requests" must be a string-keyed object.')
336: ;
337:
338: $requests = [];
339:
340: if (\array_key_exists('elicitation', $value)) {
341: Assert::that($value['elicitation'])
342: ->isArray('"capabilities.tasks.requests.elicitation" must be an object, {type} given.')
343: ->isMap('"capabilities.tasks.requests.elicitation" must be a string-keyed object.')
344: ;
345:
346: $elicitation = [];
347:
348: if (\array_key_exists('create', $value['elicitation'])) {
349: Assert::that($value['elicitation']['create'])
350: ->isArray('"capabilities.tasks.requests.elicitation.create" must be an object, {type} given.')
351: ->isMap('"capabilities.tasks.requests.elicitation.create" must be a string-keyed object.')
352: ;
353: $elicitation['create'] = $value['elicitation']['create'];
354: }
355:
356: $requests['elicitation'] = $elicitation;
357: }
358:
359: if (\array_key_exists('sampling', $value)) {
360: Assert::that($value['sampling'])
361: ->isArray('"capabilities.tasks.requests.sampling" must be an object, {type} given.')
362: ->isMap('"capabilities.tasks.requests.sampling" must be a string-keyed object.')
363: ;
364:
365: $sampling = [];
366:
367: if (\array_key_exists('createMessage', $value['sampling'])) {
368: Assert::that($value['sampling']['createMessage'])
369: ->isArray('"capabilities.tasks.requests.sampling.createMessage" must be an object, {type} given.')
370: ->isMap('"capabilities.tasks.requests.sampling.createMessage" must be a string-keyed object.')
371: ;
372: $sampling['createMessage'] = $value['sampling']['createMessage'];
373: }
374:
375: $requests['sampling'] = $sampling;
376: }
377:
378: return $requests;
379: }
380: }
381: