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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

All notable changes to `mcp/sdk` will be documented in this file.

0.6.1
-----

* Always emit an `items` schema for array tool parameters: untyped arrays get `items: {}` and nullable typed arrays (e.g. `string[]|null`) keep their element type. Fixes strict clients rejecting tools with "array type must have items" (#151).

0.6.0
-----

Expand Down
36 changes: 34 additions & 2 deletions src/Capability/Discovery/SchemaGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ private function buildSchemaFromParameters(array $parametersInfo, ?array $method
private function buildParameterSchema(array $paramInfo, ?array $methodLevelParamSchema): array
{
if ($paramInfo['is_variadic']) {
return $this->buildVariadicParameterSchema($paramInfo);
return $this->ensureArrayItems($this->buildVariadicParameterSchema($paramInfo));
}

$inferredSchema = $this->buildInferredParameterSchema($paramInfo);
Expand All @@ -240,7 +240,34 @@ private function buildParameterSchema(array $paramInfo, ?array $methodLevelParam
$mergedSchema = array_merge($mergedSchema, $parameterLevelSchema);
}

return $mergedSchema;
// Run after all merges so that when a Schema attribute reshapes the parameter
// (e.g. to `object`), the array `items` invariant is only enforced on what is
// genuinely still an array.
return $this->ensureArrayItems($mergedSchema);
}

/**
* Guarantees an array-typed schema always declares `items`.
*
* `items` is optional in JSON Schema, but some strict clients reject an array schema
* without it. When no element type could be inferred, default to the empty schema `{}`
* (matches anything) — represented as `new \stdClass()` so it serializes to `{}` rather
* than `[]`.
*
* @param array<string, mixed> $schema
*
* @return array<string, mixed>
*/
private function ensureArrayItems(array $schema): array
{
$type = $schema['type'] ?? null;
$isArray = 'array' === $type || (\is_array($type) && \in_array('array', $type, true));

if ($isArray && !isset($schema['items'])) {
$schema['items'] = new \stdClass();
}

return $schema;
}

/**
Expand Down Expand Up @@ -693,6 +720,11 @@ private function inferArrayItemsType(string $phpTypeString): string|array
{
$normalizedType = trim($phpTypeString);

// Strip a top-level nullable union (e.g. `string[]|null`, `null|int[]`) so the
// element type is still recovered; array nullability is handled separately via
// `allows_null`. Internal unions such as `array<int|string>` are left untouched.
$normalizedType = trim((string) preg_replace('/^null\s*\|\s*|\s*\|\s*null$/i', '', $normalizedType));

// Case 1: Simple T[] syntax (e.g., string[], int[], bool[], etc.)
if (preg_match('/^(\\??)([\w\\\\]+)\\s*\\[\\]$/i', $normalizedType, $matches)) {
$itemType = strtolower($matches[2]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@
"null"
],
"description": "an optional list of attendee email addresses",
"default": null
"default": null,
"items": {
"type": "string"
}
},
"sendInvites": {
"type": "boolean",
Expand Down
21 changes: 21 additions & 0 deletions tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,18 @@ public function arrayTypeScenarios(
): 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 =====

/**
Expand Down Expand Up @@ -308,6 +320,15 @@ 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.
*
Expand Down
31 changes: 26 additions & 5 deletions tests/Unit/Capability/Discovery/SchemaGeneratorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public function testInfersBasicTypesFromPhpTypeHints(): void
$this->assertEquals(['type' => 'string'], $schema['properties']['name']);
$this->assertEquals(['type' => 'integer'], $schema['properties']['age']);
$this->assertEquals(['type' => 'boolean'], $schema['properties']['active']);
$this->assertEquals(['type' => 'array'], $schema['properties']['tags']);
$this->assertEquals(['type' => 'array', 'items' => new \stdClass()], $schema['properties']['tags']);
$this->assertEquals(['type' => ['null', 'object'], 'default' => null], $schema['properties']['config']);
$this->assertEqualsCanonicalizing(['name', 'age', 'active', 'tags'], $schema['required']);
}
Expand All @@ -56,7 +56,7 @@ public function testInfersTypesAndDescriptionsFromDocBlockTags(): void
$this->assertEquals(['type' => 'string', 'description' => 'The username'], $schema['properties']['username']);
$this->assertEquals(['type' => 'integer', 'description' => 'Number of items'], $schema['properties']['count']);
$this->assertEquals(['type' => 'boolean', 'description' => 'Whether enabled'], $schema['properties']['enabled']);
$this->assertEquals(['type' => 'array', 'description' => 'Some data'], $schema['properties']['data']);
$this->assertEquals(['type' => 'array', 'description' => 'Some data', 'items' => new \stdClass()], $schema['properties']['data']);
$this->assertEqualsCanonicalizing(['username', 'count', 'enabled', 'data'], $schema['required']);
}

Expand Down Expand Up @@ -206,10 +206,13 @@ public function testGeneratesCorrectSchemaForArrayTypeDeclarations(): void
{
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'arrayTypeScenarios');
$schema = $this->schemaGenerator->generate($method);
$this->assertEquals(['type' => 'array', 'description' => 'Generic array'], $schema['properties']['genericArray']);
$this->assertEquals(['type' => 'array', 'description' => 'Generic array', 'items' => new \stdClass()], $schema['properties']['genericArray']);
// An untyped array must still declare `items`, serialized as the empty schema `{}`
// (not `[]`) so strict clients accept it.
$this->assertSame('{}', json_encode($schema['properties']['genericArray']['items']));
$this->assertEquals(['type' => 'array', 'description' => 'Array of strings', 'items' => ['type' => 'string']], $schema['properties']['stringArray']);
$this->assertEquals(['type' => 'array', 'description' => 'Array of integers', 'items' => ['type' => 'integer']], $schema['properties']['intArray']);
$this->assertEquals(['type' => 'array', 'description' => 'Mixed array map'], $schema['properties']['mixedMap']);
$this->assertEquals(['type' => 'array', 'description' => 'Mixed array map', 'items' => new \stdClass()], $schema['properties']['mixedMap']);
$this->assertArrayHasKey('type', $schema['properties']['objectLikeArray']);
$this->assertEquals('object', $schema['properties']['objectLikeArray']['type']);
$this->assertArrayHasKey('properties', $schema['properties']['objectLikeArray']);
Expand All @@ -218,6 +221,16 @@ public function testGeneratesCorrectSchemaForArrayTypeDeclarations(): void
$this->assertEqualsCanonicalizing(['genericArray', 'stringArray', 'intArray', 'mixedMap', 'objectLikeArray', 'nestedObjectArray'], $schema['required']);
}

public function testRecoversItemsTypeForNullableTypedArrays(): void
{
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'nullableTypedArrays');
$schema = $this->schemaGenerator->generate($method);
// A `|null` suffix must not erase the element type: `string[]|null` keeps `items: {type: string}`.
$this->assertEquals(['type' => ['array', 'null'], 'description' => 'Nullable list of strings', 'items' => ['type' => 'string']], $schema['properties']['nullableStrings']);
$this->assertEquals(['type' => ['array', 'null'], 'description' => 'Nullable list of integers', 'default' => null, 'items' => ['type' => 'integer']], $schema['properties']['nullableInts']);
$this->assertEqualsCanonicalizing(['nullableStrings'], $schema['required']);
}

public function testHandlesNullableTypeHintsAndOptionalParameters(): void
{
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'nullableAndOptional');
Expand All @@ -226,7 +239,7 @@ public function testHandlesNullableTypeHintsAndOptionalParameters(): void
$this->assertEquals(['type' => ['null', 'integer'], 'description' => 'Nullable integer', 'default' => null], $schema['properties']['nullableInt']);
$this->assertEquals(['type' => 'string', 'default' => 'default'], $schema['properties']['optionalString']);
$this->assertEquals(['type' => 'boolean', 'default' => true], $schema['properties']['optionalBool']);
$this->assertEquals(['type' => 'array', 'default' => []], $schema['properties']['optionalArray']);
$this->assertEquals(['type' => 'array', 'default' => [], 'items' => new \stdClass()], $schema['properties']['optionalArray']);
$this->assertEqualsCanonicalizing(['nullableString'], $schema['required']);
}

Expand Down Expand Up @@ -255,6 +268,14 @@ public function testAppliesItemConstraintsToVariadicParameters(): void
$this->assertArrayNotHasKey('required', $schema);
}

public function testUntypedVariadicStillDeclaresItems(): void
{
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'untypedVariadic');
$schema = $this->schemaGenerator->generate($method);
$this->assertEquals(['type' => 'array', 'description' => 'Variadic values', 'items' => new \stdClass()], $schema['properties']['values']);
$this->assertSame('{}', json_encode($schema['properties']['values']['items']));
}

public function testHandlesMixedTypeHintsOmittingExplicitType(): void
{
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'mixedTypes');
Expand Down