diff --git a/CHANGELOG.md b/CHANGELOG.md index 01bf9a90..cec221c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to `mcp/sdk` will be documented in this file. +0.7.1 +----- + +* Always emit `{}` for empty tool schema `properties`: `Tool` now recursively normalizes empty `properties` arrays (including nested object parameters and `outputSchema`) in the constructor, so `tools/list` never serializes invalid JSON Schema `properties: []` (#405). + 0.7.0 ----- diff --git a/src/Schema/Tool.php b/src/Schema/Tool.php index 18178044..6fab82a5 100644 --- a/src/Schema/Tool.php +++ b/src/Schema/Tool.php @@ -21,12 +21,12 @@ * * @phpstan-type ToolInputSchema array{ * type: 'object', - * properties: array, + * properties: array|\stdClass, * required: string[]|null * } * @phpstan-type ToolOutputSchema array{ * type: 'object', - * properties?: array, + * properties?: array|\stdClass, * required?: string[]|null, * additionalProperties?: bool|array, * description?: string @@ -46,6 +46,16 @@ */ class Tool implements \JsonSerializable { + /** + * @var ToolInputSchema + */ + public readonly array $inputSchema; + + /** + * @var ToolOutputSchema|null + */ + public readonly ?array $outputSchema; + /** * @param string $name the name of the tool * @param ?string $title Optional human-readable title for display in UI @@ -61,16 +71,21 @@ class Tool implements \JsonSerializable public function __construct( public readonly string $name, public readonly ?string $title, - public readonly array $inputSchema, + array $inputSchema, public readonly ?string $description, public readonly ?ToolAnnotations $annotations, public readonly ?array $icons = null, public readonly ?array $meta = null, - public readonly ?array $outputSchema = null, + ?array $outputSchema = null, ) { if (!isset($inputSchema['type']) || 'object' !== $inputSchema['type']) { throw new InvalidArgumentException('Tool inputSchema must be a JSON Schema of type "object".'); } + + // Always normalize here so every construction path emits `{}` for empty + // object `properties` — not only SchemaGenerator / fromArray. + $this->inputSchema = self::normalizeSchemaProperties($inputSchema); + $this->outputSchema = null !== $outputSchema ? self::normalizeSchemaProperties($outputSchema) : null; } /** @@ -87,20 +102,19 @@ public static function fromArray(array $data): self if (!isset($data['inputSchema']['type']) || 'object' !== $data['inputSchema']['type']) { throw new InvalidArgumentException('Tool inputSchema must be of type "object".'); } - $inputSchema = self::normalizeSchemaProperties($data['inputSchema']); $outputSchema = null; if (isset($data['outputSchema']) && \is_array($data['outputSchema'])) { if (!isset($data['outputSchema']['type']) || 'object' !== $data['outputSchema']['type']) { throw new InvalidArgumentException('Tool outputSchema must be of type "object".'); } - $outputSchema = self::normalizeSchemaProperties($data['outputSchema']); + $outputSchema = $data['outputSchema']; } return new self( name: $data['name'], title: isset($data['title']) && \is_string($data['title']) ? $data['title'] : null, - inputSchema: $inputSchema, + inputSchema: $data['inputSchema'], description: isset($data['description']) && \is_string($data['description']) ? $data['description'] : null, annotations: isset($data['annotations']) && \is_array($data['annotations']) ? ToolAnnotations::fromArray($data['annotations']) : null, icons: isset($data['icons']) && \is_array($data['icons']) ? array_map(Icon::fromArray(...), $data['icons']) : null, @@ -148,7 +162,11 @@ public function jsonSerialize(): array } /** - * Normalize schema properties: convert an empty properties array to stdClass. + * Normalize schema properties: convert empty `properties` arrays to `\stdClass` + * so they JSON-encode as `{}` (a JSON Schema object) rather than `[]`. + * + * Walks nested property schemas recursively so nested object parameters and + * `outputSchema` are covered, not only the top-level `properties` map. * * @param array $schema * @@ -156,8 +174,45 @@ public function jsonSerialize(): array */ private static function normalizeSchemaProperties(array $schema): array { - if (isset($schema['properties']) && \is_array($schema['properties']) && empty($schema['properties'])) { - $schema['properties'] = new \stdClass(); + if (isset($schema['properties']) && \is_array($schema['properties'])) { + if ([] === $schema['properties']) { + $schema['properties'] = new \stdClass(); + } else { + foreach ($schema['properties'] as $name => $propertySchema) { + if (\is_array($propertySchema)) { + $schema['properties'][$name] = self::normalizeSchemaProperties($propertySchema); + } + } + } + } + + if (isset($schema['items']) && \is_array($schema['items'])) { + // Tuple-style `items` (list of schemas) or a single item schema object. + if (array_is_list($schema['items'])) { + foreach ($schema['items'] as $index => $itemSchema) { + if (\is_array($itemSchema)) { + $schema['items'][$index] = self::normalizeSchemaProperties($itemSchema); + } + } + } else { + $schema['items'] = self::normalizeSchemaProperties($schema['items']); + } + } + + if (isset($schema['additionalProperties']) && \is_array($schema['additionalProperties'])) { + $schema['additionalProperties'] = self::normalizeSchemaProperties($schema['additionalProperties']); + } + + foreach (['anyOf', 'oneOf', 'allOf', 'prefixItems'] as $combiner) { + if (!isset($schema[$combiner]) || !\is_array($schema[$combiner])) { + continue; + } + + foreach ($schema[$combiner] as $index => $subSchema) { + if (\is_array($subSchema)) { + $schema[$combiner][$index] = self::normalizeSchemaProperties($subSchema); + } + } } return $schema; diff --git a/tests/Unit/Schema/ToolTest.php b/tests/Unit/Schema/ToolTest.php index dd6861c3..1126ee0d 100644 --- a/tests/Unit/Schema/ToolTest.php +++ b/tests/Unit/Schema/ToolTest.php @@ -97,4 +97,84 @@ public function testRoundTripPreservesTitle(): void $this->assertSame($original->name, $restored->name); $this->assertSame($original->description, $restored->description); } + + public function testConstructorNormalizesEmptyInputSchemaPropertiesToObject(): void + { + $tool = new Tool( + name: 'no_params', + title: null, + inputSchema: ['type' => 'object', 'properties' => [], 'required' => null], + description: null, + annotations: null, + ); + + $this->assertInstanceOf(\stdClass::class, $tool->inputSchema['properties']); + $this->assertSame('{"name":"no_params","inputSchema":{"type":"object","properties":{},"required":null}}', json_encode($tool)); + } + + public function testConstructorNormalizesEmptyPropertiesAfterJsonDecodeRoundTrip(): void + { + /** @var array{type: 'object', properties: array, required: null} $schema */ + $schema = json_decode('{"type":"object","properties":{},"required":null}', true); + $this->assertSame([], $schema['properties']); + + $tool = new Tool('t', null, $schema, null, null); + + $this->assertInstanceOf(\stdClass::class, $tool->inputSchema['properties']); + $this->assertStringContainsString('"properties":{}', (string) json_encode($tool)); + } + + public function testFromArrayNormalizesNestedEmptyPropertiesRecursively(): void + { + $tool = Tool::fromArray([ + 'name' => 't', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'filter' => ['type' => 'object', 'properties' => []], + ], + 'required' => null, + ], + ]); + + $this->assertInstanceOf(\stdClass::class, $tool->inputSchema['properties']['filter']['properties']); + $this->assertStringContainsString('"properties":{}', (string) json_encode($tool->inputSchema['properties']['filter'])); + } + + public function testConstructorNormalizesEmptyOutputSchemaProperties(): void + { + $tool = new Tool( + name: 't', + title: null, + inputSchema: ['type' => 'object', 'properties' => ['q' => ['type' => 'string']], 'required' => null], + description: null, + annotations: null, + outputSchema: ['type' => 'object', 'properties' => []], + ); + + $this->assertInstanceOf(\stdClass::class, $tool->outputSchema['properties']); + $this->assertStringContainsString('"outputSchema":{"type":"object","properties":{}}', (string) json_encode($tool)); + } + + public function testConstructorNormalizesEmptyPropertiesInsideArrayItems(): void + { + $tool = new Tool( + name: 't', + title: null, + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'rows' => [ + 'type' => 'array', + 'items' => ['type' => 'object', 'properties' => []], + ], + ], + 'required' => null, + ], + description: null, + annotations: null, + ); + + $this->assertInstanceOf(\stdClass::class, $tool->inputSchema['properties']['rows']['items']['properties']); + } }