From 49cb152e6d70e22559e9453ef41144ba7c07ce0c Mon Sep 17 00:00:00 2001 From: Valery Gutu Date: Sat, 6 Jun 2026 15:14:47 +0300 Subject: [PATCH] [Server] Always emit items for array tool parameter schemas 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 --- CHANGELOG.md | 5 +++ src/Capability/Discovery/SchemaGenerator.php | 36 +++++++++++++++++-- .../HttpComplexToolSchemaTest-tools_list.json | 5 ++- .../Discovery/SchemaGeneratorFixture.php | 21 +++++++++++ .../Discovery/SchemaGeneratorTest.php | 31 +++++++++++++--- 5 files changed, 90 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8db034bd..091d03dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ----- diff --git a/src/Capability/Discovery/SchemaGenerator.php b/src/Capability/Discovery/SchemaGenerator.php index 04ee08f6..a4efdd6a 100644 --- a/src/Capability/Discovery/SchemaGenerator.php +++ b/src/Capability/Discovery/SchemaGenerator.php @@ -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); @@ -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 $schema + * + * @return array + */ + 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 @@ 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` 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]); diff --git a/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_list.json b/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_list.json index 2a7698a8..0e0bbe93 100644 --- a/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_list.json +++ b/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_list.json @@ -48,7 +48,10 @@ "null" ], "description": "an optional list of attendee email addresses", - "default": null + "default": null, + "items": { + "type": "string" + } }, "sendInvites": { "type": "boolean", diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php index 0d40026c..d6959173 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php @@ -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|null $nullableInts Nullable list of integers + */ + public function nullableTypedArrays( + ?array $nullableStrings, + ?array $nullableInts = null, + ): void { + } + // ===== NULLABLE AND OPTIONAL SCENARIOS ===== /** @@ -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. * diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php index c92121e6..0c223cb5 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php @@ -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']); } @@ -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']); } @@ -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']); @@ -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'); @@ -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']); } @@ -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');