[Schema] Stop rejecting elicitation schemas the specification allows (#437)

* [Schema] Stop rejecting elicitation schemas the specification allows

The specification makes `title` optional on every elicitation schema
definition. AbstractSchemaDefinition required it, so a client reading a
conformant server's elicitation/create refused the request - and a server
building one could not omit it either.

`title` is now nullable on all seven definitions, omitted from the
serialized form when absent, and rejected only when present and not a
string.

* Fix stale title docblocks; allow omitting title where safe

* Give Number's title param a default too

* Apply suggestions from code review

Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>
This commit is contained in:
Christopher Hertel 2026-08-18 19:49:15 +02:00 • committed by GitHub
commit cf2c4d4ead
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 144 additions and 65 deletions

View file

@ -21,13 +21,13 @@ use Mcp\Exception\InvalidArgumentException;
abstract class AbstractSchemaDefinition implements \JsonSerializable
{
public function __construct(
public readonly string $title,
public readonly ?string $title = null,
public readonly ?string $description = null,
) {
}
/**
* Validate that title exists and is a string in the data array.
* Reject a title that is present but not a string.
*
* @param array<string, mixed> $data
*
@ -35,22 +35,23 @@ abstract class AbstractSchemaDefinition implements \JsonSerializable
*/
protected static function validateTitle(array $data, string $schemaType): void
{
if (!isset($data['title']) || !\is_string($data['title'])) {
throw new InvalidArgumentException(\sprintf('Missing or invalid "title" for %s schema definition.', $schemaType));
if (\array_key_exists('title', $data) && null !== $data['title'] && !\is_string($data['title'])) {
throw new InvalidArgumentException(\sprintf('Invalid "title" for %s schema definition.', $schemaType));
}
}
/**
* Build the base JSON structure with type, title, and optional description.
* Build the base JSON structure with type, optional title and description.
*
* @return array<string, mixed>
*/
protected function buildBaseJson(string $type): array
{
$data = [
'type' => $type,
'title' => $this->title,
];
$data = ['type' => $type];
if (null !== $this->title) {
$data['title'] = $this->title;
}
if (null !== $this->description) {
$data['description'] = $this->description;

View file

@ -19,12 +19,12 @@ namespace Mcp\Schema\Elicitation;
final class BooleanSchemaDefinition extends AbstractSchemaDefinition
{
/**
* @param string $title Human-readable title for the field
* @param ?string $title Optional human-readable title for the field
* @param string|null $description Optional description/help text
* @param bool|null $default Optional default value
*/
public function __construct(
string $title,
?string $title = null,
?string $description = null,
public readonly ?bool $default = null,
) {
@ -33,7 +33,7 @@ final class BooleanSchemaDefinition extends AbstractSchemaDefinition
/**
* @param array{
* title: string,
* title?: string,
* description?: string,
* default?: bool,
* } $data
@ -43,7 +43,7 @@ final class BooleanSchemaDefinition extends AbstractSchemaDefinition
self::validateTitle($data, 'boolean');
return new self(
title: $data['title'],
title: $data['title'] ?? null,
description: $data['description'] ?? null,
default: isset($data['default']) ? (bool) $data['default'] : null,
);
@ -52,7 +52,7 @@ final class BooleanSchemaDefinition extends AbstractSchemaDefinition
/**
* @return array{
* type: string,
* title: string,
* title?: string,
* description?: string,
* default?: bool,
* }

View file

@ -23,14 +23,14 @@ use Mcp\Exception\InvalidArgumentException;
final class EnumSchemaDefinition extends AbstractSchemaDefinition
{
/**
* @param string $title Human-readable title for the field
* @param ?string $title Optional 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 value (must be in enum)
* @param string[]|null $enumNames Optional human-readable labels for each enum value
*/
public function __construct(
string $title,
?string $title,
public readonly array $enum,
?string $description = null,
public readonly ?string $default = null,
@ -59,7 +59,7 @@ final class EnumSchemaDefinition extends AbstractSchemaDefinition
/**
* @param array{
* title: string,
* title?: string,
* enum: string[],
* description?: string,
* default?: string,
@ -75,7 +75,7 @@ final class EnumSchemaDefinition extends AbstractSchemaDefinition
}
return new self(
title: $data['title'],
title: $data['title'] ?? null,
enum: $data['enum'],
description: $data['description'] ?? null,
default: $data['default'] ?? null,
@ -86,7 +86,7 @@ final class EnumSchemaDefinition extends AbstractSchemaDefinition
/**
* @return array{
* type: string,
* title: string,
* title?: string,
* enum: string[],
* description?: string,
* default?: string,
@ -95,11 +95,13 @@ final class EnumSchemaDefinition extends AbstractSchemaDefinition
*/
public function jsonSerialize(): array
{
$data = [
'type' => 'string',
'title' => $this->title,
'enum' => $this->enum,
];
$data = ['type' => 'string'];
if (null !== $this->title) {
$data['title'] = $this->title;
}
$data['enum'] = $this->enum;
if (null !== $this->description) {
$data['description'] = $this->description;

View file

@ -23,7 +23,7 @@ use Mcp\Exception\InvalidArgumentException;
final class MultiSelectEnumSchemaDefinition extends AbstractSchemaDefinition
{
/**
* @param string $title Human-readable title for the field
* @param ?string $title Optional 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)
@ -31,7 +31,7 @@ final class MultiSelectEnumSchemaDefinition extends AbstractSchemaDefinition
* @param int|null $maxItems Optional maximum number of selections
*/
public function __construct(
string $title,
?string $title,
public readonly array $enum,
?string $description = null,
public readonly ?array $default = null,
@ -73,7 +73,7 @@ final class MultiSelectEnumSchemaDefinition extends AbstractSchemaDefinition
/**
* @param array{
* title: string,
* title?: string,
* items: array{type: string, enum: string[]},
* description?: string,
* default?: string[],
@ -90,7 +90,7 @@ final class MultiSelectEnumSchemaDefinition extends AbstractSchemaDefinition
}
return new self(
title: $data['title'],
title: $data['title'] ?? null,
enum: $data['items']['enum'],
description: $data['description'] ?? null,
default: $data['default'] ?? null,

View file

@ -23,7 +23,7 @@ use Mcp\Exception\InvalidArgumentException;
final class NumberSchemaDefinition extends AbstractSchemaDefinition
{
/**
* @param string $title Human-readable title for the field
* @param ?string $title Optional human-readable title for the field
* @param bool $integerOnly Whether to restrict to integer values only
* @param string|null $description Optional description/help text
* @param int|float|null $default Optional default value
@ -31,7 +31,7 @@ final class NumberSchemaDefinition extends AbstractSchemaDefinition
* @param int|float|null $maximum Optional maximum value (inclusive)
*/
public function __construct(
string $title,
?string $title = null,
public readonly bool $integerOnly = false,
?string $description = null,
public readonly int|float|null $default = null,
@ -60,7 +60,7 @@ final class NumberSchemaDefinition extends AbstractSchemaDefinition
/**
* @param array{
* type: string,
* title: string,
* title?: string,
* description?: string,
* default?: int|float,
* minimum?: int|float,
@ -75,7 +75,7 @@ final class NumberSchemaDefinition extends AbstractSchemaDefinition
$integerOnly = 'integer' === $type;
return new self(
title: $data['title'],
title: $data['title'] ?? null,
integerOnly: $integerOnly,
description: $data['description'] ?? null,
default: $data['default'] ?? null,
@ -87,7 +87,7 @@ final class NumberSchemaDefinition extends AbstractSchemaDefinition
/**
* @return array{
* type: string,
* title: string,
* title?: string,
* description?: string,
* default?: int|float,
* minimum?: int|float,

View file

@ -25,7 +25,7 @@ final class StringSchemaDefinition extends AbstractSchemaDefinition
private const VALID_FORMATS = ['date', 'date-time', 'email', 'uri'];
/**
* @param string $title Human-readable title for the field
* @param ?string $title Optional human-readable title for the field
* @param string|null $description Optional description/help text
* @param string|null $default Optional default value
* @param string|null $format Optional format constraint (date, date-time, email, uri)
@ -33,7 +33,7 @@ final class StringSchemaDefinition extends AbstractSchemaDefinition
* @param int|null $maxLength Optional maximum string length
*/
public function __construct(
string $title,
?string $title = null,
?string $description = null,
public readonly ?string $default = null,
public readonly ?string $format = null,
@ -61,7 +61,7 @@ final class StringSchemaDefinition extends AbstractSchemaDefinition
/**
* @param array{
* title: string,
* title?: string,
* description?: string,
* default?: string,
* format?: string,
@ -74,7 +74,7 @@ final class StringSchemaDefinition extends AbstractSchemaDefinition
self::validateTitle($data, 'string');
return new self(
title: $data['title'],
title: $data['title'] ?? null,
description: $data['description'] ?? null,
default: $data['default'] ?? null,
format: $data['format'] ?? null,
@ -86,7 +86,7 @@ final class StringSchemaDefinition extends AbstractSchemaDefinition
/**
* @return array{
* type: string,
* title: string,
* title?: string,
* description?: string,
* default?: string,
* format?: string,

View file

@ -24,13 +24,13 @@ use Mcp\Exception\InvalidArgumentException;
final class TitledEnumSchemaDefinition extends AbstractSchemaDefinition
{
/**
* @param string $title Human-readable title for the field
* @param ?string $title Optional 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,
?string $title,
public readonly array $oneOf,
?string $description = null,
public readonly ?string $default = null,
@ -59,7 +59,7 @@ final class TitledEnumSchemaDefinition extends AbstractSchemaDefinition
/**
* @param array{
* title: string,
* title?: string,
* oneOf: list<array{const: string, title: string}>,
* description?: string,
* default?: string,
@ -74,7 +74,7 @@ final class TitledEnumSchemaDefinition extends AbstractSchemaDefinition
}
return new self(
title: $data['title'],
title: $data['title'] ?? null,
oneOf: $data['oneOf'],
description: $data['description'] ?? null,
default: $data['default'] ?? null,

View file

@ -23,7 +23,7 @@ use Mcp\Exception\InvalidArgumentException;
final class TitledMultiSelectEnumSchemaDefinition extends AbstractSchemaDefinition
{
/**
* @param string $title Human-readable title for the field
* @param ?string $title Optional 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)
@ -31,7 +31,7 @@ final class TitledMultiSelectEnumSchemaDefinition extends AbstractSchemaDefiniti
* @param int|null $maxItems Optional maximum number of selections
*/
public function __construct(
string $title,
?string $title,
public readonly array $anyOf,
?string $description = null,
public readonly ?array $default = null,
@ -78,7 +78,7 @@ final class TitledMultiSelectEnumSchemaDefinition extends AbstractSchemaDefiniti
/**
* @param array{
* title: string,
* title?: string,
* items: array{anyOf: list<array{const: string, title: string}>},
* description?: string,
* default?: string[],
@ -95,7 +95,7 @@ final class TitledMultiSelectEnumSchemaDefinition extends AbstractSchemaDefiniti
}
return new self(
title: $data['title'],
title: $data['title'] ?? null,
anyOf: $data['items']['anyOf'],
description: $data['description'] ?? null,
default: $data['default'] ?? null,

View file

@ -71,13 +71,23 @@ final class BooleanSchemaDefinitionTest extends TestCase
$this->assertTrue($schema->default);
}
public function testFromArrayWithMissingTitle(): void
public function testFromArrayWithoutTitleIsAccepted(): void
{
// `title` is optional in the specification, so a server that omits it
// must still be readable.
$schema = BooleanSchemaDefinition::fromArray([]);
$this->assertNull($schema->title);
$this->assertArrayNotHasKey('title', $schema->jsonSerialize());
}
public function testFromArrayRejectsNonStringTitle(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Missing or invalid "title"');
$this->expectExceptionMessage('Invalid "title" for boolean schema definition.');
/* @phpstan-ignore argument.type */
BooleanSchemaDefinition::fromArray([]);
BooleanSchemaDefinition::fromArray(['title' => 42]);
}
public function testJsonSerializeWithMinimalParams(): void

View file

@ -114,13 +114,23 @@ final class EnumSchemaDefinitionTest extends TestCase
$this->assertSame(['Poor', 'Fair', 'Good'], $schema->enumNames);
}
public function testFromArrayWithMissingTitle(): void
public function testFromArrayWithoutTitleIsAccepted(): void
{
// `title` is optional in the specification, so a server that omits it
// must still be readable.
$schema = EnumSchemaDefinition::fromArray(['enum' => ['a', 'b']]);
$this->assertNull($schema->title);
$this->assertArrayNotHasKey('title', $schema->jsonSerialize());
}
public function testFromArrayRejectsNonStringTitle(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Missing or invalid "title"');
$this->expectExceptionMessage('Invalid "title" for enum schema definition.');
/* @phpstan-ignore argument.type */
EnumSchemaDefinition::fromArray(['enum' => ['a', 'b']]);
EnumSchemaDefinition::fromArray(['title' => 42]);
}
public function testFromArrayWithMissingEnum(): void

View file

@ -137,13 +137,26 @@ final class MultiSelectEnumSchemaDefinitionTest extends TestCase
$this->assertSame(3, $schema->maxItems);
}
public function testFromArrayWithMissingTitle(): void
public function testFromArrayWithoutTitleIsAccepted(): void
{
// `title` is optional in the specification, so a server that omits it
// must still be readable.
$schema = MultiSelectEnumSchemaDefinition::fromArray([
'items' => ['type' => 'string', 'enum' => ['a']],
]);
$this->assertNull($schema->title);
$this->assertArrayNotHasKey('title', $schema->jsonSerialize());
}
public function testFromArrayRejectsNonStringTitle(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Missing or invalid "title"');
$this->expectExceptionMessage('Invalid "title" for multi-select enum schema definition.');
/* @phpstan-ignore argument.type */
MultiSelectEnumSchemaDefinition::fromArray([
'title' => 42,
'items' => ['type' => 'string', 'enum' => ['a']],
]);
}

View file

@ -133,13 +133,23 @@ final class NumberSchemaDefinitionTest extends TestCase
$this->assertSame(10, $schema->maximum);
}
public function testFromArrayWithMissingTitle(): void
public function testFromArrayWithoutTitleIsAccepted(): void
{
// `title` is optional in the specification, so a server that omits it
// must still be readable.
$schema = NumberSchemaDefinition::fromArray(['type' => 'integer']);
$this->assertNull($schema->title);
$this->assertArrayNotHasKey('title', $schema->jsonSerialize());
}
public function testFromArrayRejectsNonStringTitle(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Missing or invalid "title"');
$this->expectExceptionMessage('Invalid "title" for number schema definition.');
/* @phpstan-ignore argument.type */
NumberSchemaDefinition::fromArray(['type' => 'integer']);
NumberSchemaDefinition::fromArray(['title' => 42]);
}
public function testJsonSerializeAsInteger(): void

View file

@ -114,13 +114,23 @@ final class StringSchemaDefinitionTest extends TestCase
$this->assertSame(100, $schema->maxLength);
}
public function testFromArrayWithMissingTitle(): void
public function testFromArrayWithoutTitleIsAccepted(): void
{
// `title` is optional in the specification, so a server that omits it
// must still be readable.
$schema = StringSchemaDefinition::fromArray([]);
$this->assertNull($schema->title);
$this->assertArrayNotHasKey('title', $schema->jsonSerialize());
}
public function testFromArrayRejectsNonStringTitle(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Missing or invalid "title"');
$this->expectExceptionMessage('Invalid "title" for string schema definition.');
/* @phpstan-ignore argument.type */
StringSchemaDefinition::fromArray([]);
StringSchemaDefinition::fromArray(['title' => 42]);
}
public function testJsonSerializeWithMinimalParams(): void

View file

@ -124,13 +124,23 @@ final class TitledEnumSchemaDefinitionTest extends TestCase
$this->assertSame('b', $schema->default);
}
public function testFromArrayWithMissingTitle(): void
public function testFromArrayWithoutTitleIsAccepted(): void
{
// `title` is optional in the specification, so a server that omits it
// must still be readable.
$schema = TitledEnumSchemaDefinition::fromArray(['oneOf' => [['const' => 'a', 'title' => 'A']]]);
$this->assertNull($schema->title);
$this->assertArrayNotHasKey('title', $schema->jsonSerialize());
}
public function testFromArrayRejectsNonStringTitle(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Missing or invalid "title"');
$this->expectExceptionMessage('Invalid "title" for titled enum schema definition.');
/* @phpstan-ignore argument.type */
TitledEnumSchemaDefinition::fromArray(['oneOf' => [['const' => 'a', 'title' => 'A']]]);
TitledEnumSchemaDefinition::fromArray(['title' => 42]);
}
public function testFromArrayWithMissingOneOf(): void

View file

@ -166,13 +166,26 @@ final class TitledMultiSelectEnumSchemaDefinitionTest extends TestCase
$this->assertSame(2, $schema->maxItems);
}
public function testFromArrayWithMissingTitle(): void
public function testFromArrayWithoutTitleIsAccepted(): void
{
// `title` is optional in the specification, so a server that omits it
// must still be readable.
$schema = TitledMultiSelectEnumSchemaDefinition::fromArray([
'items' => ['anyOf' => [['const' => 'a', 'title' => 'A']]],
]);
$this->assertNull($schema->title);
$this->assertArrayNotHasKey('title', $schema->jsonSerialize());
}
public function testFromArrayRejectsNonStringTitle(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Missing or invalid "title"');
$this->expectExceptionMessage('Invalid "title" for titled multi-select enum schema definition.');
/* @phpstan-ignore argument.type */
TitledMultiSelectEnumSchemaDefinition::fromArray([
'title' => 42,
'items' => ['anyOf' => [['const' => 'a', 'title' => 'A']]],
]);
}