Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

All notable changes to `mcp/sdk` will be documented in this file.

0.7.1
-----

* Always emit `{}` for empty tool schema `properties`: `Tool` now recursively normalizes empty `properties` arrays (including nested object parameters and `outputSchema`) in the constructor, so `tools/list` never serializes invalid JSON Schema `properties: []` (#405).

0.7.0
-----

Expand Down
75 changes: 65 additions & 10 deletions src/Schema/Tool.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@
*
* @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>,
* description?: string
Expand All @@ -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
Expand All @@ -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;
}

/**
Expand All @@ -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,
Expand Down Expand Up @@ -148,16 +162,57 @@ 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<string, mixed> $schema
*
* @return array<string, mixed>
*/
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;
Expand Down
80 changes: 80 additions & 0 deletions tests/Unit/Schema/ToolTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<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']);
}
}