From c6efca40be241a08da2f5b4f66a2149b48048e00 Mon Sep 17 00:00:00 2001 From: BrocksiNet Date: Tue, 28 Jul 2026 13:42:32 +0200 Subject: [PATCH 1/2] [Schema] Always emit {} for empty tool schema properties Normalize empty properties arrays (including nested object schemas and outputSchema) in Tool::__construct so tools/list never serializes invalid JSON Schema properties: []. Fixes #405 --- CHANGELOG.md | 5 +++ src/Schema/Tool.php | 75 ++++++++++++++++++++++++++----- tests/Unit/Schema/ToolTest.php | 80 ++++++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01bf9a90..ddef0a7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to `mcp/sdk` will be documented in this file. +0.8.0 +----- + +* Always emit `{}` for an empty tool schema `properties` map: `Tool` normalizes it in the constructor, recursively and for both `inputSchema` and `outputSchema`, so `tools/list` never serializes an invalid `properties: []`. + 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']); + } } From 05ac007b2eb647fe424a54a0be55b8c71e2e9eba Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Fri, 14 Aug 2026 21:40:40 +0200 Subject: [PATCH 2/2] [Schema] Normalize every empty sub-schema, not only properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walking into `items` and `additionalProperties` without normalizing an empty sub-schema at those positions left the `[]`-instead-of-`{}` bug intact one level down. Most notably it regressed #151 on a `Tool::fromArray()` round-trip: `SchemaGenerator` emits `items: {}` for untyped array parameters, which decodes to `[]` and was re-serialized as `[]` — the exact schema strict clients reject. Empty sub-schemas are now replaced with a `\stdClass` at every schema position, and the keyword list is completed to cover draft-07 through 2020-12: `$defs`, `definitions`, `dependentSchemas`, `patternProperties`, `propertyNames`, `contains`, `not`, `if`/`then`/`else`, `additionalItems`, and `unevaluatedItems`/`unevaluatedProperties`. Keywords holding plain JSON arrays (`allOf: []`, `enum: []`, `dependentRequired`) keep encoding as `[]`. --- CHANGELOG.md | 2 +- src/Schema/Tool.php | 133 ++++++++++++++++++++++++--------- tests/Unit/Schema/ToolTest.php | 112 +++++++++++++++++++++++++++ 3 files changed, 211 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ddef0a7b..01665a1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file. 0.8.0 ----- -* Always emit `{}` for an empty tool schema `properties` map: `Tool` normalizes it in the constructor, recursively and for both `inputSchema` and `outputSchema`, so `tools/list` never serializes an invalid `properties: []`. +* Always emit `{}` for empty tool schemas: `Tool` recursively normalizes every empty sub-schema — `properties`, `items`, `additionalProperties`, `$defs`, combinators and the other draft-07 to 2020-12 schema keywords — in the constructor, for both `inputSchema` and `outputSchema`, so an object position is never serialized as `[]`. 0.7.0 ----- diff --git a/src/Schema/Tool.php b/src/Schema/Tool.php index 6fab82a5..83a83f85 100644 --- a/src/Schema/Tool.php +++ b/src/Schema/Tool.php @@ -28,7 +28,7 @@ * type: 'object', * properties?: array|\stdClass, * required?: string[]|null, - * additionalProperties?: bool|array, + * additionalProperties?: bool|array|\stdClass, * description?: string * } * @phpstan-type ToolData array{ @@ -46,6 +46,43 @@ */ class Tool implements \JsonSerializable { + /** + * JSON Schema keywords whose value is a single sub-schema. + */ + private const SUB_SCHEMA_KEYWORDS = [ + 'additionalItems', + 'additionalProperties', + 'contains', + 'else', + 'if', + 'not', + 'propertyNames', + 'then', + 'unevaluatedItems', + 'unevaluatedProperties', + ]; + + /** + * JSON Schema keywords whose value maps names to sub-schemas. + */ + private const SUB_SCHEMA_MAP_KEYWORDS = [ + '$defs', + 'definitions', + 'dependentSchemas', + 'patternProperties', + 'properties', + ]; + + /** + * JSON Schema keywords whose value is a list of sub-schemas. + */ + private const SUB_SCHEMA_LIST_KEYWORDS = [ + 'allOf', + 'anyOf', + 'oneOf', + 'prefixItems', + ]; + /** * @var ToolInputSchema */ @@ -83,9 +120,9 @@ public function __construct( } // 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; + // sub-schemas — not only SchemaGenerator / fromArray. + $this->inputSchema = self::normalizeSchema($inputSchema); + $this->outputSchema = null !== $outputSchema ? self::normalizeSchema($outputSchema) : null; } /** @@ -162,59 +199,85 @@ public function jsonSerialize(): array } /** - * Normalize schema properties: convert empty `properties` arrays to `\stdClass` - * so they JSON-encode as `{}` (a JSON Schema object) rather than `[]`. + * Normalize a JSON Schema so that empty sub-schemas JSON-encode as `{}` rather than `[]`. * - * Walks nested property schemas recursively so nested object parameters and - * `outputSchema` are covered, not only the top-level `properties` map. + * Once JSON is decoded into associative arrays, PHP cannot tell the empty object `{}` + * from the empty array `[]` — both are `[]`. Re-encoding then produces `[]`, which is + * invalid wherever a schema is expected (`properties`, `items`, `additionalProperties`, + * …), and strict clients reject it. Every empty sub-schema is therefore replaced with a + * `\stdClass` before serialization. + * + * The walk is recursive and covers the schema keywords of draft-07 through 2020-12, so + * nested object parameters, `$defs`, combinators, and `outputSchema` are all covered — + * not only the top-level `properties` map. * * @param array $schema * * @return array */ - private static function normalizeSchemaProperties(array $schema): array + private static function normalizeSchema(array $schema): array { - 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); - } - } + foreach (self::SUB_SCHEMA_KEYWORDS as $keyword) { + if (isset($schema[$keyword]) && \is_array($schema[$keyword])) { + $schema[$keyword] = self::normalizeSubSchema($schema[$keyword]); } } - 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']); + foreach (self::SUB_SCHEMA_MAP_KEYWORDS as $keyword) { + if (!isset($schema[$keyword]) || !\is_array($schema[$keyword])) { + continue; } - } - if (isset($schema['additionalProperties']) && \is_array($schema['additionalProperties'])) { - $schema['additionalProperties'] = self::normalizeSchemaProperties($schema['additionalProperties']); + if ([] === $schema[$keyword]) { + $schema[$keyword] = new \stdClass(); + continue; + } + + foreach ($schema[$keyword] as $name => $subSchema) { + if (\is_array($subSchema)) { + $schema[$keyword][$name] = self::normalizeSubSchema($subSchema); + } + } } - foreach (['anyOf', 'oneOf', 'allOf', 'prefixItems'] as $combiner) { - if (!isset($schema[$combiner]) || !\is_array($schema[$combiner])) { + foreach (self::SUB_SCHEMA_LIST_KEYWORDS as $keyword) { + if (!isset($schema[$keyword]) || !\is_array($schema[$keyword])) { continue; } - foreach ($schema[$combiner] as $index => $subSchema) { + // An empty list stays a list — `allOf: []` is already valid JSON. + foreach ($schema[$keyword] as $index => $subSchema) { if (\is_array($subSchema)) { - $schema[$combiner][$index] = self::normalizeSchemaProperties($subSchema); + $schema[$keyword][$index] = self::normalizeSubSchema($subSchema); } } } + if (isset($schema['items']) && \is_array($schema['items'])) { + // `items` is a single sub-schema, or a list of them in draft-07 tuple form. + // An empty array is read as the empty schema `{}` — what an `items: {}` from + // SchemaGenerator decodes to — rather than as an empty tuple. + if ([] !== $schema['items'] && array_is_list($schema['items'])) { + foreach ($schema['items'] as $index => $itemSchema) { + if (\is_array($itemSchema)) { + $schema['items'][$index] = self::normalizeSubSchema($itemSchema); + } + } + } else { + $schema['items'] = self::normalizeSubSchema($schema['items']); + } + } + return $schema; } + + /** + * @param array $schema + * + * @return array|\stdClass + */ + private static function normalizeSubSchema(array $schema): array|\stdClass + { + return [] === $schema ? new \stdClass() : self::normalizeSchema($schema); + } } diff --git a/tests/Unit/Schema/ToolTest.php b/tests/Unit/Schema/ToolTest.php index 1126ee0d..dc71189d 100644 --- a/tests/Unit/Schema/ToolTest.php +++ b/tests/Unit/Schema/ToolTest.php @@ -177,4 +177,116 @@ public function testConstructorNormalizesEmptyPropertiesInsideArrayItems(): void $this->assertInstanceOf(\stdClass::class, $tool->inputSchema['properties']['rows']['items']['properties']); } + + /** + * Each case is a schema that is already valid JSON: decoding it collapses every `{}` + * to `[]`, and normalization has to restore it verbatim. + * + * @return iterable + */ + public static function emptySubSchemaProvider(): iterable + { + yield 'top-level properties' => ['{"type":"object","properties":{}}']; + yield 'nested properties' => ['{"type":"object","properties":{"filter":{"type":"object","properties":{}}}}']; + yield 'property schema' => ['{"type":"object","properties":{"anything":{}}}']; + yield 'items schema' => ['{"type":"object","properties":{"tags":{"type":"array","items":{}}}}']; + yield 'tuple items schema' => ['{"type":"object","properties":{"pair":{"type":"array","items":[{"type":"object","properties":{}},{}]}}}']; + yield 'additionalItems schema' => ['{"type":"object","properties":{"tags":{"type":"array","additionalItems":{}}}}']; + yield 'additionalProperties schema' => ['{"type":"object","properties":{"map":{"type":"object","additionalProperties":{}}}}']; + yield 'propertyNames schema' => ['{"type":"object","properties":{"map":{"type":"object","propertyNames":{}}}}']; + yield 'contains schema' => ['{"type":"object","properties":{"tags":{"type":"array","contains":{}}}}']; + yield 'unevaluatedItems schema' => ['{"type":"object","properties":{"tags":{"type":"array","unevaluatedItems":{}}}}']; + yield 'unevaluatedProperties schema' => ['{"type":"object","properties":{"map":{"type":"object","unevaluatedProperties":{}}}}']; + yield 'not schema' => ['{"type":"object","properties":{"a":{"not":{}}}}']; + yield 'if/then/else schemas' => ['{"type":"object","properties":{"a":{"if":{},"then":{"type":"object","properties":{}},"else":{}}}}']; + yield '$defs entry' => ['{"type":"object","properties":{"a":{"$ref":"#/$defs/E"}},"$defs":{"E":{"type":"object","properties":{}}}}']; + yield 'definitions entry' => ['{"type":"object","properties":{"a":{"$ref":"#/definitions/E"}},"definitions":{"E":{}}}']; + yield 'patternProperties entry' => ['{"type":"object","properties":{"map":{"type":"object","patternProperties":{"^x":{}}}}}']; + yield 'dependentSchemas entry' => ['{"type":"object","properties":{"a":{"type":"string"}},"dependentSchemas":{"a":{}}}']; + yield 'combinator branches' => ['{"type":"object","properties":{"a":{"anyOf":[{},{"type":"object","properties":{}}],"oneOf":[{}],"allOf":[{}]}}}']; + yield 'prefixItems entry' => ['{"type":"object","properties":{"a":{"type":"array","prefixItems":[{},{"type":"object","properties":{}}]}}}']; + } + + #[DataProvider('emptySubSchemaProvider')] + public function testConstructorNormalizesEmptySubSchemas(string $schemaJson): void + { + /** @var array{type: 'object', properties: array, required: string[]|null} $schema */ + $schema = json_decode($schemaJson, true, 512, \JSON_THROW_ON_ERROR); + + $tool = new Tool(name: 't', title: null, inputSchema: $schema, description: null, annotations: null); + + $this->assertSame($schemaJson, json_encode($tool->inputSchema, \JSON_UNESCAPED_SLASHES)); + } + + #[DataProvider('emptySubSchemaProvider')] + public function testConstructorNormalizesEmptySubSchemasInOutputSchema(string $schemaJson): void + { + /** @var array{type: 'object', properties?: array} $schema */ + $schema = json_decode($schemaJson, true, 512, \JSON_THROW_ON_ERROR); + + $tool = new Tool( + name: 't', + title: null, + inputSchema: self::validInputSchema(), + description: null, + annotations: null, + outputSchema: $schema, + ); + + $this->assertSame($schemaJson, json_encode($tool->outputSchema, \JSON_UNESCAPED_SLASHES)); + } + + /** + * @return iterable + */ + public static function preservedEmptyArrayProvider(): iterable + { + yield 'empty combinator list' => ['{"type":"object","properties":{},"allOf":[]}']; + yield 'empty prefixItems list' => ['{"type":"object","properties":{},"prefixItems":[]}']; + yield 'empty required list' => ['{"type":"object","properties":{},"required":[]}']; + yield 'empty enum list' => ['{"type":"object","properties":{"a":{"enum":[]}}}']; + yield 'empty dependentRequired list' => ['{"type":"object","properties":{"a":{}},"dependentRequired":{"a":[]}}']; + } + + /** + * Keywords that hold JSON arrays — not sub-schemas — must keep encoding as `[]`. + */ + #[DataProvider('preservedEmptyArrayProvider')] + public function testConstructorLeavesNonSchemaEmptyArraysAlone(string $schemaJson): void + { + /** @var array{type: 'object', properties: array, required: string[]|null} $schema */ + $schema = json_decode($schemaJson, true, 512, \JSON_THROW_ON_ERROR); + + $tool = new Tool(name: 't', title: null, inputSchema: $schema, description: null, annotations: null); + + $this->assertSame($schemaJson, json_encode($tool->inputSchema, \JSON_UNESCAPED_SLASHES)); + } + + /** + * Regression test for #151: `SchemaGenerator` emits `items: {}` for untyped arrays, + * but a client decoding that payload gets `items: []` back — re-serializing it used to + * hand strict clients the very schema #151 fixed. + */ + public function testFromArrayRoundTripPreservesEmptyItemsSchema(): void + { + $tool = new Tool( + name: 't', + title: null, + inputSchema: [ + 'type' => 'object', + 'properties' => ['tags' => ['type' => 'array', 'items' => new \stdClass()]], + 'required' => null, + ], + description: null, + annotations: null, + ); + + $wire = (string) json_encode($tool); + $this->assertStringContainsString('"items":{}', $wire); + + /** @var array{name: string, inputSchema: array{type: 'object', properties: array, required: string[]|null}} $decoded */ + $decoded = json_decode($wire, true, 512, \JSON_THROW_ON_ERROR); + + $this->assertSame($wire, json_encode(Tool::fromArray($decoded))); + } }