From 5df7206b7af59571a533b159fad8b06a338d43ef Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 11 Mar 2026 00:18:36 +0100 Subject: [PATCH] Extend elicitation enum schema compliance --- src/Schema/Elicitation/ElicitationSchema.php | 4 +- .../MultiSelectEnumSchemaDefinition.php | 70 ++++++++++++++++ .../TitledEnumSchemaDefinition.php | 83 +++++++++++++++++++ .../TitledMultiSelectEnumSchemaDefinition.php | 72 ++++++++++++++++ tests/Conformance/Elements.php | 67 +++++++++++++++ tests/Conformance/conformance-baseline.yml | 4 +- tests/Conformance/server.php | 3 + 7 files changed, 298 insertions(+), 5 deletions(-) create mode 100644 src/Schema/Elicitation/MultiSelectEnumSchemaDefinition.php create mode 100644 src/Schema/Elicitation/TitledEnumSchemaDefinition.php create mode 100644 src/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinition.php diff --git a/src/Schema/Elicitation/ElicitationSchema.php b/src/Schema/Elicitation/ElicitationSchema.php index ff1d3f1f..15971c0e 100644 --- a/src/Schema/Elicitation/ElicitationSchema.php +++ b/src/Schema/Elicitation/ElicitationSchema.php @@ -25,8 +25,8 @@ final class ElicitationSchema implements \JsonSerializable { /** - * @param array $properties Property definitions keyed by name - * @param string[] $required Array of required property names + * @param array $properties Property definitions keyed by name + * @param string[] $required Array of required property names */ public function __construct( public readonly array $properties, diff --git a/src/Schema/Elicitation/MultiSelectEnumSchemaDefinition.php b/src/Schema/Elicitation/MultiSelectEnumSchemaDefinition.php new file mode 100644 index 00000000..d840e130 --- /dev/null +++ b/src/Schema/Elicitation/MultiSelectEnumSchemaDefinition.php @@ -0,0 +1,70 @@ + + */ + public function jsonSerialize(): array + { + $data = [ + 'type' => 'array', + 'title' => $this->title, + 'items' => [ + 'type' => 'string', + 'enum' => $this->enum, + ], + ]; + + if (null !== $this->description) { + $data['description'] = $this->description; + } + + return $data; + } +} diff --git a/src/Schema/Elicitation/TitledEnumSchemaDefinition.php b/src/Schema/Elicitation/TitledEnumSchemaDefinition.php new file mode 100644 index 00000000..1f58140e --- /dev/null +++ b/src/Schema/Elicitation/TitledEnumSchemaDefinition.php @@ -0,0 +1,83 @@ + $oneOf Array of const/title pairs + * @param string|null $description Optional description/help text + * @param string|null $default Optional default value (must match a const) + */ + public function __construct( + string $title, + public readonly array $oneOf, + ?string $description = null, + public readonly ?string $default = null, + ) { + parent::__construct($title, $description); + + if ([] === $oneOf) { + throw new InvalidArgumentException('oneOf array must not be empty.'); + } + + $consts = []; + foreach ($oneOf as $item) { + if (!isset($item['const']) || !\is_string($item['const'])) { + throw new InvalidArgumentException('Each oneOf item must have a string "const" property.'); + } + if (!isset($item['title']) || !\is_string($item['title'])) { + throw new InvalidArgumentException('Each oneOf item must have a string "title" property.'); + } + $consts[] = $item['const']; + } + + if (null !== $default && !\in_array($default, $consts, true)) { + throw new InvalidArgumentException(\sprintf('Default value "%s" is not in the oneOf const values.', $default)); + } + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + $data = [ + 'type' => 'string', + 'title' => $this->title, + 'oneOf' => $this->oneOf, + ]; + + if (null !== $this->description) { + $data['description'] = $this->description; + } + + if (null !== $this->default) { + $data['default'] = $this->default; + } + + return $data; + } +} diff --git a/src/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinition.php b/src/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinition.php new file mode 100644 index 00000000..c5827a88 --- /dev/null +++ b/src/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinition.php @@ -0,0 +1,72 @@ + $anyOf Array of const/title pairs + * @param string|null $description Optional description/help text + */ + public function __construct( + string $title, + public readonly array $anyOf, + ?string $description = null, + ) { + parent::__construct($title, $description); + + if ([] === $anyOf) { + throw new InvalidArgumentException('anyOf array must not be empty.'); + } + + foreach ($anyOf as $item) { + if (!isset($item['const']) || !\is_string($item['const'])) { + throw new InvalidArgumentException('Each anyOf item must have a string "const" property.'); + } + if (!isset($item['title']) || !\is_string($item['title'])) { + throw new InvalidArgumentException('Each anyOf item must have a string "title" property.'); + } + } + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + $data = [ + 'type' => 'array', + 'title' => $this->title, + 'items' => [ + 'anyOf' => $this->anyOf, + ], + ]; + + if (null !== $this->description) { + $data['description'] = $this->description; + } + + return $data; + } +} diff --git a/tests/Conformance/Elements.php b/tests/Conformance/Elements.php index 8256a37b..6d65fd22 100644 --- a/tests/Conformance/Elements.php +++ b/tests/Conformance/Elements.php @@ -16,6 +16,14 @@ use Mcp\Schema\Content\PromptMessage; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Content\TextResourceContents; +use Mcp\Schema\Elicitation\BooleanSchemaDefinition; +use Mcp\Schema\Elicitation\ElicitationSchema; +use Mcp\Schema\Elicitation\EnumSchemaDefinition; +use Mcp\Schema\Elicitation\MultiSelectEnumSchemaDefinition; +use Mcp\Schema\Elicitation\NumberSchemaDefinition; +use Mcp\Schema\Elicitation\StringSchemaDefinition; +use Mcp\Schema\Elicitation\TitledEnumSchemaDefinition; +use Mcp\Schema\Elicitation\TitledMultiSelectEnumSchemaDefinition; use Mcp\Schema\Enum\Role; use Mcp\Schema\Result\CallToolResult; use Mcp\Server\Protocol; @@ -76,6 +84,65 @@ public function toolWithSampling(RequestContext $context, string $prompt): strin ); } + /** + * @param string $message The message to display to the user + */ + public function toolWithElicitation(RequestContext $context, string $message): string + { + $schema = new ElicitationSchema( + properties: [ + 'username' => new StringSchemaDefinition('Username'), + 'email' => new StringSchemaDefinition('Email'), + ], + ); + + $context->getClientGateway()->elicit($message, $schema); + + return 'ok'; + } + + public function toolWithElicitationDefaults(RequestContext $context): string + { + $schema = new ElicitationSchema( + properties: [ + 'name' => new StringSchemaDefinition('Name', default: 'John Doe'), + 'age' => new NumberSchemaDefinition('Age', integerOnly: true, default: 30), + 'score' => new NumberSchemaDefinition('Score', default: 95.5), + 'status' => new EnumSchemaDefinition('Status', enum: ['active', 'inactive', 'pending'], default: 'active'), + 'verified' => new BooleanSchemaDefinition('Verified', default: true), + ], + ); + + $context->getClientGateway()->elicit('Provide profile information', $schema); + + return 'ok'; + } + + public function toolWithElicitationEnums(RequestContext $context): string + { + $schema = new ElicitationSchema( + properties: [ + 'untitledSingle' => new EnumSchemaDefinition('Untitled Single', enum: ['option1', 'option2', 'option3']), + 'titledSingle' => new TitledEnumSchemaDefinition('Titled Single', oneOf: [ + ['const' => 'value1', 'title' => 'Label 1'], + ['const' => 'value2', 'title' => 'Label 2'], + ['const' => 'value3', 'title' => 'Label 3'], + ]), + 'legacyEnum' => new EnumSchemaDefinition('Legacy Enum', enum: ['opt1', 'opt2', 'opt3'], enumNames: ['Option 1', 'Option 2', 'Option 3']), + 'untitledMulti' => new MultiSelectEnumSchemaDefinition('Untitled Multi', enum: ['option1', 'option2', 'option3']), + 'titledMulti' => new TitledMultiSelectEnumSchemaDefinition('Titled Multi', anyOf: [ + ['const' => 'value1', 'title' => 'Label 1'], + ['const' => 'value2', 'title' => 'Label 2'], + ['const' => 'value3', 'title' => 'Label 3'], + ]), + ], + ); + + $context->getClientGateway()->elicit('Select options', $schema); + + return 'ok'; + } + public function resourceTemplate(string $id): TextResourceContents { return new TextResourceContents( diff --git a/tests/Conformance/conformance-baseline.yml b/tests/Conformance/conformance-baseline.yml index 2613c0d4..de676e85 100644 --- a/tests/Conformance/conformance-baseline.yml +++ b/tests/Conformance/conformance-baseline.yml @@ -1,5 +1,3 @@ server: - - tools-call-elicitation - - elicitation-sep1034-defaults - - elicitation-sep1330-enums - dns-rebinding-protection + diff --git a/tests/Conformance/server.php b/tests/Conformance/server.php index 1b69b8f0..35855f65 100644 --- a/tests/Conformance/server.php +++ b/tests/Conformance/server.php @@ -49,6 +49,9 @@ ->addTool([Elements::class, 'toolWithProgress'], 'test_tool_with_progress', 'Tests tool that reports progress notifications') ->addTool([Elements::class, 'toolWithSampling'], 'test_sampling', 'Tests server-initiated sampling') ->addTool(static fn () => CallToolResult::error([new TextContent('This tool intentionally returns an error for testing')]), 'test_error_handling', 'Tests error response handling') + ->addTool([Elements::class, 'toolWithElicitation'], 'test_elicitation', 'Tests server-initiated elicitation') + ->addTool([Elements::class, 'toolWithElicitationDefaults'], 'test_elicitation_sep1034_defaults', 'Tests elicitation with default values') + ->addTool([Elements::class, 'toolWithElicitationEnums'], 'test_elicitation_sep1330_enums', 'Tests elicitation with enum schemas') // Resources ->addResource(static fn () => 'This is the content of the static text resource.', 'test://static-text', 'static-text', 'A static text resource for testing') ->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')