[Schema] Always emit {} for empty tool schema properties (#407)

* [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

* [Schema] Normalize every empty sub-schema, not only properties

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 `[]`.

---------

Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>
This commit is contained in:
Björn Meyer 2026-08-14 21:50:08 +02:00 • committed by GitHub
commit 9236824b6d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 327 additions and 12 deletions

View file

@ -2,6 +2,11 @@
All notable changes to `mcp/sdk` will be documented in this file.
0.8.0
-----
* 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
-----

View file

@ -21,14 +21,14 @@ use Mcp\Exception\InvalidArgumentException;
*
* @phpstan-type ToolInputSchema array{
* type: 'object',
* properties: array<string, mixed>,
* properties: array<string, mixed>|\stdClass,
* required: string[]|null
* }
* @phpstan-type ToolOutputSchema array{
* type: 'object',
* properties?: array<string, mixed>,
* properties?: array<string, mixed>|\stdClass,
* required?: string[]|null,
* additionalProperties?: bool|array<string, mixed>,
* additionalProperties?: bool|array<string, mixed>|\stdClass,
* description?: string
* }
* @phpstan-type ToolData array{
@ -46,6 +46,53 @@ use Mcp\Exception\InvalidArgumentException;
*/
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
*/
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 +108,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
// sub-schemas — not only SchemaGenerator / fromArray.
$this->inputSchema = self::normalizeSchema($inputSchema);
$this->outputSchema = null !== $outputSchema ? self::normalizeSchema($outputSchema) : null;
}
/**
@ -87,20 +139,19 @@ class Tool implements \JsonSerializable
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,18 +199,85 @@ class Tool implements \JsonSerializable
}
/**
* Normalize schema properties: convert an empty properties array to stdClass.
* Normalize a JSON Schema so that empty sub-schemas JSON-encode as `{}` rather than `[]`.
*
* 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<string, mixed> $schema
*
* @return array<string, mixed>
*/
private static function normalizeSchemaProperties(array $schema): array
private static function normalizeSchema(array $schema): array
{
if (isset($schema['properties']) && \is_array($schema['properties']) && empty($schema['properties'])) {
$schema['properties'] = new \stdClass();
foreach (self::SUB_SCHEMA_KEYWORDS as $keyword) {
if (isset($schema[$keyword]) && \is_array($schema[$keyword])) {
$schema[$keyword] = self::normalizeSubSchema($schema[$keyword]);
}
}
foreach (self::SUB_SCHEMA_MAP_KEYWORDS as $keyword) {
if (!isset($schema[$keyword]) || !\is_array($schema[$keyword])) {
continue;
}
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 (self::SUB_SCHEMA_LIST_KEYWORDS as $keyword) {
if (!isset($schema[$keyword]) || !\is_array($schema[$keyword])) {
continue;
}
// An empty list stays a list — `allOf: []` is already valid JSON.
foreach ($schema[$keyword] as $index => $subSchema) {
if (\is_array($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<string, mixed> $schema
*
* @return array<string, mixed>|\stdClass
*/
private static function normalizeSubSchema(array $schema): array|\stdClass
{
return [] === $schema ? new \stdClass() : self::normalizeSchema($schema);
}
}

View file

@ -97,4 +97,196 @@ class ToolTest extends TestCase
$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<string, mixed>, 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']);
}
/**
* Each case is a schema that is already valid JSON: decoding it collapses every `{}`
* to `[]`, and normalization has to restore it verbatim.
*
* @return iterable<string, array{string}>
*/
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<string, mixed>, 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<string, mixed>} $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<string, array{string}>
*/
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<string, mixed>, 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<string, mixed>, required: string[]|null}} $decoded */
$decoded = json_decode($wire, true, 512, \JSON_THROW_ON_ERROR);
$this->assertSame($wire, json_encode(Tool::fromArray($decoded)));
}
}