Skip to content
Draft
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
4 changes: 2 additions & 2 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
*/
Comment on lines +28 to 30
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The constructor PHPDoc now allows any AbstractSchemaDefinition, but ElicitationSchema::fromArray still delegates to PrimitiveSchemaDefinition::fromArray which currently rejects type: array and cannot recognize oneOf/anyOf enum shapes. This means a client deserializing an elicitation/create request containing SEP-1330 enums (or multi-select arrays) will either throw (array type) or silently downgrade titled enums to a plain StringSchemaDefinition. Consider extending the parsing path (PrimitiveSchemaDefinition + related fromArray implementations) to support these new schema shapes and updating the schema-level tests accordingly.

Copilot uses AI. Check for mistakes.
public function __construct(
public readonly array $properties,
Expand Down
70 changes: 70 additions & 0 deletions src/Schema/Elicitation/MultiSelectEnumSchemaDefinition.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?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;

/**
* 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
*/
public function __construct(
string $title,
public readonly array $enum,
?string $description = null,
) {
parent::__construct($title, $description);

Comment on lines +25 to +38
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This schema definition doesn’t provide a fromArray() constructor, and the current PrimitiveSchemaDefinition::fromArray rejects type: array, so a client deserializing a multi-select enum schema will throw. Consider adding fromArray() here and extending the primitive schema factory to recognize {type: "array", items: {type: "string", enum: [...]}} as a MultiSelectEnumSchemaDefinition.

Copilot uses AI. Check for mistakes.
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.');
}
}
}

/**
* @return array<string, mixed>
*/
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;
}
}
83 changes: 83 additions & 0 deletions src/Schema/Elicitation/TitledEnumSchemaDefinition.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?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;

/**
* 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);
Comment on lines +28 to +40
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This schema definition doesn’t provide a fromArray() constructor, and the current PrimitiveSchemaDefinition::fromArray logic will parse a {type: "string", oneOf: [...]} schema as a StringSchemaDefinition (losing the enum options). To keep request deserialization symmetric with serialization, add a fromArray() here and update the primitive schema factory to detect the SEP-1330 titled enum shape (e.g., presence of oneOf with const/title) and return this type.

Copilot uses AI. Check for mistakes.

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<string, mixed>
*/
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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?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;

/**
* 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
{
/**
Comment on lines +23 to +27
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is an established unit test suite for elicitation schema (tests/Unit/Schema/Elicitation/*), but the new SEP-1330 schema definitions introduced here aren’t covered. Adding tests for jsonSerialize() output and for round-tripping via ElicitationSchema::fromArray / PrimitiveSchemaDefinition::fromArray would help prevent regressions and ensure both client and server can handle the new schema shapes consistently.

Copilot uses AI. Check for mistakes.
* @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
*/
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.');
}
Comment on lines +25 to +41
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This schema definition doesn’t provide a fromArray() constructor, and the current PrimitiveSchemaDefinition::fromArray rejects type: array, so a client deserializing {type:"array", items:{anyOf:[...]}} will throw. Consider adding fromArray() here and extending the primitive schema factory to detect the SEP-1330 titled multi-select shape (array + items.anyOf with const/title pairs) and return this type.

Copilot uses AI. Check for mistakes.

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<string, mixed>
*/
public function jsonSerialize(): array
{
$data = [
'type' => 'array',
'title' => $this->title,
'items' => [
'anyOf' => $this->anyOf,
],
];

if (null !== $this->description) {
$data['description'] = $this->description;
}

return $data;
}
}
67 changes: 67 additions & 0 deletions tests/Conformance/Elements.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 1 addition & 3 deletions tests/Conformance/conformance-baseline.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
server:
- tools-call-elicitation
- elicitation-sep1034-defaults
- elicitation-sep1330-enums
- dns-rebinding-protection

3 changes: 3 additions & 0 deletions tests/Conformance/server.php
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
Loading