Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
* Add built-in authentication middleware for HTTP transport using OAuth
* Add client component for building MCP clients
* Add `Builder::setReferenceHandler()` to allow custom `ReferenceHandlerInterface` implementations (e.g. authorization decorators)
* Add elicitation enum schema types per SEP-1330: `TitledEnumSchemaDefinition`, `MultiSelectEnumSchemaDefinition`, `TitledMultiSelectEnumSchemaDefinition`

0.4.0
-----
Expand Down
58 changes: 55 additions & 3 deletions src/Schema/Elicitation/ElicitationSchema.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@
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,
Expand Down Expand Up @@ -67,7 +67,7 @@ public static function fromArray(array $data): self
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(
Expand All @@ -76,6 +76,58 @@ public static function fromArray(array $data): self
);
}

/**
* 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,
Expand Down
127 changes: 127 additions & 0 deletions src/Schema/Elicitation/MultiSelectEnumSchemaDefinition.php
Original file line number Diff line number Diff line change
@@ -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;
}
}
57 changes: 0 additions & 57 deletions src/Schema/Elicitation/PrimitiveSchemaDefinition.php

This file was deleted.

98 changes: 98 additions & 0 deletions src/Schema/Elicitation/TitledEnumSchemaDefinition.php
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading