Environment
Summary
A Tool whose inputSchema.properties is an empty PHP array serializes to "properties":[]. JSON Schema requires an object there, so strict clients reject the whole tools/list payload — e.g. OpenAI-compatible clients answer 400 invalid_function_parameters: "[] is not of type 'object'". One parameterless tool then breaks every request, not just calls to that tool.
The SDK already knows empty properties must become {} — it does so in SchemaGenerator::buildSchemaFromParameters() and in Tool::fromArray() (via normalizeSchemaProperties(), PR #223). But this normalization lives only on those input boundaries. Tool::__construct() and Tool::jsonSerialize() do not enforce it, so any Tool built another way — or whose schema survived a json_decode($json, true) round-trip — reaches the wire unnormalized.
Reproduction
Direct construction (bypasses fromArray):
use Mcp\Schema\Tool;
$tool = new Tool(
name: 'no_params',
title: null,
inputSchema: ['type' => 'object', 'properties' => []],
description: null,
annotations: null,
);
echo json_encode($tool);
// {"name":"no_params","inputSchema":{"type":"object","properties":[]}} <-- invalid
The round-trip that bites real servers (an empty JSON object decodes to an empty PHP array, then re-serializes as []):
$schema = json_decode('{"type":"object","properties":{}}', true); // ['type'=>'object','properties'=>[]]
$tool = new Tool('t', null, $schema, null, null);
echo json_encode($tool); // "properties":[] — the {} was silently lost
Expected: "properties":{} in every case.
Actual: "properties":[] whenever the schema wasn't produced by SchemaGenerator or passed through Tool::fromArray().
Additional facet — normalization is also shallow
Even SchemaGenerator and Tool::fromArray() only normalize the top-level properties. A nested object parameter with no members, or an empty outputSchema.properties, still serializes as []:
$tool = Tool::fromArray([
'name' => 't',
'inputSchema' => [
'type' => 'object',
'properties' => ['filter' => ['type' => 'object', 'properties' => []]], // nested, not normalized
],
]);
echo json_encode($tool); // ...{"filter":{"type":"object","properties":[]}} <-- still invalid
Suggested fix
Make the object invariant total rather than boundary-only:
- Normalize in
Tool::__construct() (and/or Tool::jsonSerialize()) so a Tool can never hold or emit properties: [], regardless of how it was built.
- Make the walk recursive so nested object schemas and
outputSchema are covered too, not just the top-level properties.
This mirrors the precedent in PR #378 ("Always emit items for array tool parameter schemas"), which fixed the same class of strict-client rejection (VS Code / Copilot) in the SDK rather than leaving each consumer to patch it at the transport layer. Downstream servers currently have to re-parse and rewrite every tools/list response to work around this.
Environment
mcp/sdkv0.7.0Summary
A
ToolwhoseinputSchema.propertiesis an empty PHP array serializes to"properties":[]. JSON Schema requires an object there, so strict clients reject the wholetools/listpayload — e.g. OpenAI-compatible clients answer400 invalid_function_parameters: "[] is not of type 'object'". One parameterless tool then breaks every request, not just calls to that tool.The SDK already knows empty
propertiesmust become{}— it does so inSchemaGenerator::buildSchemaFromParameters()and inTool::fromArray()(vianormalizeSchemaProperties(), PR #223). But this normalization lives only on those input boundaries.Tool::__construct()andTool::jsonSerialize()do not enforce it, so anyToolbuilt another way — or whose schema survived ajson_decode($json, true)round-trip — reaches the wire unnormalized.Reproduction
Direct construction (bypasses
fromArray):The round-trip that bites real servers (an empty JSON object decodes to an empty PHP array, then re-serializes as
[]):Expected:
"properties":{}in every case.Actual:
"properties":[]whenever the schema wasn't produced bySchemaGeneratoror passed throughTool::fromArray().Additional facet — normalization is also shallow
Even
SchemaGeneratorandTool::fromArray()only normalize the top-levelproperties. A nested object parameter with no members, or an emptyoutputSchema.properties, still serializes as[]:Suggested fix
Make the object invariant total rather than boundary-only:
Tool::__construct()(and/orTool::jsonSerialize()) so aToolcan never hold or emitproperties: [], regardless of how it was built.outputSchemaare covered too, not just the top-levelproperties.This mirrors the precedent in PR #378 ("Always emit
itemsfor array tool parameter schemas"), which fixed the same class of strict-client rejection (VS Code / Copilot) in the SDK rather than leaving each consumer to patch it at the transport layer. Downstream servers currently have to re-parse and rewrite everytools/listresponse to work around this.