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/draft/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: #[\Override]
78: public static function fromArray(array $data): static
79: {
80: Assert::that($data)->hasOffset('type', '"requestedSchema" is missing the required "type" key.');
81: $type = $data['type'];
82: Assert::that($type)->isIdentical(self::TYPE, '"requestedSchema.type" must be {other}, {value} given.');
83:
84: Assert::that($data)->hasOffset('properties', '"requestedSchema" is missing the required "properties" key.');
85: Assert::that($data['properties'])
86: ->isArray('"requestedSchema.properties" must be an object, {type} given.')
87: ->isMap('"requestedSchema.properties" must be a string-keyed object.')
88: ;
89:
90: $properties = [];
91:
92: foreach ($data['properties'] as $name => $shape) {
93: Assert::that($shape)
94: ->isArray('"requestedSchema.properties" must be an object, {type} given.')
95: ->isMap('"requestedSchema.properties" must be a string-keyed object.')
96: ;
97:
98: $properties[$name] = self::parsePrimitiveSchema($shape);
99: }
100:
101: $required = null;
102:
103: if (isset($data['required'])) {
104: Assert::that($data['required'])
105: ->isList('"requestedSchema.required" must be a list, non-list array given.')
106: ->values()->isString('each "requestedSchema.required" must be a string, {type} given.')
107: ;
108: $required = $data['required'];
109: }
110:
111: $schema = $data['$schema'] ?? null;
112: Assert::that($schema)->nullOr()->isString('"requestedSchema.$schema" must be a string or null, {type} given.');
113:
114: return new self(properties: $properties, required: $required, schema: $schema);
115: }
116:
117: #[\Override]
118: public function toArray(): array
119: {
120: $data = [
121: 'type' => self::TYPE,
122: 'properties' => array_map(
123: static fn(PrimitiveSchemaDefinition $p): array => $p->toArray(),
124: $this->properties,
125: ),
126: ];
127:
128: if (null !== $this->required) {
129: $data['required'] = $this->required;
130: }
131:
132: if (null !== $this->schema) {
133: $data['$schema'] = $this->schema;
134: }
135:
136: return $data;
137: }
138:
139: #[\Override]
140: public function jsonSerialize(): array
141: {
142: return $this->toArray();
143: }
144:
145: /**
146: * @param array<string, mixed> $data
147: */
148: private static function parsePrimitiveSchema(array $data): PrimitiveSchemaDefinition
149: {
150: $type = $data['type'] ?? null;
151: Assert::that($type)->isString('"requestedSchema.primitiveSchema" must carry a "type" string, {type} given.');
152:
153: return match (true) {
154: BooleanSchema::TYPE === $type => BooleanSchema::fromArray($data),
155: NumberSchema::TYPE === $type, NumberSchema::TYPE_INTEGER === $type => NumberSchema::fromArray($data),
156: UntitledMultiSelectEnumSchema::TYPE === $type => self::parseArraySchema($data),
157: StringSchema::TYPE === $type => self::parseStringSchema($data),
158: default => throw new ExpectationFailedException(
159: '"requestedSchema.primitiveSchema" has unknown "type" {value}.',
160: ['value' => var_export($type, true)],
161: ),
162: };
163: }
164:
165: /**
166: * @param array<string, mixed> $data
167: */
168: private static function parseStringSchema(array $data): PrimitiveSchemaDefinition
169: {
170: return match (true) {
171: isset($data['oneOf']) => TitledSingleSelectEnumSchema::fromArray($data),
172: isset($data['enum']) && isset($data['enumNames']) => LegacyTitledEnumSchema::fromArray($data),
173: isset($data['enum']) => UntitledSingleSelectEnumSchema::fromArray($data),
174: default => StringSchema::fromArray($data),
175: };
176: }
177:
178: /**
179: * @param array<string, mixed> $data
180: */
181: private static function parseArraySchema(array $data): PrimitiveSchemaDefinition
182: {
183: $items = $data['items'] ?? null;
184: Assert::that($items)
185: ->isArray('"requestedSchema.items" must be an object, {type} given.')
186: ->isMap('"requestedSchema" multi-select "items" must be a string-keyed object.')
187: ;
188:
189: return isset($items['anyOf'])
190: ? TitledMultiSelectEnumSchema::fromArray($data)
191: : UntitledMultiSelectEnumSchema::fromArray($data);
192: }
193: }
194: