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