diff --git a/includes/Domain/Utils/AbilityArgumentNormalizer.php b/includes/Domain/Utils/AbilityArgumentNormalizer.php index 68d800f5..49f0ec75 100644 --- a/includes/Domain/Utils/AbilityArgumentNormalizer.php +++ b/includes/Domain/Utils/AbilityArgumentNormalizer.php @@ -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 " (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 */ @@ -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 " (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 $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; + } } diff --git a/tests/phpunit/Unit/Domain/Utils/AbilityArgumentNormalizerTest.php b/tests/phpunit/Unit/Domain/Utils/AbilityArgumentNormalizerTest.php index 49cf9d67..1e979b96 100644 --- a/tests/phpunit/Unit/Domain/Utils/AbilityArgumentNormalizerTest.php +++ b/tests/phpunit/Unit/Domain/Utils/AbilityArgumentNormalizerTest.php @@ -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', @@ -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. */