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