Extend elicitation enum schema compliance (#261)

This commit is contained in:
Christopher Hertel 2026-03-22 18:46:40 +01:00 • committed by GitHub
commit e8f5a8ba22
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 1252 additions and 178 deletions

View file

@ -25,8 +25,8 @@ use Mcp\Exception\InvalidArgumentException;
final class ElicitationSchema implements \JsonSerializable
{
/**
* @param array<string, StringSchemaDefinition|NumberSchemaDefinition|BooleanSchemaDefinition|EnumSchemaDefinition> $properties Property definitions keyed by name
* @param string[] $required Array of required property names
* @param array<string, AbstractSchemaDefinition> $properties Property definitions keyed by name
* @param string[] $required Array of required property names
*/
public function __construct(
public readonly array $properties,
@ -67,7 +67,7 @@ final class ElicitationSchema implements \JsonSerializable
if (!\is_array($propertyData)) {
throw new InvalidArgumentException(\sprintf('Property "%s" must be an array.', $name));
}
$properties[$name] = PrimitiveSchemaDefinition::fromArray($propertyData);
$properties[$name] = self::createSchemaDefinition($propertyData);
}
return new self(
@ -76,6 +76,58 @@ final class ElicitationSchema implements \JsonSerializable
);
}
/**
* Create a schema definition from array data.
*
* @param array<string, mixed> $data
*/
private static function createSchemaDefinition(array $data): AbstractSchemaDefinition
{
if (!isset($data['type']) || !\is_string($data['type'])) {
throw new InvalidArgumentException('Missing or invalid "type" for schema definition.');
}
return match ($data['type']) {
'string' => self::resolveStringType($data),
'integer', 'number' => NumberSchemaDefinition::fromArray($data),
'boolean' => BooleanSchemaDefinition::fromArray($data),
'array' => self::resolveArrayType($data),
default => throw new InvalidArgumentException(\sprintf('Unsupported type "%s". Supported types are: string, integer, number, boolean, array.', $data['type'])),
};
}
/**
* @param array<string, mixed> $data
*/
private static function resolveStringType(array $data): AbstractSchemaDefinition
{
if (isset($data['oneOf'])) {
return TitledEnumSchemaDefinition::fromArray($data);
}
if (isset($data['enum'])) {
return EnumSchemaDefinition::fromArray($data);
}
return StringSchemaDefinition::fromArray($data);
}
/**
* @param array<string, mixed> $data
*/
private static function resolveArrayType(array $data): AbstractSchemaDefinition
{
if (isset($data['items']['anyOf'])) {
return TitledMultiSelectEnumSchemaDefinition::fromArray($data);
}
if (isset($data['items']['enum'])) {
return MultiSelectEnumSchemaDefinition::fromArray($data);
}
throw new InvalidArgumentException('Array type must have "items" with either "enum" or "anyOf".');
}
/**
* @return array{
* type: string,

View file

@ -0,0 +1,127 @@
<?php
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Schema\Elicitation;
use Mcp\Exception\InvalidArgumentException;
/**
* Schema definition for multi-select enum fields without titles (SEP-1330).
*
* Produces: {"type": "array", "items": {"type": "string", "enum": [...]}}
*
* @see https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1330
*/
final class MultiSelectEnumSchemaDefinition extends AbstractSchemaDefinition
{
/**
* @param string $title Human-readable title for the field
* @param string[] $enum Array of allowed string values
* @param string|null $description Optional description/help text
* @param string[]|null $default Optional default selected values (must be subset of enum)
* @param int|null $minItems Optional minimum number of selections
* @param int|null $maxItems Optional maximum number of selections
*/
public function __construct(
string $title,
public readonly array $enum,
?string $description = null,
public readonly ?array $default = null,
public readonly ?int $minItems = null,
public readonly ?int $maxItems = null,
) {
parent::__construct($title, $description);
if ([] === $enum) {
throw new InvalidArgumentException('enum array must not be empty.');
}
foreach ($enum as $value) {
if (!\is_string($value)) {
throw new InvalidArgumentException('All enum values must be strings.');
}
}
if (null !== $minItems && $minItems < 0) {
throw new InvalidArgumentException('minItems must be non-negative.');
}
if (null !== $maxItems && $maxItems < 0) {
throw new InvalidArgumentException('maxItems must be non-negative.');
}
if (null !== $minItems && null !== $maxItems && $minItems > $maxItems) {
throw new InvalidArgumentException('minItems cannot be greater than maxItems.');
}
if (null !== $default) {
foreach ($default as $value) {
if (!\in_array($value, $enum, true)) {
throw new InvalidArgumentException(\sprintf('Default value "%s" is not in the enum array.', $value));
}
}
}
}
/**
* @param array{
* title: string,
* items: array{type: string, enum: string[]},
* description?: string,
* default?: string[],
* minItems?: int,
* maxItems?: int,
* } $data
*/
public static function fromArray(array $data): self
{
self::validateTitle($data, 'multi-select enum');
if (!isset($data['items']['enum']) || !\is_array($data['items']['enum'])) {
throw new InvalidArgumentException('Missing or invalid "items.enum" for multi-select enum schema definition.');
}
return new self(
title: $data['title'],
enum: $data['items']['enum'],
description: $data['description'] ?? null,
default: $data['default'] ?? null,
minItems: isset($data['minItems']) ? (int) $data['minItems'] : null,
maxItems: isset($data['maxItems']) ? (int) $data['maxItems'] : null,
);
}
/**
* @return array<string, mixed>
*/
public function jsonSerialize(): array
{
$data = $this->buildBaseJson('array');
$data['items'] = [
'type' => 'string',
'enum' => $this->enum,
];
if (null !== $this->default) {
$data['default'] = $this->default;
}
if (null !== $this->minItems) {
$data['minItems'] = $this->minItems;
}
if (null !== $this->maxItems) {
$data['maxItems'] = $this->maxItems;
}
return $data;
}
}

View file

@ -1,57 +0,0 @@
<?php
declare(strict_types=1);
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Schema\Elicitation;
use Mcp\Exception\InvalidArgumentException;
/**
* Factory class for creating primitive schema definitions from array data.
*
* Dispatches to the correct schema definition class based on the "type" field.
*
* @author Johannes Wachter <johannes@sulu.io>
*/
final class PrimitiveSchemaDefinition
{
/**
* Create a schema definition from array data.
*
* @param array{
* type: string,
* title: string,
* description?: string,
* default?: mixed,
* enum?: string[],
* enumNames?: string[],
* format?: string,
* minLength?: int,
* maxLength?: int,
* minimum?: int|float,
* maximum?: int|float,
* } $data
*/
public static function fromArray(array $data): StringSchemaDefinition|NumberSchemaDefinition|BooleanSchemaDefinition|EnumSchemaDefinition
{
if (!isset($data['type']) || !\is_string($data['type'])) {
throw new InvalidArgumentException('Missing or invalid "type" for primitive schema definition.');
}
return match ($data['type']) {
'string' => isset($data['enum']) ? EnumSchemaDefinition::fromArray($data) : StringSchemaDefinition::fromArray($data),
'integer', 'number' => NumberSchemaDefinition::fromArray($data),
'boolean' => BooleanSchemaDefinition::fromArray($data),
default => throw new InvalidArgumentException(\sprintf('Unsupported primitive type "%s". Supported types are: string, integer, number, boolean.', $data['type'])),
};
}
}

View file

@ -0,0 +1,98 @@
<?php
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Schema\Elicitation;
use Mcp\Exception\InvalidArgumentException;
/**
* Schema definition for single-select enum fields with titled options (SEP-1330).
*
* Uses the oneOf pattern with const/title pairs instead of enum/enumNames.
* Produces: {"type": "string", "oneOf": [{"const": "value", "title": "Label"}, ...]}
*
* @see https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1330
*/
final class TitledEnumSchemaDefinition extends AbstractSchemaDefinition
{
/**
* @param string $title Human-readable title for the field
* @param list<array{const: string, title: string}> $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));
}
}
/**
* @param array{
* title: string,
* oneOf: list<array{const: string, title: string}>,
* description?: string,
* default?: string,
* } $data
*/
public static function fromArray(array $data): self
{
self::validateTitle($data, 'titled enum');
if (!isset($data['oneOf']) || !\is_array($data['oneOf'])) {
throw new InvalidArgumentException('Missing or invalid "oneOf" for titled enum schema definition.');
}
return new self(
title: $data['title'],
oneOf: $data['oneOf'],
description: $data['description'] ?? null,
default: $data['default'] ?? null,
);
}
/**
* @return array<string, mixed>
*/
public function jsonSerialize(): array
{
$data = $this->buildBaseJson('string');
$data['oneOf'] = $this->oneOf;
if (null !== $this->default) {
$data['default'] = $this->default;
}
return $data;
}
}

View file

@ -0,0 +1,131 @@
<?php
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Schema\Elicitation;
use Mcp\Exception\InvalidArgumentException;
/**
* Schema definition for multi-select enum fields with titled options (SEP-1330).
*
* Produces: {"type": "array", "items": {"anyOf": [{"const": "value", "title": "Label"}, ...]}}
*
* @see https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1330
*/
final class TitledMultiSelectEnumSchemaDefinition extends AbstractSchemaDefinition
{
/**
* @param string $title Human-readable title for the field
* @param list<array{const: string, title: string}> $anyOf Array of const/title pairs
* @param string|null $description Optional description/help text
* @param string[]|null $default Optional default selected values (must be subset of anyOf consts)
* @param int|null $minItems Optional minimum number of selections
* @param int|null $maxItems Optional maximum number of selections
*/
public function __construct(
string $title,
public readonly array $anyOf,
?string $description = null,
public readonly ?array $default = null,
public readonly ?int $minItems = null,
public readonly ?int $maxItems = null,
) {
parent::__construct($title, $description);
if ([] === $anyOf) {
throw new InvalidArgumentException('anyOf array must not be empty.');
}
$consts = [];
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.');
}
$consts[] = $item['const'];
}
if (null !== $minItems && $minItems < 0) {
throw new InvalidArgumentException('minItems must be non-negative.');
}
if (null !== $maxItems && $maxItems < 0) {
throw new InvalidArgumentException('maxItems must be non-negative.');
}
if (null !== $minItems && null !== $maxItems && $minItems > $maxItems) {
throw new InvalidArgumentException('minItems cannot be greater than maxItems.');
}
if (null !== $default) {
foreach ($default as $value) {
if (!\in_array($value, $consts, true)) {
throw new InvalidArgumentException(\sprintf('Default value "%s" is not in the anyOf const values.', $value));
}
}
}
}
/**
* @param array{
* title: string,
* items: array{anyOf: list<array{const: string, title: string}>},
* description?: string,
* default?: string[],
* minItems?: int,
* maxItems?: int,
* } $data
*/
public static function fromArray(array $data): self
{
self::validateTitle($data, 'titled multi-select enum');
if (!isset($data['items']['anyOf']) || !\is_array($data['items']['anyOf'])) {
throw new InvalidArgumentException('Missing or invalid "items.anyOf" for titled multi-select enum schema definition.');
}
return new self(
title: $data['title'],
anyOf: $data['items']['anyOf'],
description: $data['description'] ?? null,
default: $data['default'] ?? null,
minItems: isset($data['minItems']) ? (int) $data['minItems'] : null,
maxItems: isset($data['maxItems']) ? (int) $data['maxItems'] : null,
);
}
/**
* @return array<string, mixed>
*/
public function jsonSerialize(): array
{
$data = $this->buildBaseJson('array');
$data['items'] = [
'anyOf' => $this->anyOf,
];
if (null !== $this->default) {
$data['default'] = $this->default;
}
if (null !== $this->minItems) {
$data['minItems'] = $this->minItems;
}
if (null !== $this->maxItems) {
$data['maxItems'] = $this->maxItems;
}
return $data;
}
}