$schema The schema to prepare. * * @return array The client-prepared schema. * @since 1.4.0 */ private function prepare_schema( array $schema ): array { $normalised = array_map( [ $this, 'to_array_deep' ], $schema ); // Stripping server-only keys needs 7.1; below that they stay, since the // function does not exist. 6.9/7.0 fallback — removable, see WpCompat. // The object-default correction below runs on every version. if ( WpCompat::supports_client_schema_prep() ) { $normalised = wp_prepare_json_schema_for_client( $normalised ); } return $this->objectify_object_defaults( $normalised ); } /** * Render an object schema's empty default as `{}` rather than `[]`. * * {@see \Albert\Abstracts\BaseAbility::prepare_input_schema()} gives every * object-typed input schema a top-level `default` of an empty PHP array, so * that a call arriving with no arguments at all is rescued into an empty * object instead of failing validation as "input is not of type object". * That is a server-side rescue, but PHP cannot tell an empty list from an * empty map, so it reached the client as `"default": []` on a schema that * says `"type": "object"` two lines up — a contradiction a strict JSON * Schema consumer is entitled to complain about. * * The default is not dropped, because it is true: these abilities really can * be called with nothing. It is restored to the object it was always meant * to be. Only an *empty* default on an *object*-typed schema is touched, so * an array-typed property whose default is genuinely `[]` is left alone. * * @param array $schema The schema to correct. * * @return array The schema with object defaults rendered as objects. * @since 1.4.0 */ private function objectify_object_defaults( array $schema ): array { foreach ( $schema as $key => $value ) { if ( is_array( $value ) ) { $schema[ $key ] = $this->objectify_object_defaults( $value ); } } if ( ( $schema['default'] ?? null ) === [] && $this->is_object_type( $schema['type'] ?? null ) ) { $schema['default'] = new \stdClass(); } return $schema; } /** * Whether a schema's declared type is an object and nothing but an object. * * A union such as `[ 'object', 'array' ]` is left alone: an empty default is * ambiguous there, and guessing would be worse than saying nothing. * * @param mixed $type The schema's `type` keyword. * * @return bool True when the type is exactly `object`. * @since 1.4.0 */ private function is_object_type( $type ): bool { if ( is_array( $type ) ) { return array_values( array_unique( $type ) ) === [ 'object' ]; } return $type === 'object'; } /** * Recursively convert `stdClass` nodes to arrays, leaving other values as-is. * * @param mixed $value The value to normalise. * * @return mixed The value with any `stdClass` nodes turned into arrays. * @since 1.4.0 */ private function to_array_deep( $value ) { if ( $value instanceof \stdClass ) { $value = (array) $value; } return is_array( $value ) ? array_map( [ $this, 'to_array_deep' ], $value ) : $value; } /** * Re-object empty JSON Schema keyword maps so they serialise as `{}`, not `[]`. * * The array normalisation cannot distinguish an empty object from an empty * list, so a keyword like `properties: {}` becomes an empty PHP array and * would render as the JSON array `[]`, which is invalid where an object is * required. This restores the standard subschema-map keywords to `stdClass` * when they are empty. Non-empty maps already have string keys and serialise * as objects correctly, so only the empty case needs fixing. * * @param array $schema The prepared schema. * * @return array The schema with empty keyword maps re-objected. * @since 1.4.0 */ private function restore_object_maps( array $schema ): array { $object_map_keywords = [ 'properties', 'patternProperties', 'dependentSchemas', 'definitions', '$defs' ]; foreach ( $schema as $key => $value ) { if ( ! is_array( $value ) ) { continue; } $value = $this->restore_object_maps( $value ); $schema[ $key ] = ( $value === [] && in_array( $key, $object_map_keywords, true ) ) ? new \stdClass() : $value; } return $schema; } /** * Prepare the schemas embedded in a get-ability-info result. * * Fires on the raw execution result, before the adapter turns it into a * protocol response, so the `input_schema` / `output_schema` keys are plain * arrays returned by the meta-tool. Every other tool, and every non-array * result (e.g. a WP_Error), passes through untouched. * * `mcp_adapter_tool_call_result` is a global hook that any adapter-based MCP * server fires (including WooCommerce's bundled copy, which registers a * meta-tool of the same name), so this acts only on Albert's own server. * * @param mixed $result The raw tool execution result. * @param mixed $args The tool arguments used (unused). * @param string $tool_name The tool name that was called. * @param mixed $mcp_tool The MCP tool instance (unused). * @param mixed $server The MCP server firing the filter. * * @return mixed The result with client-prepared schemas on 7.1+. * @since 1.4.0 */ public function prepare_ability_info_result( $result, $args, string $tool_name, $mcp_tool = null, $server = null ) { if ( ! Server::is_albert_server( $server ) ) { return $result; } if ( ! $this->is_ability_info_tool( $tool_name ) || ! is_array( $result ) ) { return $result; } foreach ( [ 'input_schema', 'output_schema' ] as $key ) { if ( isset( $result[ $key ] ) && is_array( $result[ $key ] ) ) { $result[ $key ] = $this->restore_object_maps( $this->prepare_schema( $result[ $key ] ) ); } } return $result; } /** * Whether a tool name refers to the get-ability-info meta-tool. * * Matches the sanitised spelling a client sends and the raw ability id * alike, the way {@see \Albert\Core\AbilitiesRegistry::is_transport_ability()} * does for the transport set. * * @param string $tool_name The tool name as it arrived on the filter. * * @return bool True when this is the get-ability-info meta-tool. * @since 1.4.0 */ private function is_ability_info_tool( string $tool_name ): bool { return str_replace( '/', '-', $tool_name ) === str_replace( '/', '-', self::ABILITY_INFO_TOOL ); } }