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\Elicitation;
15:
16: use Nexus\Assert\Assert;
17: use Nexus\Assert\ExpectationFailedException;
18: use Nexus\Mcp\Core\Schema\Arrayable;
19:
20: /**
21: * The `requestedSchema` shape carried by an `ElicitRequestFormParams`.
22: *
23: * @implements Arrayable<array{
24: * type: 'object',
25: * properties: array<non-empty-string, template-type<PrimitiveSchemaDefinition, Arrayable, 'T'>>,
26: * required?: list<non-empty-string>,
27: * '$schema'?: non-empty-string,
28: * }>
29: *
30: * @see https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2025-11-25/schema.ts
31: */
32: final readonly class ElicitRequestedSchema implements Arrayable
33: {
34: public const string TYPE = 'object';
35:
36: /**
37: * @var array<non-empty-string, PrimitiveSchemaDefinition>
38: */
39: public array $properties;
40:
41: /**
42: * @var null|list<non-empty-string>
43: */
44: public ?array $required;
45:
46: /**
47: * @var null|non-empty-string
48: */
49: public ?string $schema;
50:
51: /**
52: * @param array<string, PrimitiveSchemaDefinition> $properties
53: * @param null|list<string> $required
54: */
55: public function __construct(array $properties, ?array $required = null, ?string $schema = null)
56: {
57: Assert::that($properties)
58: ->isMap('"requestedSchema.properties" must be a string-keyed map.')
59: ->keys()->isNonEmptyString('each "requestedSchema.properties" key must be a non-empty string.')
60: ;
61: Assert::that($properties)->values()->isInstanceOf(PrimitiveSchemaDefinition::class);
62:
63: if (null !== $required) {
64: Assert::that($required)
65: ->isList('"requestedSchema.required" must be a list, non-list array given.')
66: ->values()->isNonEmptyString('each "requestedSchema.required" must be a non-empty string.')
67: ;
68: }
69:
70: Assert::that($schema)->nullOr()->isNonEmptyString('"requestedSchema.$schema" must be a non-empty string or null.');
71:
72: $this->properties = $properties;
73: $this->required = $required;
74: $this->schema = $schema;
75: }
76:
77: /**
78: * @param array<string, mixed> $data
79: */
80: #[\Override]
81: public static function fromArray(array $data): static
82: {
83: Assert::that($data)->hasOffset('type', '"requestedSchema" missing the required "type" key.');
84: $type = $data['type'];
85: Assert::that($type)->isIdentical(self::TYPE, '"requestedSchema.type" must be {other}, {value} given.');
86:
87: Assert::that($data)->hasOffset('properties', '"requestedSchema" missing the required "properties" key.');
88: Assert::that($data['properties'])
89: ->isArray('"requestedSchema.properties" must be an object, {type} given.')
90: ->isMap('"requestedSchema.properties" must be a string-keyed object.')
91: ;
92:
93: $properties = [];
94:
95: foreach ($data['properties'] as $name => $shape) {
96: Assert::that($shape)
97: ->isArray('"requestedSchema.properties" must be an object, {type} given.')
98: ->isMap('"requestedSchema.properties" must be a string-keyed object.')
99: ;
100:
101: $properties[$name] = self::parsePrimitiveSchema($shape);
102: }
103:
104: $required = null;
105:
106: if (isset($data['required'])) {
107: Assert::that($data['required'])
108: ->isList('"requestedSchema.required" must be a list, non-list array given.')
109: ->values()->isString('each "requestedSchema.required" must be a string, {type} given.')
110: ;
111: $required = $data['required'];
112: }
113:
114: $schema = $data['$schema'] ?? null;
115: Assert::that($schema)->nullOr()->isString('"requestedSchema.$schema" must be a string or null, {type} given.');
116:
117: return new self($properties, $required, $schema);
118: }
119:
120: #[\Override]
121: public function toArray(): array
122: {
123: $data = [
124: 'type' => self::TYPE,
125: 'properties' => array_map(
126: static fn(PrimitiveSchemaDefinition $p): array => $p->toArray(),
127: $this->properties,
128: ),
129: ];
130:
131: if (null !== $this->required) {
132: $data['required'] = $this->required;
133: }
134:
135: if (null !== $this->schema) {
136: $data['$schema'] = $this->schema;
137: }
138:
139: return $data;
140: }
141:
142: #[\Override]
143: public function jsonSerialize(): array
144: {
145: return $this->toArray();
146: }
147:
148: /**
149: * @param array<string, mixed> $data
150: */
151: private static function parsePrimitiveSchema(array $data): PrimitiveSchemaDefinition
152: {
153: $type = $data['type'] ?? null;
154: Assert::that($type)->isString('"requestedSchema.primitiveSchema" must carry a "type" string, {type} given.');
155:
156: return match (true) {
157: BooleanSchema::TYPE === $type => BooleanSchema::fromArray($data),
158: NumberSchema::TYPE === $type, NumberSchema::TYPE_INTEGER === $type => NumberSchema::fromArray($data),
159: UntitledMultiSelectEnumSchema::TYPE === $type => self::parseArraySchema($data),
160: StringSchema::TYPE === $type => self::parseStringSchema($data),
161: default => throw new ExpectationFailedException(
162: '"requestedSchema.primitiveSchema" has unknown "type" {value}.',
163: ['value' => var_export($type, true)],
164: ),
165: };
166: }
167:
168: /**
169: * @param array<string, mixed> $data
170: */
171: private static function parseStringSchema(array $data): PrimitiveSchemaDefinition
172: {
173: return match (true) {
174: isset($data['oneOf']) => TitledSingleSelectEnumSchema::fromArray($data),
175: isset($data['enum']) && isset($data['enumNames']) => LegacyTitledEnumSchema::fromArray($data),
176: isset($data['enum']) => UntitledSingleSelectEnumSchema::fromArray($data),
177: default => StringSchema::fromArray($data),
178: };
179: }
180:
181: /**
182: * @param array<string, mixed> $data
183: */
184: private static function parseArraySchema(array $data): PrimitiveSchemaDefinition
185: {
186: $items = $data['items'] ?? null;
187: Assert::that($items)
188: ->isArray('"requestedSchema.items" must be an object, {type} given.')
189: ->isMap('"requestedSchema" multi-select "items" must be a string-keyed object.')
190: ;
191:
192: return isset($items['anyOf'])
193: ? TitledMultiSelectEnumSchema::fromArray($data)
194: : UntitledMultiSelectEnumSchema::fromArray($data);
195: }
196: }
197: