Skip to content

Commit 5df7206

Browse files
committed
Extend elicitation enum schema compliance
1 parent 591abb4 commit 5df7206

File tree

7 files changed

+298
-5
lines changed

7 files changed

+298
-5
lines changed

src/Schema/Elicitation/ElicitationSchema.php

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

tests/Conformance/Elements.php

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@
1616
use Mcp\Schema\Content\PromptMessage;
1717
use Mcp\Schema\Content\TextContent;
1818
use Mcp\Schema\Content\TextResourceContents;
19+
use Mcp\Schema\Elicitation\BooleanSchemaDefinition;
20+
use Mcp\Schema\Elicitation\ElicitationSchema;
21+
use Mcp\Schema\Elicitation\EnumSchemaDefinition;
22+
use Mcp\Schema\Elicitation\MultiSelectEnumSchemaDefinition;
23+
use Mcp\Schema\Elicitation\NumberSchemaDefinition;
24+
use Mcp\Schema\Elicitation\StringSchemaDefinition;
25+
use Mcp\Schema\Elicitation\TitledEnumSchemaDefinition;
26+
use Mcp\Schema\Elicitation\TitledMultiSelectEnumSchemaDefinition;
1927
use Mcp\Schema\Enum\Role;
2028
use Mcp\Schema\Result\CallToolResult;
2129
use Mcp\Server\Protocol;
@@ -76,6 +84,65 @@ public function toolWithSampling(RequestContext $context, string $prompt): strin
7684
);
7785
}
7886

87+
/**
88+
* @param string $message The message to display to the user
89+
*/
90+
public function toolWithElicitation(RequestContext $context, string $message): string
91+
{
92+
$schema = new ElicitationSchema(
93+
properties: [
94+
'username' => new StringSchemaDefinition('Username'),
95+
'email' => new StringSchemaDefinition('Email'),
96+
],
97+
);
98+
99+
$context->getClientGateway()->elicit($message, $schema);
100+
101+
return 'ok';
102+
}
103+
104+
public function toolWithElicitationDefaults(RequestContext $context): string
105+
{
106+
$schema = new ElicitationSchema(
107+
properties: [
108+
'name' => new StringSchemaDefinition('Name', default: 'John Doe'),
109+
'age' => new NumberSchemaDefinition('Age', integerOnly: true, default: 30),
110+
'score' => new NumberSchemaDefinition('Score', default: 95.5),
111+
'status' => new EnumSchemaDefinition('Status', enum: ['active', 'inactive', 'pending'], default: 'active'),
112+
'verified' => new BooleanSchemaDefinition('Verified', default: true),
113+
],
114+
);
115+
116+
$context->getClientGateway()->elicit('Provide profile information', $schema);
117+
118+
return 'ok';
119+
}
120+
121+
public function toolWithElicitationEnums(RequestContext $context): string
122+
{
123+
$schema = new ElicitationSchema(
124+
properties: [
125+
'untitledSingle' => new EnumSchemaDefinition('Untitled Single', enum: ['option1', 'option2', 'option3']),
126+
'titledSingle' => new TitledEnumSchemaDefinition('Titled Single', oneOf: [
127+
['const' => 'value1', 'title' => 'Label 1'],
128+
['const' => 'value2', 'title' => 'Label 2'],
129+
['const' => 'value3', 'title' => 'Label 3'],
130+
]),
131+
'legacyEnum' => new EnumSchemaDefinition('Legacy Enum', enum: ['opt1', 'opt2', 'opt3'], enumNames: ['Option 1', 'Option 2', 'Option 3']),
132+
'untitledMulti' => new MultiSelectEnumSchemaDefinition('Untitled Multi', enum: ['option1', 'option2', 'option3']),
133+
'titledMulti' => new TitledMultiSelectEnumSchemaDefinition('Titled Multi', anyOf: [
134+
['const' => 'value1', 'title' => 'Label 1'],
135+
['const' => 'value2', 'title' => 'Label 2'],
136+
['const' => 'value3', 'title' => 'Label 3'],
137+
]),
138+
],
139+
);
140+
141+
$context->getClientGateway()->elicit('Select options', $schema);
142+
143+
return 'ok';
144+
}
145+
79146
public function resourceTemplate(string $id): TextResourceContents
80147
{
81148
return new TextResourceContents(
Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
11
server:
2-
- tools-call-elicitation
3-
- elicitation-sep1034-defaults
4-
- elicitation-sep1330-enums
52
- dns-rebinding-protection
3+

tests/Conformance/server.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@
4949
->addTool([Elements::class, 'toolWithProgress'], 'test_tool_with_progress', 'Tests tool that reports progress notifications')
5050
->addTool([Elements::class, 'toolWithSampling'], 'test_sampling', 'Tests server-initiated sampling')
5151
->addTool(static fn () => CallToolResult::error([new TextContent('This tool intentionally returns an error for testing')]), 'test_error_handling', 'Tests error response handling')
52+
->addTool([Elements::class, 'toolWithElicitation'], 'test_elicitation', 'Tests server-initiated elicitation')
53+
->addTool([Elements::class, 'toolWithElicitationDefaults'], 'test_elicitation_sep1034_defaults', 'Tests elicitation with default values')
54+
->addTool([Elements::class, 'toolWithElicitationEnums'], 'test_elicitation_sep1330_enums', 'Tests elicitation with enum schemas')
5255
// Resources
5356
->addResource(static fn () => 'This is the content of the static text resource.', 'test://static-text', 'static-text', 'A static text resource for testing')
5457
->addResource(static fn () => fopen('data://image/png;base64,'.Elements::TEST_IMAGE_BASE64, 'r'), 'test://static-binary', 'static-binary', 'A static binary resource (image) for testing')

0 commit comments

Comments
 (0)