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