> $registry Registry. * @return array> */ function aafm_register_user_meta_definitions( array $registry ): array { $registry['aafm/get-user-meta'] = array( 'label' => __( 'Get user meta', 'agent-abilities-for-mcp' ), 'description' => __( 'Read a single allowlisted scalar meta value from a user the agent can edit. Auth, capability, and 2FA keys are never readable.', 'agent-abilities-for-mcp' ), 'group' => 'reads', 'risk' => 'read', 'subject' => 'users', 'args_builder' => 'aafm_args_get_user_meta', ); $registry['aafm/update-user-meta'] = array( 'label' => __( 'Update user meta', 'agent-abilities-for-mcp' ), 'description' => __( 'Write a single allowlisted scalar meta value to a user the agent can edit. Auth, capability, and 2FA keys are blocked outright. The response returns the old value when it is plain text or a number.', 'agent-abilities-for-mcp' ), 'group' => 'writes', 'risk' => 'write', 'subject' => 'users', 'args_builder' => 'aafm_args_update_user_meta', ); $registry['aafm/delete-user-meta'] = array( 'label' => __( 'Delete user meta', 'agent-abilities-for-mcp' ), 'description' => __( 'Delete an allowlisted meta key from a user the agent can edit. Removes all values of that key. Auth and capability keys can never be touched. The response returns the old value when it is plain text or a number.', 'agent-abilities-for-mcp' ), 'group' => 'writes', 'risk' => 'destructive', 'subject' => 'users', 'args_builder' => 'aafm_args_delete_user_meta', ); return $registry; } /** * Shared user-meta gate: the target user must be editable by the agent (per-object * edit_user($id)) AND the key must clear the hard-block + allowlist. Used by all three * user-meta permission callbacks. Reads are gated identically to writes because user meta * can hold private data. * * @param array $input Ability input. * @return bool */ function aafm_can_access_user_meta( array $input ): bool { $id = isset( $input['user_id'] ) ? absint( $input['user_id'] ) : 0; // The object-independent edit_users floor comes first: edit_user($id) alone is true for every // user against their own id (map_meta_cap self short-circuit), so without the floor a subscriber // could read or write its own user meta. Mirrors aafm_perm_update_user(); matches the edit_users // discovery floor these abilities already use in server.php. if ( $id < 1 || ! current_user_can( 'edit_users' ) || ! aafm_user_can_checked( 'edit_user', $id, 'user' ) ) { return false; } $key = isset( $input['key'] ) ? (string) $input['key'] : ''; return ! is_wp_error( aafm_validate_user_meta_key( $key ) ); } /** * Args for aafm/get-user-meta. * * @return array */ function aafm_args_get_user_meta(): array { return array( 'label' => aafm_ability_label( 'aafm/get-user-meta' ), 'description' => aafm_ability_description( 'aafm/get-user-meta' ), 'category' => 'aafm-reads', 'input_schema' => array( 'type' => 'object', 'properties' => array( 'user_id' => array( 'type' => 'integer', 'minimum' => 1, 'description' => __( 'ID of the user whose meta to read. The current user must have edit access to this user.', 'agent-abilities-for-mcp' ), ), 'key' => array( 'type' => 'string', 'minLength' => 1, 'description' => __( 'The user-meta key to operate on. Must be on the operator\'s user-meta allowlist (or the allow-all wildcard) and not explicitly denied or hard-blocked; session tokens, application passwords, capability, user-level, and 2FA/passkey keys can never be exposed under any allowlist, and a key that fails any of those checks is rejected before the operation runs. (The post-meta abilities name the equivalent parameter meta_key; the two are kept distinct for back-compatibility and are not interchangeable.)', 'agent-abilities-for-mcp' ), ), ), 'required' => array( 'user_id', 'key' ), 'additionalProperties' => false, ), 'output_schema' => array( 'type' => 'object', 'properties' => array( 'user_id' => array( 'type' => 'integer' ), 'key' => array( 'type' => 'string' ), 'value' => array( 'type' => array( 'string', 'number', 'boolean' ), 'description' => __( 'The stored scalar value. A key with no stored value is returned as an empty string, not null.', 'agent-abilities-for-mcp' ), ), ), ), 'execute_callback' => 'aafm_exec_get_user_meta', 'permission_callback' => 'aafm_perm_user_meta_access', 'meta' => array( 'annotations' => array( 'readonly' => true, 'destructive' => false, 'idempotent' => true, ), ), ); } /** * Args for aafm/update-user-meta. * * @return array */ function aafm_args_update_user_meta(): array { return array( 'label' => aafm_ability_label( 'aafm/update-user-meta' ), 'description' => aafm_ability_description( 'aafm/update-user-meta' ), 'category' => 'aafm-writes', 'input_schema' => array( 'type' => 'object', 'properties' => array( 'user_id' => array( 'type' => 'integer', 'minimum' => 1, 'description' => __( 'ID of the user whose meta to write. The current user must have edit access to this user.', 'agent-abilities-for-mcp' ), ), 'key' => array( 'type' => 'string', 'minLength' => 1, 'description' => __( 'The user-meta key to operate on. Must be on the operator\'s user-meta allowlist (or the allow-all wildcard) and not explicitly denied or hard-blocked; session tokens, application passwords, capability, user-level, and 2FA/passkey keys can never be exposed under any allowlist, and a key that fails any of those checks is rejected before the operation runs. (The post-meta abilities name the equivalent parameter meta_key; the two are kept distinct for back-compatibility and are not interchangeable.)', 'agent-abilities-for-mcp' ), ), 'value' => array( 'type' => array( 'string', 'number', 'boolean', 'integer' ), 'description' => __( 'The scalar value to store. Only text, number, or boolean values are accepted; the value is plain-text sanitized and then run through any registered sanitize_user_meta_{key} callback.', 'agent-abilities-for-mcp' ), ), ), 'required' => array( 'user_id', 'key', 'value' ), 'additionalProperties' => false, ), 'output_schema' => array( 'type' => 'object', 'properties' => array_merge( array( 'user_id' => array( 'type' => 'integer' ), 'key' => array( 'type' => 'string' ), 'value' => array( 'type' => array( 'string', 'number', 'boolean', 'integer' ), ), ), aafm_meta_write_output_properties() ), ), 'execute_callback' => 'aafm_exec_update_user_meta', 'permission_callback' => 'aafm_perm_user_meta_access', 'meta' => array( 'annotations' => array( 'readonly' => false, 'destructive' => false, ), ), ); } /** * Args for aafm/delete-user-meta. * * @return array */ function aafm_args_delete_user_meta(): array { return array( 'label' => aafm_ability_label( 'aafm/delete-user-meta' ), 'description' => aafm_ability_description( 'aafm/delete-user-meta' ), 'category' => 'aafm-writes', 'input_schema' => array( 'type' => 'object', 'properties' => array( 'user_id' => array( 'type' => 'integer', 'minimum' => 1, 'description' => __( 'ID of the user whose meta to delete. The current user must have edit access to this user.', 'agent-abilities-for-mcp' ), ), 'key' => array( 'type' => 'string', 'minLength' => 1, 'description' => __( 'The user-meta key to operate on. Must be on the operator\'s user-meta allowlist (or the allow-all wildcard) and not explicitly denied or hard-blocked; session tokens, application passwords, capability, user-level, and 2FA/passkey keys can never be exposed under any allowlist, and a key that fails any of those checks is rejected before the operation runs. (The post-meta abilities name the equivalent parameter meta_key; the two are kept distinct for back-compatibility and are not interchangeable.)', 'agent-abilities-for-mcp' ), ), ), 'required' => array( 'user_id', 'key' ), 'additionalProperties' => false, ), 'output_schema' => array( 'type' => 'object', 'properties' => array_merge( array( 'deleted' => array( 'type' => 'boolean' ), ), aafm_meta_delete_output_properties() ), ), 'execute_callback' => 'aafm_exec_delete_user_meta', 'permission_callback' => 'aafm_perm_user_meta_access', 'meta' => array( 'annotations' => array( 'readonly' => false, 'destructive' => true, ), ), ); } /** * Permission for all three user-meta abilities: the shared per-object + per-key gate. * * @param array $input Ability input. * @return bool */ function aafm_perm_user_meta_access( array $input ): bool { return aafm_can_access_user_meta( $input ); } /** * Execute aafm/get-user-meta. * * Re-validates the key (defence in depth - the permission callback already gated it), * confirms the user exists, then reads a single value. Non-scalar values are refused so a * serialized array/object stored under an allowlisted key can never leak its structure (M3). * * @param array $input Validated input. * @return array|WP_Error */ function aafm_exec_get_user_meta( array $input ) { $id = absint( $input['user_id'] ); $key = aafm_validate_user_meta_key( isset( $input['key'] ) ? (string) $input['key'] : '' ); if ( is_wp_error( $key ) || ! aafm_exact_object( 'user', $id ) instanceof WP_User ) { return aafm_generic_error(); } $value = aafm_meta_get( 'user', $id, $key, true ); if ( '' !== $value && ! is_scalar( $value ) ) { return aafm_generic_error(); // never dump arrays/serialized blobs. } return array( 'user_id' => $id, 'key' => $key, 'value' => $value, ); } /** * Execute aafm/update-user-meta. * * Re-validates the key, refuses non-scalar values via aafm_sanitize_user_meta_value(), * then writes a single value. wp_slash() guards update_user_meta()'s internal unslash. The * stored value reads back as a longtext string, so the no-op guard compares stringified * forms to avoid a false failure when an int/bool is re-sent unchanged. * * @param array $input Validated input. * @return array|WP_Error */ function aafm_exec_update_user_meta( array $input ) { $id = absint( $input['user_id'] ); $key = aafm_validate_user_meta_key( isset( $input['key'] ) ? (string) $input['key'] : '' ); if ( is_wp_error( $key ) || ! aafm_exact_object( 'user', $id ) instanceof WP_User ) { return aafm_generic_error(); } // This used to omit the object subtype, defaulting to ''. get_userdata() // above already confirmed the target user exists, and core's get_object_subtype( 'user', $id ) // resolves to the literal string 'user' for any user that exists (wp-includes/meta.php) - that // is the subtype update_metadata() itself passes to sanitize_meta() at write time. That resolution is filterable via get_object_subtype_user, so resolve it through // get_object_subtype() itself rather than hardcoding the literal 'user' - a site remapping the // subtype is honoured here the same way it is at write time. $subtype = (string) get_object_subtype( 'user', $id ); $value = aafm_sanitize_user_meta_value( $key, $input['value'] ?? '', $subtype ); if ( is_wp_error( $value ) ) { return $value; } $result = aafm_meta_set( 'user', $id, $key, $value, $subtype ); if ( is_wp_error( $result ) ) { return $result; } return aafm_meta_update_response( 'user', $id, $key, $result, array( 'user_id' => $id, 'key' => $key, ) ); } /** * Execute aafm/delete-user-meta. * * Re-validates the key (defence in depth - the permission callback already gated it), * then removes every value of that key. delete_user_meta() with no value arg deletes all * values of the key, which is the intended destructive behaviour here. * * @param array $input Validated input. * @return array|WP_Error */ function aafm_exec_delete_user_meta( array $input ) { $id = absint( $input['user_id'] ); $key = aafm_validate_user_meta_key( isset( $input['key'] ) ? (string) $input['key'] : '' ); if ( is_wp_error( $key ) || ! aafm_exact_object( 'user', $id ) instanceof WP_User ) { return aafm_generic_error(); } return aafm_meta_delete_response( 'user', $id, $key, aafm_meta_delete( 'user', $id, $key ) ); }