[Server] Always emit items for array tool parameter schemas (#378)

Array tool parameters without an inferable element type generated `{"type":"array"}` with no `items`, which strict MCP clients (VS Code / Copilot) reject. Always emit `items` (`{}` when the element type is unknown) and recover the element type for nullable typed arrays such as `string[]|null`.

Fixes #151
This commit is contained in:
Valeriu Gutu 2026-07-15 00:56:50 +03:00 • committed by GitHub
commit 8e18bfbdf1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 86 additions and 8 deletions

View file

@ -6,6 +6,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
----- -----
* Allow `[$instance, 'methodName']` as an element handler in `Builder::addTool()`, `addResource()`, `addResourceTemplate()`, and `addPrompt()`. Unblocks handlers with constructor dependencies that the container-less `new $className()` fallback cannot build. * Allow `[$instance, 'methodName']` as an element handler in `Builder::addTool()`, `addResource()`, `addResourceTemplate()`, and `addPrompt()`. Unblocks handlers with constructor dependencies that the container-less `new $className()` fallback cannot build.
* 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 0.6.0
----- -----

View file

@ -223,7 +223,7 @@ final class SchemaGenerator implements SchemaGeneratorInterface
private function buildParameterSchema(array $paramInfo, ?array $methodLevelParamSchema): array private function buildParameterSchema(array $paramInfo, ?array $methodLevelParamSchema): array
{ {
if ($paramInfo['is_variadic']) { if ($paramInfo['is_variadic']) {
return $this->buildVariadicParameterSchema($paramInfo); return $this->ensureArrayItems($this->buildVariadicParameterSchema($paramInfo));
} }
$inferredSchema = $this->buildInferredParameterSchema($paramInfo); $inferredSchema = $this->buildInferredParameterSchema($paramInfo);
@ -240,7 +240,34 @@ final class SchemaGenerator implements SchemaGeneratorInterface
$mergedSchema = array_merge($mergedSchema, $parameterLevelSchema); $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;
} }
/** /**
@ -693,6 +720,11 @@ final class SchemaGenerator implements SchemaGeneratorInterface
{ {
$normalizedType = trim($phpTypeString); $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.) // Case 1: Simple T[] syntax (e.g., string[], int[], bool[], etc.)
if (preg_match('/^(\\??)([\w\\\\]+)\\s*\\[\\]$/i', $normalizedType, $matches)) { if (preg_match('/^(\\??)([\w\\\\]+)\\s*\\[\\]$/i', $normalizedType, $matches)) {
$itemType = strtolower($matches[2]); $itemType = strtolower($matches[2]);

View file

@ -48,7 +48,10 @@
"null" "null"
], ],
"description": "an optional list of attendee email addresses", "description": "an optional list of attendee email addresses",
"default": null "default": null,
"items": {
"type": "string"
}
}, },
"sendInvites": { "sendInvites": {
"type": "boolean", "type": "boolean",

View file

@ -266,6 +266,18 @@ class SchemaGeneratorFixture
): void { ): 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 SCENARIOS =====
/** /**
@ -308,6 +320,15 @@ class SchemaGeneratorFixture
{ {
} }
/**
* Variadic parameter without a type hint.
*
* @param mixed ...$values Variadic values
*/
public function untypedVariadic(...$values): void
{
}
/** /**
* Variadic with Schema constraints. * Variadic with Schema constraints.
* *

View file

@ -44,7 +44,7 @@ final class SchemaGeneratorTest extends TestCase
$this->assertEquals(['type' => 'string'], $schema['properties']['name']); $this->assertEquals(['type' => 'string'], $schema['properties']['name']);
$this->assertEquals(['type' => 'integer'], $schema['properties']['age']); $this->assertEquals(['type' => 'integer'], $schema['properties']['age']);
$this->assertEquals(['type' => 'boolean'], $schema['properties']['active']); $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->assertEquals(['type' => ['null', 'object'], 'default' => null], $schema['properties']['config']);
$this->assertEqualsCanonicalizing(['name', 'age', 'active', 'tags'], $schema['required']); $this->assertEqualsCanonicalizing(['name', 'age', 'active', 'tags'], $schema['required']);
} }
@ -56,7 +56,7 @@ final class SchemaGeneratorTest extends TestCase
$this->assertEquals(['type' => 'string', 'description' => 'The username'], $schema['properties']['username']); $this->assertEquals(['type' => 'string', 'description' => 'The username'], $schema['properties']['username']);
$this->assertEquals(['type' => 'integer', 'description' => 'Number of items'], $schema['properties']['count']); $this->assertEquals(['type' => 'integer', 'description' => 'Number of items'], $schema['properties']['count']);
$this->assertEquals(['type' => 'boolean', 'description' => 'Whether enabled'], $schema['properties']['enabled']); $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']); $this->assertEqualsCanonicalizing(['username', 'count', 'enabled', 'data'], $schema['required']);
} }
@ -206,10 +206,13 @@ final class SchemaGeneratorTest extends TestCase
{ {
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'arrayTypeScenarios'); $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'arrayTypeScenarios');
$schema = $this->schemaGenerator->generate($method); $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 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' => '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->assertArrayHasKey('type', $schema['properties']['objectLikeArray']);
$this->assertEquals('object', $schema['properties']['objectLikeArray']['type']); $this->assertEquals('object', $schema['properties']['objectLikeArray']['type']);
$this->assertArrayHasKey('properties', $schema['properties']['objectLikeArray']); $this->assertArrayHasKey('properties', $schema['properties']['objectLikeArray']);
@ -218,6 +221,16 @@ final class SchemaGeneratorTest extends TestCase
$this->assertEqualsCanonicalizing(['genericArray', 'stringArray', 'intArray', 'mixedMap', 'objectLikeArray', 'nestedObjectArray'], $schema['required']); $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 public function testHandlesNullableTypeHintsAndOptionalParameters(): void
{ {
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'nullableAndOptional'); $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'nullableAndOptional');
@ -226,7 +239,7 @@ final class SchemaGeneratorTest extends TestCase
$this->assertEquals(['type' => ['null', 'integer'], 'description' => 'Nullable integer', 'default' => null], $schema['properties']['nullableInt']); $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' => 'string', 'default' => 'default'], $schema['properties']['optionalString']);
$this->assertEquals(['type' => 'boolean', 'default' => true], $schema['properties']['optionalBool']); $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']); $this->assertEqualsCanonicalizing(['nullableString'], $schema['required']);
} }
@ -255,6 +268,14 @@ final class SchemaGeneratorTest extends TestCase
$this->assertArrayNotHasKey('required', $schema); $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 public function testHandlesMixedTypeHintsOmittingExplicitType(): void
{ {
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'mixedTypes'); $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'mixedTypes');