Skip to content

Commit 8eaeef3

Browse files
committed
Extend elicitation enum schema compliance
1 parent faac4a5 commit 8eaeef3

13 files changed

+1251
-178
lines changed

src/Schema/Elicitation/ElicitationSchema.php

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@
2525
final class ElicitationSchema implements \JsonSerializable
2626
{
2727
/**
28-
* @param array<string, StringSchemaDefinition|NumberSchemaDefinition|BooleanSchemaDefinition|EnumSchemaDefinition> $properties Property definitions keyed by name
29-
* @param string[] $required Array of required property names
28+
* @param array<string, AbstractSchemaDefinition> $properties Property definitions keyed by name
29+
* @param string[] $required Array of required property names
3030
*/
3131
public function __construct(
3232
public readonly array $properties,
@@ -67,7 +67,7 @@ public static function fromArray(array $data): self
6767
if (!\is_array($propertyData)) {
6868
throw new InvalidArgumentException(\sprintf('Property "%s" must be an array.', $name));
6969
}
70-
$properties[$name] = PrimitiveSchemaDefinition::fromArray($propertyData);
70+
$properties[$name] = self::createSchemaDefinition($propertyData);
7171
}
7272

7373
return new self(
@@ -76,6 +76,58 @@ public static function fromArray(array $data): self
7676
);
7777
}
7878

79+
/**
80+
* Create a schema definition from array data.
81+
*
82+
* @param array<string, mixed> $data
83+
*/
84+
private static function createSchemaDefinition(array $data): AbstractSchemaDefinition
85+
{
86+
if (!isset($data['type']) || !\is_string($data['type'])) {
87+
throw new InvalidArgumentException('Missing or invalid "type" for schema definition.');
88+
}
89+
90+
return match ($data['type']) {
91+
'string' => self::resolveStringType($data),
92+
'integer', 'number' => NumberSchemaDefinition::fromArray($data),
93+
'boolean' => BooleanSchemaDefinition::fromArray($data),
94+
'array' => self::resolveArrayType($data),
95+
default => throw new InvalidArgumentException(\sprintf('Unsupported type "%s". Supported types are: string, integer, number, boolean, array.', $data['type'])),
96+
};
97+
}
98+
99+
/**
100+
* @param array<string, mixed> $data
101+
*/
102+
private static function resolveStringType(array $data): AbstractSchemaDefinition
103+
{
104+
if (isset($data['oneOf'])) {
105+
return TitledEnumSchemaDefinition::fromArray($data);
106+
}
107+
108+
if (isset($data['enum'])) {
109+
return EnumSchemaDefinition::fromArray($data);
110+
}
111+
112+
return StringSchemaDefinition::fromArray($data);
113+
}
114+
115+
/**
116+
* @param array<string, mixed> $data
117+
*/
118+
private static function resolveArrayType(array $data): AbstractSchemaDefinition
119+
{
120+
if (isset($data['items']['anyOf'])) {
121+
return TitledMultiSelectEnumSchemaDefinition::fromArray($data);
122+
}
123+
124+
if (isset($data['items']['enum'])) {
125+
return MultiSelectEnumSchemaDefinition::fromArray($data);
126+
}
127+
128+
throw new InvalidArgumentException('Array type must have "items" with either "enum" or "anyOf".');
129+
}
130+
79131
/**
80132
* @return array{
81133
* type: string,
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Mcp\Schema\Elicitation;
13+
14+
use Mcp\Exception\InvalidArgumentException;
15+
16+
/**
17+
* Schema definition for multi-select enum fields without titles (SEP-1330).
18+
*
19+
* Produces: {"type": "array", "items": {"type": "string", "enum": [...]}}
20+
*
21+
* @see https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1330
22+
*/
23+
final class MultiSelectEnumSchemaDefinition extends AbstractSchemaDefinition
24+
{
25+
/**
26+
* @param string $title Human-readable title for the field
27+
* @param string[] $enum Array of allowed string values
28+
* @param string|null $description Optional description/help text
29+
* @param string[]|null $default Optional default selected values (must be subset of enum)
30+
* @param int|null $minItems Optional minimum number of selections
31+
* @param int|null $maxItems Optional maximum number of selections
32+
*/
33+
public function __construct(
34+
string $title,
35+
public readonly array $enum,
36+
?string $description = null,
37+
public readonly ?array $default = null,
38+
public readonly ?int $minItems = null,
39+
public readonly ?int $maxItems = null,
40+
) {
41+
parent::__construct($title, $description);
42+
43+
if ([] === $enum) {
44+
throw new InvalidArgumentException('enum array must not be empty.');
45+
}
46+
47+
foreach ($enum as $value) {
48+
if (!\is_string($value)) {
49+
throw new InvalidArgumentException('All enum values must be strings.');
50+
}
51+
}
52+
53+
if (null !== $minItems && $minItems < 0) {
54+
throw new InvalidArgumentException('minItems must be non-negative.');
55+
}
56+
57+
if (null !== $maxItems && $maxItems < 0) {
58+
throw new InvalidArgumentException('maxItems must be non-negative.');
59+
}
60+
61+
if (null !== $minItems && null !== $maxItems && $minItems > $maxItems) {
62+
throw new InvalidArgumentException('minItems cannot be greater than maxItems.');
63+
}
64+
65+
if (null !== $default) {
66+
foreach ($default as $value) {
67+
if (!\in_array($value, $enum, true)) {
68+
throw new InvalidArgumentException(\sprintf('Default value "%s" is not in the enum array.', $value));
69+
}
70+
}
71+
}
72+
}
73+
74+
/**
75+
* @param array{
76+
* title: string,
77+
* items: array{type: string, enum: string[]},
78+
* description?: string,
79+
* default?: string[],
80+
* minItems?: int,
81+
* maxItems?: int,
82+
* } $data
83+
*/
84+
public static function fromArray(array $data): self
85+
{
86+
self::validateTitle($data, 'multi-select enum');
87+
88+
if (!isset($data['items']['enum']) || !\is_array($data['items']['enum'])) {
89+
throw new InvalidArgumentException('Missing or invalid "items.enum" for multi-select enum schema definition.');
90+
}
91+
92+
return new self(
93+
title: $data['title'],
94+
enum: $data['items']['enum'],
95+
description: $data['description'] ?? null,
96+
default: $data['default'] ?? null,
97+
minItems: isset($data['minItems']) ? (int) $data['minItems'] : null,
98+
maxItems: isset($data['maxItems']) ? (int) $data['maxItems'] : null,
99+
);
100+
}
101+
102+
/**
103+
* @return array<string, mixed>
104+
*/
105+
public function jsonSerialize(): array
106+
{
107+
$data = $this->buildBaseJson('array');
108+
$data['items'] = [
109+
'type' => 'string',
110+
'enum' => $this->enum,
111+
];
112+
113+
if (null !== $this->default) {
114+
$data['default'] = $this->default;
115+
}
116+
117+
if (null !== $this->minItems) {
118+
$data['minItems'] = $this->minItems;
119+
}
120+
121+
if (null !== $this->maxItems) {
122+
$data['maxItems'] = $this->maxItems;
123+
}
124+
125+
return $data;
126+
}
127+
}

src/Schema/Elicitation/PrimitiveSchemaDefinition.php

Lines changed: 0 additions & 57 deletions
This file was deleted.
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Mcp\Schema\Elicitation;
13+
14+
use Mcp\Exception\InvalidArgumentException;
15+
16+
/**
17+
* Schema definition for single-select enum fields with titled options (SEP-1330).
18+
*
19+
* Uses the oneOf pattern with const/title pairs instead of enum/enumNames.
20+
* Produces: {"type": "string", "oneOf": [{"const": "value", "title": "Label"}, ...]}
21+
*
22+
* @see https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1330
23+
*/
24+
final class TitledEnumSchemaDefinition extends AbstractSchemaDefinition
25+
{
26+
/**
27+
* @param string $title Human-readable title for the field
28+
* @param list<array{const: string, title: string}> $oneOf Array of const/title pairs
29+
* @param string|null $description Optional description/help text
30+
* @param string|null $default Optional default value (must match a const)
31+
*/
32+
public function __construct(
33+
string $title,
34+
public readonly array $oneOf,
35+
?string $description = null,
36+
public readonly ?string $default = null,
37+
) {
38+
parent::__construct($title, $description);
39+
40+
if ([] === $oneOf) {
41+
throw new InvalidArgumentException('oneOf array must not be empty.');
42+
}
43+
44+
$consts = [];
45+
foreach ($oneOf as $item) {
46+
if (!isset($item['const']) || !\is_string($item['const'])) {
47+
throw new InvalidArgumentException('Each oneOf item must have a string "const" property.');
48+
}
49+
if (!isset($item['title']) || !\is_string($item['title'])) {
50+
throw new InvalidArgumentException('Each oneOf item must have a string "title" property.');
51+
}
52+
$consts[] = $item['const'];
53+
}
54+
55+
if (null !== $default && !\in_array($default, $consts, true)) {
56+
throw new InvalidArgumentException(\sprintf('Default value "%s" is not in the oneOf const values.', $default));
57+
}
58+
}
59+
60+
/**
61+
* @param array{
62+
* title: string,
63+
* oneOf: list<array{const: string, title: string}>,
64+
* description?: string,
65+
* default?: string,
66+
* } $data
67+
*/
68+
public static function fromArray(array $data): self
69+
{
70+
self::validateTitle($data, 'titled enum');
71+
72+
if (!isset($data['oneOf']) || !\is_array($data['oneOf'])) {
73+
throw new InvalidArgumentException('Missing or invalid "oneOf" for titled enum schema definition.');
74+
}
75+
76+
return new self(
77+
title: $data['title'],
78+
oneOf: $data['oneOf'],
79+
description: $data['description'] ?? null,
80+
default: $data['default'] ?? null,
81+
);
82+
}
83+
84+
/**
85+
* @return array<string, mixed>
86+
*/
87+
public function jsonSerialize(): array
88+
{
89+
$data = $this->buildBaseJson('string');
90+
$data['oneOf'] = $this->oneOf;
91+
92+
if (null !== $this->default) {
93+
$data['default'] = $this->default;
94+
}
95+
96+
return $data;
97+
}
98+
}

0 commit comments

Comments
 (0)