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: * }>
31: *
32: * @see https://modelcontextprotocol.io/specification/draft/schema#clientcapabilities
33: */
34: final readonly class ClientCapabilities implements Arrayable
35: {
36: /**
37: * @param null|ElicitationCapability $elicitation
38: * @param null|ExperimentalCapability $experimental
39: * @param null|ExtensionsCapability $extensions
40: */
41: public function __construct(
42: public ?array $elicitation = null,
43: public ?array $experimental = null,
44: public ?array $extensions = null,
45: ) {
46: }
47:
48: #[\Override]
49: public static function fromArray(array $data): static
50: {
51: return new self(
52: elicitation: self::extractElicitation($data),
53: experimental: self::extractExperimental($data),
54: extensions: self::extractExtensions($data),
55: );
56: }
57:
58: #[\Override]
59: public function toArray(): array
60: {
61: $data = [];
62:
63: if (null !== $this->elicitation) {
64: $data['elicitation'] = $this->elicitation;
65: }
66:
67: if (null !== $this->experimental) {
68: $data['experimental'] = $this->experimental;
69: }
70:
71: if (null !== $this->extensions) {
72: $data['extensions'] = $this->extensions;
73: }
74:
75: return $data;
76: }
77:
78: #[\Override]
79: public function jsonSerialize(): array|\stdClass
80: {
81: $data = $this->toArray();
82:
83: if ([] === $data) {
84: return new \stdClass();
85: }
86:
87: foreach ($data as $key => $value) {
88: if (\is_array($value)) {
89: $data[$key] = [] === $value ? new \stdClass() : self::normalizeEmptyObjects($value);
90: }
91: }
92:
93: return $data;
94: }
95:
96: /**
97: * Substitutes `\stdClass` for empty arrays so `json_encode` emits `{}`. Safe
98: * because every capability slot is spec-typed as an object (no list-typed leaves).
99: *
100: * @param array<array-key, mixed> $data
101: *
102: * @return array<array-key, mixed>
103: */
104: private static function normalizeEmptyObjects(array $data): array
105: {
106: foreach ($data as $key => $value) {
107: if (\is_array($value)) {
108: $data[$key] = [] === $value ? new \stdClass() : self::normalizeEmptyObjects($value);
109: }
110: }
111:
112: return $data;
113: }
114:
115: /**
116: * @param array<string, mixed> $data
117: *
118: * @return null|ElicitationCapability
119: */
120: private static function extractElicitation(array $data): ?array
121: {
122: $value = $data['elicitation'] ?? null;
123:
124: if (null === $value) {
125: return null;
126: }
127:
128: Assert::that($value)
129: ->isArray('"capabilities.elicitation" must be an object, {type} given.')
130: ->isMap('"capabilities.elicitation" must be a string-keyed object.')
131: ;
132:
133: $elicitation = [];
134:
135: if (\array_key_exists('form', $value)) {
136: Assert::that($value['form'])
137: ->isArray('"capabilities.elicitation.form" must be an object, {type} given.')
138: ->isMap('"capabilities.elicitation.form" must be a string-keyed object.')
139: ;
140: $elicitation['form'] = $value['form'];
141: }
142:
143: if (\array_key_exists('url', $value)) {
144: Assert::that($value['url'])
145: ->isArray('"capabilities.elicitation.url" must be an object, {type} given.')
146: ->isMap('"capabilities.elicitation.url" must be a string-keyed object.')
147: ;
148: $elicitation['url'] = $value['url'];
149: }
150:
151: return $elicitation;
152: }
153:
154: /**
155: * @param array<string, mixed> $data
156: *
157: * @return null|ExperimentalCapability
158: */
159: private static function extractExperimental(array $data): ?array
160: {
161: $value = $data['experimental'] ?? null;
162:
163: if (null === $value) {
164: return null;
165: }
166:
167: Assert::that($value)
168: ->isArray('"capabilities.experimental" must be an object, {type} given.')
169: ->isMap('"capabilities.experimental" must be a string-keyed object.')
170: ;
171:
172: $experimental = [];
173:
174: foreach ($value as $extKey => $extValue) {
175: Assert::that($extValue)
176: ->isArray(\sprintf('"capabilities.experimental.%s" must be an object, {type} given.', $extKey))
177: ->isMap(\sprintf('"capabilities.experimental.%s" must be a string-keyed object.', $extKey))
178: ;
179: $experimental[$extKey] = $extValue;
180: }
181:
182: return $experimental;
183: }
184:
185: /**
186: * @param array<string, mixed> $data
187: *
188: * @return null|ExtensionsCapability
189: */
190: private static function extractExtensions(array $data): ?array
191: {
192: $value = $data['extensions'] ?? null;
193:
194: if (null === $value) {
195: return null;
196: }
197:
198: Assert::that($value)
199: ->isArray('"capabilities.extensions" must be an object, {type} given.')
200: ->isMap('"capabilities.extensions" must be a string-keyed object.')
201: ;
202:
203: $extensions = [];
204:
205: foreach ($value as $extKey => $extValue) {
206: Assert::that($extValue)
207: ->isArray(\sprintf('"capabilities.extensions.%s" must be an object, {type} given.', $extKey))
208: ->isMap(\sprintf('"capabilities.extensions.%s" must be a string-keyed object.', $extKey))
209: ;
210: $extensions[$extKey] = $extValue;
211: }
212:
213: return $extensions;
214: }
215: }
216: