php-sdk/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php
Christopher Hertel cfe5e8f570
[Capability] Apply a parameter's complete schema definition (#435)
* [Capability] Apply a parameter's complete schema definition

Schema(definition:) is documented as taking precedence over the individual
keywords, and generate() unwraps it that way at method level. At parameter
level it was merged as an ordinary key instead, in both the plain and the
variadic path, so the property reached clients carrying a literal
"definition" member - not JSON Schema, read by nothing, and everything
inside it lost.

Both paths now replace the schema with the definition. The variadic one
still forces type: array afterwards, since a variadic arrives as an array
however the definition describes it, and the signature default survives a
definition that states none - it is what the handler receives when the
argument is omitted, and default is an annotation rather than a constraint.

* Fix docblock description overwriting definition on variadic params

* Apply suggestion from @chr-hertel
2026-08-18 15:09:58 +02:00

537 lines
16 KiB
PHP

<?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\Tests\Unit\Capability\Discovery;
use Mcp\Capability\Attribute\McpTool;
use Mcp\Capability\Attribute\Schema;
use Mcp\Tests\Unit\Fixtures\Enum\BackedIntEnum;
use Mcp\Tests\Unit\Fixtures\Enum\BackedStringEnum;
use Mcp\Tests\Unit\Fixtures\Enum\UnitEnum;
/**
* Comprehensive fixture for testing SchemaGenerator with various scenarios.
*/
class SchemaGeneratorFixture
{
// ===== BASIC SCENARIOS =====
public function noParams(): void
{
}
/**
* Type hints only - no Schema attributes.
*/
public function typeHintsOnly(string $name, int $age, bool $active, array $tags, ?\stdClass $config = null): void
{
}
/**
* DocBlock types only - no PHP type hints, no Schema attributes.
*
* @param string $username The username
* @param int $count Number of items
* @param bool $enabled Whether enabled
* @param array $data Some data
*/
public function docBlockOnly($username, $count, $enabled, $data): void
{
}
/**
* Type hints with DocBlock descriptions.
*
* @param string $email User email address
* @param int $score User score
* @param bool $verified Whether user is verified
*/
public function typeHintsWithDocBlock(string $email, int $score, bool $verified): void
{
}
// ===== METHOD-LEVEL SCHEMA SCENARIOS =====
/**
* Method-level Schema with complete definition.
*/
#[Schema(definition: [
'type' => 'object',
'description' => 'Creates a custom filter with complete definition',
'properties' => [
'field' => ['type' => 'string', 'enum' => ['name', 'date', 'status']],
'operator' => ['type' => 'string', 'enum' => ['eq', 'gt', 'lt', 'contains']],
'value' => ['description' => 'Value to filter by, type depends on field and operator'],
],
'required' => ['field', 'operator', 'value'],
'if' => [
'properties' => ['field' => ['const' => 'date']],
],
'then' => [
'properties' => ['value' => ['type' => 'string', 'format' => 'date']],
],
])]
public function methodLevelCompleteDefinition(string $field, string $operator, mixed $value): array
{
return compact('field', 'operator', 'value');
}
/**
* Method-level Schema defining properties.
*/
#[Schema(
description: 'Creates a new user with detailed information.',
properties: [
'username' => ['type' => 'string', 'minLength' => 3, 'pattern' => '^[a-zA-Z0-9_]+$'],
'email' => ['type' => 'string', 'format' => 'email'],
'age' => ['type' => 'integer', 'minimum' => 18, 'description' => 'Age in years.'],
'isActive' => ['type' => 'boolean', 'default' => true],
],
required: ['username', 'email']
)]
public function methodLevelWithProperties(string $username, string $email, int $age, bool $isActive = true): array
{
return compact('username', 'email', 'age', 'isActive');
}
/**
* Method-level Schema for complex array argument.
*/
#[Schema(
properties: [
'profiles' => [
'type' => 'array',
'description' => 'An array of user profiles to update.',
'minItems' => 1,
'items' => [
'type' => 'object',
'properties' => [
'id' => ['type' => 'integer'],
'data' => ['type' => 'object', 'additionalProperties' => true],
],
'required' => ['id', 'data'],
],
],
],
required: ['profiles']
)]
public function methodLevelArrayArgument(array $profiles): array
{
return ['updated_count' => \count($profiles)];
}
// ===== PARAMETER-LEVEL SCHEMA SCENARIOS =====
/**
* Parameter-level Schema with complete definition.
*/
public function parameterLevelCompleteDefinition(
#[Schema(definition: [
'type' => 'string',
'description' => 'The region to query.',
'x-mcp-header' => 'Region',
])]
string $region = 'eu',
#[Schema(definition: ['type' => 'integer', 'minimum' => 1, 'maximum' => 10])]
int $limit = 3,
): void {
}
/**
* Parameter-level definition that reshapes the parameter's type.
*/
public function definitionReshapingTheParameter(
#[Schema(definition: ['type' => 'object', 'properties' => ['id' => ['type' => 'integer']]])]
string $payload = '{}',
): void {
}
/**
* Parameter-level Schema attributes only.
*/
public function parameterLevelOnly(
#[Schema(description: 'Recipient ID', pattern: '^user_')]
string $recipientId,
#[Schema(maxLength: 1024)]
string $messageBody,
#[Schema(type: 'integer', enum: [1, 2, 5])]
int $priority = 1,
#[Schema(
type: 'object',
properties: [
'type' => ['type' => 'string', 'enum' => ['sms', 'email', 'push']],
'deviceToken' => ['type' => 'string', 'description' => 'Required if type is push'],
],
required: ['type']
)]
?array $notificationConfig = null,
): array {
return compact('recipientId', 'messageBody', 'priority', 'notificationConfig');
}
/**
* Parameter-level Schema with string constraints.
*/
public function parameterStringConstraints(
#[Schema(format: 'email')]
string $email,
#[Schema(minLength: 8, pattern: '^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$')]
string $password,
string $regularString,
): void {
}
/**
* Parameter-level Schema with numeric constraints.
*/
public function parameterNumericConstraints(
#[Schema(minimum: 18, maximum: 120)]
int $age,
#[Schema(minimum: 0, maximum: 5, exclusiveMaximum: true)]
float $rating,
#[Schema(multipleOf: 10)]
int $count,
): void {
}
/**
* Parameter-level Schema with array constraints.
*/
public function parameterArrayConstraints(
#[Schema(type: 'array', items: ['type' => 'string'], minItems: 1, uniqueItems: true)]
array $tags,
#[Schema(type: 'array', items: ['type' => 'integer', 'minimum' => 0, 'maximum' => 100], minItems: 1, maxItems: 5)]
array $scores,
): void {
}
// ===== COMBINED SCENARIOS =====
/**
* Method-level + Parameter-level Schema combination.
*
* @param string $settingKey The key of the setting
* @param mixed $newValue The new value for the setting
*/
#[Schema(
properties: [
'settingKey' => ['type' => 'string', 'description' => 'The key of the setting.'],
'newValue' => ['description' => 'The new value for the setting (any type).'],
],
required: ['settingKey', 'newValue']
)]
public function methodAndParameterLevel(
string $settingKey,
#[Schema(description: 'The specific new boolean value.', type: 'boolean')]
mixed $newValue,
): array {
return compact('settingKey', 'newValue');
}
/**
* Type hints + DocBlock + Parameter-level Schema.
*
* @param string $username The user's name
* @param int $priority Task priority level
*/
public function typeHintDocBlockAndParameterSchema(
#[Schema(minLength: 3, pattern: '^[a-zA-Z0-9_]+$')]
string $username,
#[Schema(minimum: 1, maximum: 10)]
int $priority,
): void {
}
/**
* PHPStan/Psalm integer ranges should keep their base integer type.
*
* @param int<0, max> $offset Positive offset
* @param int<min, -1> $negative Negative value
* @param int<-5, 10> $bounded Bounded value
*/
public function integerRangeTypes(
#[Schema(minimum: 0)]
int $offset,
int $negative,
int $bounded,
): void {
}
// ===== ENUM SCENARIOS =====
/**
* Various enum parameter types.
*
* @param BackedStringEnum $stringEnum Backed string enum
* @param BackedIntEnum $intEnum Backed int enum
* @param UnitEnum $unitEnum Unit enum
*/
public function enumParameters(
BackedStringEnum $stringEnum,
BackedIntEnum $intEnum,
UnitEnum $unitEnum,
?BackedStringEnum $nullableEnum = null,
BackedIntEnum $enumWithDefault = BackedIntEnum::First,
): void {
}
// ===== ARRAY TYPE SCENARIOS =====
/**
* Various array type scenarios.
*
* @param array $genericArray Generic array
* @param string[] $stringArray Array of strings
* @param int[] $intArray Array of integers
* @param array<string, mixed> $mixedMap Mixed array map
* @param array{name: string, age: int} $objectLikeArray Object-like array
* @param array{user: array{id: int, name: string}, items: int[]} $nestedObjectArray Nested object array
*/
public function arrayTypeScenarios(
array $genericArray,
array $stringArray,
array $intArray,
array $mixedMap,
array $objectLikeArray,
array $nestedObjectArray,
): void {
}
/**
* Nullable typed arrays should still recover their element type.
*
* @param string[]|null $nullableStrings Nullable list of strings
* @param array<int>|null $nullableInts Nullable list of integers
*/
public function nullableTypedArrays(
?array $nullableStrings,
?array $nullableInts = null,
): void {
}
// ===== NULLABLE AND OPTIONAL SCENARIOS =====
/**
* Nullable and optional parameter scenarios.
*
* @param string|null $nullableString Nullable string
* @param int|null $nullableInt Nullable integer
*/
public function nullableAndOptional(
?string $nullableString,
?int $nullableInt = null,
string $optionalString = 'default',
bool $optionalBool = true,
array $optionalArray = [],
): void {
}
// ===== UNION TYPE SCENARIOS =====
/**
* Union type parameters.
*
* @param string|int $stringOrInt String or integer
* @param bool|string|null $multiUnion Bool, string or null
*/
public function unionTypes(
string|int $stringOrInt,
bool|string|null $multiUnion,
): void {
}
// ===== VARIADIC SCENARIOS =====
/**
* Variadic parameter with a complete definition.
*/
public function variadicCompleteDefinition(
#[Schema(definition: ['type' => 'array', 'description' => 'Tags to apply.', 'items' => ['type' => 'string']])]
string ...$tags,
): void {
}
/**
* Variadic parameter with both a docblock description and a complete definition;
* the definition's own description must win.
*
* @param string ...$tags Docblock description that must lose to the definition's own
*/
public function variadicCompleteDefinitionWithDocblockDescription(
#[Schema(definition: ['type' => 'array', 'description' => 'Tags to apply.', 'items' => ['type' => 'string']])]
string ...$tags,
): void {
}
/**
* Variadic parameter scenarios.
*
* @param string ...$items Variadic strings
*/
public function variadicStrings(string ...$items): void
{
}
/**
* Variadic parameter without a type hint.
*
* @param mixed ...$values Variadic values
*/
public function untypedVariadic(...$values): void
{
}
/**
* Variadic with Schema constraints.
*
* @param int ...$numbers Variadic integers
*/
public function variadicWithConstraints(
#[Schema(items: ['type' => 'integer', 'minimum' => 0])]
int ...$numbers,
): void {
}
// ===== MIXED TYPE SCENARIOS =====
/**
* Mixed type scenarios.
*
* @param mixed $anyValue Any value
* @param mixed $optionalAny Optional any value
*/
public function mixedTypes(
mixed $anyValue,
mixed $optionalAny = 'default',
): void {
}
// ===== COMPLEX NESTED SCENARIOS =====
/**
* Complex nested Schema constraints.
*/
#[McpTool(
outputSchema: [
'type' => 'object',
'additionalProperties' => true,
]
)]
public function complexNestedSchema(
#[Schema(
type: 'object',
properties: [
'customer' => [
'type' => 'object',
'properties' => [
'id' => ['type' => 'string', 'pattern' => '^CUS-[0-9]{6}$'],
'name' => ['type' => 'string', 'minLength' => 2],
'email' => ['type' => 'string', 'format' => 'email'],
],
'required' => ['id', 'name'],
],
'items' => [
'type' => 'array',
'minItems' => 1,
'items' => [
'type' => 'object',
'properties' => [
'product_id' => ['type' => 'string', 'pattern' => '^PRD-[0-9]{4}$'],
'quantity' => ['type' => 'integer', 'minimum' => 1],
'price' => ['type' => 'number', 'minimum' => 0],
],
'required' => ['product_id', 'quantity', 'price'],
],
],
'metadata' => [
'type' => 'object',
'additionalProperties' => true,
],
],
required: ['customer', 'items']
)]
array $order,
): array {
return ['order_id' => uniqid()];
}
// ===== TYPE PRECEDENCE SCENARIOS =====
/**
* Testing type precedence between PHP, DocBlock, and Schema.
*
* @param int $numericString DocBlock says integer despite string type hint
* @param string $stringWithConstraints String with Schema constraints
* @param array<string> $arrayWithItems Array with Schema item overrides
*/
public function typePrecedenceTest(
string $numericString,
#[Schema(format: 'email', minLength: 5)]
string $stringWithConstraints,
#[Schema(items: ['type' => 'integer', 'minimum' => 1, 'maximum' => 100])]
array $arrayWithItems,
): void {
}
// ===== ERROR EDGE CASES =====
/**
* Method with no parameters but Schema description.
*/
#[Schema(description: 'Gets server status. Takes no arguments.', properties: [])]
#[McpTool(
outputSchema: [
'type' => 'object',
'additionalProperties' => true,
]
)]
public function noParamsWithSchema(): array
{
return ['status' => 'OK'];
}
/**
* Parameter with Schema but inferred type.
*/
public function parameterSchemaInferredType(
#[Schema(description: 'Some parameter', minLength: 3)]
$inferredParam,
): void {
}
public function withParameterNamedSession(string $_session): void
{
}
public function withParameterNamedSessionWithWeirdCase(string $_sesSion): void
{
}
public function withParameterNamedRequest(string $_request): void
{
}
// ===== OUTPUT SCHEMA FIXTURES =====
#[McpTool(
outputSchema: [
'type' => 'object',
'properties' => [
'message' => ['type' => 'string'],
],
'required' => ['message'],
'description' => 'The result of the operation',
]
)]
public function returnWithExplicitOutputSchema(): array
{
return ['message' => 'result'];
}
}