diff --git a/src/Schema/Tool.php b/src/Schema/Tool.php index 18178044..304defb1 100644 --- a/src/Schema/Tool.php +++ b/src/Schema/Tool.php @@ -21,8 +21,8 @@ * * @phpstan-type ToolInputSchema array{ * type: 'object', - * properties: array, - * required: string[]|null + * properties?: array, + * required?: string[]|null * } * @phpstan-type ToolOutputSchema array{ * type: 'object', @@ -127,7 +127,7 @@ public function jsonSerialize(): array if (null !== $this->title) { $data['title'] = $this->title; } - $data['inputSchema'] = $this->inputSchema; + $data['inputSchema'] = self::normalizeSchemaProperties($this->inputSchema); if (null !== $this->description) { $data['description'] = $this->description; } @@ -141,14 +141,14 @@ public function jsonSerialize(): array $data['_meta'] = $this->meta; } if (null !== $this->outputSchema) { - $data['outputSchema'] = $this->outputSchema; + $data['outputSchema'] = self::normalizeSchemaProperties($this->outputSchema); } return $data; } /** - * Normalize schema properties: convert an empty properties array to stdClass. + * Normalize schema properties: convert an empty properties array to stdClass recursively. * * @param array $schema * @@ -156,8 +156,22 @@ 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 (empty($schema['properties'])) { + $schema['properties'] = new \stdClass(); + } else { + foreach ($schema['properties'] as $key => $propSchema) { + if (\is_array($propSchema)) { + $schema['properties'][$key] = self::normalizeSchemaProperties($propSchema); + } + } + } + } + + foreach ($schema as $key => $value) { + if ('properties' !== $key && \is_array($value)) { + $schema[$key] = self::normalizeSchemaProperties($value); + } } return $schema; diff --git a/tests/Unit/Schema/ToolTest.php b/tests/Unit/Schema/ToolTest.php index dd6861c3..59988267 100644 --- a/tests/Unit/Schema/ToolTest.php +++ b/tests/Unit/Schema/ToolTest.php @@ -97,4 +97,146 @@ public function testRoundTripPreservesTitle(): void $this->assertSame($original->name, $restored->name); $this->assertSame($original->description, $restored->description); } + + public function testEmptyPropertiesNormalizationOnDirectConstruction(): void + { + $tool = new Tool( + name: 'test', + title: null, + inputSchema: [ + 'type' => 'object', + 'properties' => [], + ], + description: null, + annotations: null, + ); + $serialized = $tool->jsonSerialize(); + $this->assertInstanceOf(\stdClass::class, $serialized['inputSchema']['properties']); + $this->assertSame('{"name":"test","inputSchema":{"type":"object","properties":{}}}', json_encode($serialized)); + } + + public function testEmptyPropertiesNormalizationOnFromArray(): void + { + $tool = Tool::fromArray([ + 'name' => 'test', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [], + ], + ]); + $serialized = $tool->jsonSerialize(); + $this->assertInstanceOf(\stdClass::class, $serialized['inputSchema']['properties']); + $this->assertSame('{"name":"test","inputSchema":{"type":"object","properties":{}}}', json_encode($serialized)); + } + + public function testJsonRoundTripPreservesEmptyPropertiesObject(): void + { + $json = '{"type":"object","properties":{}}'; + $schema = (array) json_decode($json, true); + $tool = new Tool('test', null, $schema, null, null); + $serialized = $tool->jsonSerialize(); + + $this->assertInstanceOf(\stdClass::class, $serialized['inputSchema']['properties']); + $this->assertSame('{"name":"test","inputSchema":{"type":"object","properties":{}}}', json_encode($serialized)); + } + + public function testNestedObjectEmptyPropertiesNormalization(): void + { + $tool = new Tool( + name: 'test', + title: null, + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'filter' => [ + 'type' => 'object', + 'properties' => [], + ], + ], + ], + description: null, + annotations: null, + ); + $serialized = $tool->jsonSerialize(); + $this->assertInstanceOf(\stdClass::class, $serialized['inputSchema']['properties']['filter']['properties']); + $this->assertSame('{"name":"test","inputSchema":{"type":"object","properties":{"filter":{"type":"object","properties":{}}}}}', json_encode($serialized)); + } + + public function testExistingNonEmptyPropertiesUnchanged(): void + { + $tool = new Tool( + name: 'test', + title: null, + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'name' => [ + 'type' => 'string', + ], + ], + ], + description: null, + annotations: null, + ); + $serialized = $tool->jsonSerialize(); + $this->assertIsArray($serialized['inputSchema']['properties']); + $this->assertArrayHasKey('name', $serialized['inputSchema']['properties']); + $this->assertSame('{"name":"test","inputSchema":{"type":"object","properties":{"name":{"type":"string"}}}}', json_encode($serialized)); + } + + public function testValidArraysAreNotTransformed(): void + { + $tool = new Tool( + name: 'test', + title: null, + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'tags' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'string', + ], + ], + 'status' => [ + 'type' => 'string', + 'enum' => [], + ], + ], + 'required' => [], + ], + description: null, + annotations: null, + ); + $serialized = $tool->jsonSerialize(); + $this->assertIsArray($serialized['inputSchema']['required']); + $this->assertEmpty($serialized['inputSchema']['required']); + $this->assertIsArray($serialized['inputSchema']['properties']['tags']); + $this->assertIsArray($serialized['inputSchema']['properties']['status']['enum']); + $this->assertEmpty($serialized['inputSchema']['properties']['status']['enum']); + $this->assertSame('{"name":"test","inputSchema":{"type":"object","properties":{"tags":{"type":"array","items":{"type":"string"}},"status":{"type":"string","enum":[]}},"required":[]}}', json_encode($serialized)); + } + + public function testOutputSchemaEmptyPropertiesNormalization(): void + { + $tool = new Tool( + name: 'test', + title: null, + inputSchema: [ + 'type' => 'object', + 'properties' => ['q' => ['type' => 'string']], + ], + description: null, + annotations: null, + icons: null, + meta: null, + outputSchema: [ + 'type' => 'object', + 'properties' => [], + ], + ); + $serialized = $tool->jsonSerialize(); + $this->assertInstanceOf(\stdClass::class, $serialized['outputSchema']['properties']); + $this->assertSame('{"name":"test","inputSchema":{"type":"object","properties":{"q":{"type":"string"}}},"outputSchema":{"type":"object","properties":{}}}', json_encode($serialized)); + } }