[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:
parent
a987a3a248
commit
9236824b6d
3 changed files with 327 additions and 12 deletions
|
|
@ -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
|
||||
-----
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue