Skip to content
Merged
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
80 changes: 71 additions & 9 deletions includes/Domain/Utils/AbilityArgumentNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,15 @@
*
* MCP clients send {} (empty object) for tools without arguments.
* PHP decodes this as [] (empty array).
* Abilities without input_schema expect null, not empty array.
*
* Abilities without an input_schema expect null, not an empty array.
* Abilities with an input_schema usually expect an empty array, never null:
* PHP's [] satisfies an empty object or empty array schema, but null fails
* validation as "not of type <schema type>" (object, array, and so on). Two
* exceptions keep null: a schema whose type explicitly permits null (an
* explicit null value is passed through), and a schema with a top-level
* default, where null lets the Abilities API apply that default for both a
* null and an empty {} input. In both, null is the author's declared intent.
*
* @since 0.5.0
*/
Expand All @@ -23,23 +31,77 @@ class AbilityArgumentNormalizer {
/**
* Normalize parameters for an ability based on its input schema.
*
* If the ability has no input schema, empty arrays are converted to null.
* This ensures compatibility with abilities that don't accept parameters.
* No input schema: empty arrays are converted to null, so abilities that
* take no parameters see null.
* Has input schema: null or an empty array normalizes to an empty array so a
* zero-argument call passes schema validation instead of failing as "not of
* type <schema type>" (object, array, and so on). Two exceptions return null:
* a top-level default (both null and {} return null so the Abilities API
* applies the default) and a type that explicitly permits null (an explicit
* null is passed through).
*
* @param \WP_Ability $ability The ability to normalize parameters for.
* @param mixed $parameters The parameters to normalize.
* @param \WP_Ability $ability The ability to normalize parameters for.
* @param mixed $parameters The parameters to normalize.
*
* @return mixed Normalized parameters (null if ability has no schema and params are empty).
* @return mixed Normalized parameters (null when no schema and params are empty; empty array when a schema is present and params are empty or null, unless the schema declares a top-level default or its type permits null, in which case null is returned).
* @since 0.5.0
*
* @since n.e.x.t Empty or null parameters for schema-defining abilities normalize to an empty array, except when the schema declares a top-level default (honored for both null and empty {} input) or its type explicitly permits null.
*/
public static function normalize( \WP_Ability $ability, $parameters ) {
$input_schema = $ability->get_input_schema();

if ( empty( $input_schema ) && is_array( $parameters ) && empty( $parameters ) ) {
return null;
// No schema: an empty {} means "no arguments" -> null.
if ( empty( $input_schema ) ) {
return is_array( $parameters ) && empty( $parameters ) ? null : $parameters;
}

// Has schema, missing/empty argument set (null or {}).
if ( null === $parameters || array() === $parameters ) {
// A top-level default is the author's declared "no input" value.
// Return null for BOTH null and {} so WP_Ability::normalize_input()
// applies the default -- it fills the default only when input is null.
// An MCP client sends {} to mean "no arguments", so {} must honor the
// default too, not just an omitted parameter.
if ( array_key_exists( 'default', $input_schema ) ) {
return null;
}

// An explicit null from the client is kept only when the schema's
// type permits null; {} stays [] as an empty object.
if ( null === $parameters && self::schema_permits_null( $input_schema ) ) {
return null;
}

// Otherwise use [], which satisfies an empty object or array schema
// so a zero-argument call validates. null never validates on its own.
return array();
}

return $parameters;
}

/**
* Whether the schema's top-level type explicitly permits null.
*
* Only an explicit top-level type is honored (JSON Schema "null", or a type
* array containing "null"). Composition keywords that also permit null
* (anyOf/oneOf/enum/const) are not inspected; such a schema falls through to
* [], which still validates when object or array is among the allowed forms.
* A schema with no type is treated as not permitting null, so a zero-argument
* call still normalizes to [] for callbacks that expect an array.
*
* @param array<string,mixed> $input_schema The ability input schema.
*
* @return bool True when null is a valid top-level value for the schema.
* @since n.e.x.t
*/
private static function schema_permits_null( array $input_schema ): bool {
$type = $input_schema['type'] ?? null;

if ( is_array( $type ) ) {
return in_array( 'null', $type, true );
}

return 'null' === $type;
}
}
99 changes: 97 additions & 2 deletions tests/phpunit/Unit/Domain/Utils/AbilityArgumentNormalizerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,13 @@ public function test_null_parameters_remain_null(): void {
}

/**
* Test that null parameters remain null even with input schema.
* Test that null parameters normalize to an empty array when the ability has an input schema.
*
* A schema-defining ability validates its input against the schema. null is
* rejected as "not of type object"; an empty array is a valid empty object.
* See issue #229.
*/
public function test_null_parameters_remain_null_with_schema(): void {
public function test_null_parameters_normalized_to_empty_array_with_schema(): void {
$ability = $this->create_ability_mock(
array(
'type' => 'object',
Expand All @@ -107,9 +111,100 @@ public function test_null_parameters_remain_null_with_schema(): void {

$result = AbilityArgumentNormalizer::normalize( $ability, null );

$this->assertIsArray( $result );
$this->assertEmpty( $result );
}

/**
* Test that null is preserved when the schema's type explicitly permits null.
*
* A schema typed ["null","object"] declares null as a valid value. Forcing []
* would discard the author's intent, so null must pass through unchanged.
* See issue #229 discussion.
*/
public function test_null_parameters_preserved_when_schema_permits_null(): void {
$ability = $this->create_ability_mock(
array(
'type' => array( 'null', 'object' ),
'properties' => array(
'name' => array( 'type' => 'string' ),
),
)
);

$result = AbilityArgumentNormalizer::normalize( $ability, null );

$this->assertNull( $result );
}

/**
* Test that null is preserved when the schema declares a top-level default.
*
* WP_Ability::normalize_input() fills a top-level default only when input is
* null. Forcing [] would skip the default, so null must pass through for the
* Abilities API to apply it. See issue #229 discussion.
*/
public function test_null_parameters_preserved_when_schema_has_default(): void {
$ability = $this->create_ability_mock(
array(
'type' => 'array',
'items' => array( 'type' => 'string' ),
'default' => array( 'a', 'b' ),
)
);

$result = AbilityArgumentNormalizer::normalize( $ability, null );

$this->assertNull( $result );
}

/**
* Test that an empty {} is kept as null when the schema declares a default.
*
* WP_Ability::normalize_input() applies a top-level default only when input
* is null. An MCP client sends {} to mean "no arguments", so {} (decoded as
* []) must also return null here, otherwise the default is skipped and a
* schema with required fields fails. Issue #229's title covers "empty {} /
* omitted parameters" alike.
*/
public function test_empty_array_preserved_as_null_when_schema_has_default(): void {
$ability = $this->create_ability_mock(
array(
'type' => 'object',
'required' => array( 'x' ),
'default' => array( 'x' => 1 ),
'properties' => array(
'x' => array( 'type' => 'integer' ),
),
)
);

$result = AbilityArgumentNormalizer::normalize( $ability, array() );

$this->assertNull( $result );
}

/**
* Test that an empty array still normalizes to [] even when the schema permits null.
*
* Only a null value is preserved; an explicit empty {} from the client stays [].
*/
public function test_empty_array_normalized_to_empty_array_when_schema_permits_null(): void {
$ability = $this->create_ability_mock(
array(
'type' => array( 'null', 'object' ),
'properties' => array(
'name' => array( 'type' => 'string' ),
),
)
);

$result = AbilityArgumentNormalizer::normalize( $ability, array() );

$this->assertIsArray( $result );
$this->assertEmpty( $result );
}

/**
* Test that non-array parameters are passed through unchanged.
*/
Expand Down
Loading