[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:
parent
ff91ca6d60
commit
8e18bfbdf1
5 changed files with 86 additions and 8 deletions
|
|
@ -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.
|
||||
* 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
|
||||
-----
|
||||
|
|
|
|||
|
|
@ -223,7 +223,7 @@ final class SchemaGenerator implements SchemaGeneratorInterface
|
|||
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 @@ final class SchemaGenerator implements SchemaGeneratorInterface
|
|||
$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);
|
||||
|
||||
// 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]);
|
||||
|
|
|
|||
|
|
@ -48,7 +48,10 @@
|
|||
"null"
|
||||
],
|
||||
"description": "an optional list of attendee email addresses",
|
||||
"default": null
|
||||
"default": null,
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"sendInvites": {
|
||||
"type": "boolean",
|
||||
|
|
|
|||
|
|
@ -266,6 +266,18 @@ class SchemaGeneratorFixture
|
|||
): 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 =====
|
||||
|
||||
/**
|
||||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ final class SchemaGeneratorTest extends TestCase
|
|||
$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 @@ final class SchemaGeneratorTest extends TestCase
|
|||
$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 @@ final class SchemaGeneratorTest extends TestCase
|
|||
{
|
||||
$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 @@ final class SchemaGeneratorTest extends TestCase
|
|||
$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 @@ final class SchemaGeneratorTest extends TestCase
|
|||
$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 @@ final class SchemaGeneratorTest extends TestCase
|
|||
$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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue