php-sdk/tests/Unit/Schema/ToolTest.php
Björn Meyer 9236824b6d
[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>
2026-08-14 21:50:08 +02:00

292 lines
12 KiB
PHP

<?php
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Tests\Unit\Schema;
use Mcp\Schema\Tool;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
class ToolTest extends TestCase
{
/**
* @return array{type: 'object', properties: array<string, mixed>, required: string[]|null}
*/
private static function validInputSchema(): array
{
return [
'type' => 'object',
'properties' => ['q' => ['type' => 'string']],
'required' => null,
];
}
private static function makeTool(?string $title, ?string $description = null): Tool
{
return new Tool(
name: 'x',
inputSchema: self::validInputSchema(),
description: $description,
annotations: null,
title: $title,
);
}
/**
* @return iterable<string, array{?string, list<string>}>
*/
public static function serializationKeyOrderProvider(): iterable
{
yield 'with title' => ['Friendly Title', ['name', 'title', 'inputSchema']];
yield 'without title' => [null, ['name', 'inputSchema']];
}
/**
* @param list<string> $expectedKeys
*/
#[DataProvider('serializationKeyOrderProvider')]
public function testSerializationPlacesTitleBetweenNameAndInputSchema(?string $title, array $expectedKeys): void
{
$serialized = self::makeTool($title)->jsonSerialize();
$this->assertSame($expectedKeys, array_keys($serialized));
if (null !== $title) {
$this->assertSame($title, $serialized['title']);
} else {
$this->assertArrayNotHasKey('title', $serialized);
}
}
/**
* @return iterable<string, array{array<string, mixed>, ?string}>
*/
public static function fromArrayTitleProvider(): iterable
{
yield 'title present' => [['title' => 'Friendly Title'], 'Friendly Title'];
yield 'title missing' => [[], null];
}
/**
* @param array<string, mixed> $extra
*/
#[DataProvider('fromArrayTitleProvider')]
public function testFromArrayReadsTitle(array $extra, ?string $expectedTitle): void
{
$tool = Tool::fromArray(['name' => 'x', 'inputSchema' => self::validInputSchema()] + $extra);
$this->assertSame($expectedTitle, $tool->title);
}
public function testRoundTripPreservesTitle(): void
{
$original = self::makeTool('Friendly Title', 'desc');
/** @var array{name: string, title?: string, inputSchema: array{type: 'object', properties: array<string, mixed>, required: string[]|null}, description?: string|null} $serialized */
$serialized = $original->jsonSerialize();
$restored = Tool::fromArray($serialized);
$this->assertSame('Friendly Title', $restored->title);
$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)));
}
}