PluginProbe ʕ •ᴥ•ʔ
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback / 5.1
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback v5.1
5.1.3 5.1.2 5.1.1 5.1 5.0 trunk 3.10 3.11 3.12 3.13 3.14 3.15 3.16 3.17 3.18 3.19 3.2.0 3.2.1 3.22 3.22.1 3.22.2 3.22.3 3.22.4 3.22.5 3.22.6 3.3.0 3.3.1 3.3.2 3.3.2.1 3.3.2.2 3.3.3 3.30 3.31 3.32 3.4 3.4.1 3.4.3 3.4.4 3.5 3.5.1 3.6 3.6.1 3.7 3.8 3.9 3.9.1 3.9.2 3.9.3 3.9.4 3.9.6 3.9.6.1 4.0 4.0.1 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.0.7 4.0.8 4.0.9 4.1.0 4.1.1 4.1.2 4.1.3 4.2 4.2.1 4.2.2 4.3 4.3.1 4.3.2 4.3.3 4.3.4 4.3.5 4.4
atarim-visual-collaboration / doit / abilities / class-avcf-abilities-users.php
atarim-visual-collaboration / doit / abilities Last commit date
class-avcf-abilities-base.php 3 weeks ago class-avcf-abilities-block-navigation.php 3 weeks ago class-avcf-abilities-cache.php 3 weeks ago class-avcf-abilities-content.php 3 weeks ago class-avcf-abilities-core.php 3 weeks ago class-avcf-abilities-global-styles.php 3 weeks ago class-avcf-abilities-gutenberg.php 3 weeks ago class-avcf-abilities-media.php 3 weeks ago class-avcf-abilities-metadata.php 3 weeks ago class-avcf-abilities-navigation.php 3 weeks ago class-avcf-abilities-patterns.php 3 weeks ago class-avcf-abilities-plugins.php 3 weeks ago class-avcf-abilities-settings.php 3 weeks ago class-avcf-abilities-taxonomies.php 3 weeks ago class-avcf-abilities-templates.php 3 weeks ago class-avcf-abilities-theme-files.php 3 weeks ago class-avcf-abilities-themes.php 3 weeks ago class-avcf-abilities-users.php 3 weeks ago
class-avcf-abilities-users.php
1057 lines
1 <?php
2 /**
3 * User and permissions management MCP abilities.
4 *
5 * Registers Atarim/* abilities for working with WordPress user accounts —
6 * full CRUD, role changes, and detection of two-factor authentication
7 * status across the three most common 2FA plugins.
8 *
9 * Uses standard WordPress capability checks (list_users / create_users /
10 * edit_users / delete_users / promote_users). Token validation happens
11 * upstream in AVCF_MCP::avcf_mcp_authenticate_request, which maps the
12 * incoming token to a real WP user — these abilities then check that
13 * mapped user's caps via current_user_can(). This is the same model used
14 * by every other ability cluster in DoIt.
15 *
16 * Exposed abilities:
17 * atarim/list-users List users with role / email / registered date filters.
18 * atarim/get-user Single user by ID with full detail, optional meta keys.
19 * atarim/create-user Create a user account.
20 * atarim/update-user Update profile fields (not password — separate concern).
21 * atarim/delete-user Delete a user; refuses to leave content orphaned without explicit reassign target.
22 * atarim/change-user-role Dedicated role-change ability (replace, add, remove).
23 * atarim/get-2fa-status Read 2FA enrollment status across Two Factor, WP 2FA, Wordfence.
24 *
25 * Note: ability names registered here must also be added to the $tools array
26 * in doit/class-avcf-mcp.php::avcf_mcp_setup_server() to be exposed by the
27 * MCP server.
28 *
29 * @package atarim-visual-collaboration
30 */
31
32 if ( ! defined('ABSPATH') ) {
33 exit;
34 }
35
36 class AVCF_Abilities_Users extends AVCF_Abilities_Base {
37
38 /**
39 * Register all user management abilities.
40 */
41 public function register() {
42
43 // ---- list-users ----
44 wp_register_ability( 'atarim/list-users', [
45 'label' => 'List Users',
46 'description' => 'Returns user accounts on the site with role, email, display name, and registered date. Supports filtering by role and by registration date range, free-text search across login / email / display_name, and pagination. Does NOT include the last_login field — WordPress does not track this natively; if you need login history, query the activity log plugin if one is installed.',
47 'category' => 'atarim',
48 'input_schema' => [
49 'type' => 'object',
50 'properties' => [
51 'role' => [
52 'type' => 'string',
53 'description' => 'Filter by role slug (e.g. "administrator", "editor", "subscriber"). Omit for all roles.',
54 'minLength' => 1,
55 ],
56 'search' => [
57 'type' => 'string',
58 'description' => 'Match login, email, or display name containing this string.',
59 'minLength' => 1,
60 ],
61 'registered_after' => [
62 'type' => 'string',
63 'description' => 'Only users registered on or after this date. ISO 8601 or strtotime()-parseable.',
64 ],
65 'registered_before' => [
66 'type' => 'string',
67 'description' => 'Only users registered on or before this date.',
68 ],
69 'orderby' => [
70 'type' => 'string',
71 'enum' => [ 'ID', 'login', 'email', 'display_name', 'registered' ],
72 'default' => 'registered',
73 ],
74 'order' => [
75 'type' => 'string',
76 'enum' => [ 'ASC', 'DESC' ],
77 'default' => 'DESC',
78 ],
79 'limit' => [
80 'type' => 'integer',
81 'description' => 'Max users per page. -1 returns all (use carefully on large sites). Defaults to 50.',
82 'default' => 50,
83 'minimum' => -1,
84 ],
85 'offset' => [
86 'type' => 'integer',
87 'description' => 'Skip this many users (for pagination).',
88 'default' => 0,
89 'minimum' => 0,
90 ],
91 ],
92 'additionalProperties' => false,
93 ],
94 'output_schema' => [
95 'type' => 'object',
96 'properties' => [
97 'total' => [ 'type' => 'integer' ],
98 'returned' => [ 'type' => 'integer' ],
99 'users' => [
100 'type' => 'array',
101 'items' => [
102 'type' => 'object',
103 'properties' => [
104 'id' => [ 'type' => 'integer' ],
105 'login' => [ 'type' => 'string' ],
106 'email' => [ 'type' => 'string' ],
107 'display_name' => [ 'type' => 'string' ],
108 'first_name' => [ 'type' => 'string' ],
109 'last_name' => [ 'type' => 'string' ],
110 'roles' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ],
111 'registered' => [ 'type' => 'string' ],
112 'post_count' => [ 'type' => 'integer' ],
113 ],
114 ],
115 ],
116 ],
117 'required' => [ 'total', 'returned', 'users' ],
118 ],
119 'execute_callback' => function( $input = [] ) {
120 $limit = isset( $input['limit'] ) ? (int) $input['limit'] : 50;
121 $offset = isset( $input['offset'] ) ? max( 0, (int) $input['offset'] ) : 0;
122
123 $args = [
124 'orderby' => isset( $input['orderby'] ) ? sanitize_key( $input['orderby'] ) : 'registered',
125 'order' => ( isset( $input['order'] ) && strtoupper( $input['order'] ) === 'ASC' ) ? 'ASC' : 'DESC',
126 'number' => ( $limit > 0 ) ? $limit : -1,
127 'offset' => $offset,
128 ];
129
130 if ( ! empty( $input['role'] ) ) {
131 $args['role'] = sanitize_key( (string) $input['role'] );
132 }
133 if ( ! empty( $input['search'] ) ) {
134 $args['search'] = '*' . esc_attr( (string) $input['search'] ) . '*';
135 $args['search_columns'] = [ 'user_login', 'user_email', 'display_name' ];
136 }
137
138 // Date range filter — WP_User_Query supports date_query.
139 if ( ! empty( $input['registered_after'] ) || ! empty( $input['registered_before'] ) ) {
140 $date_query = [];
141 if ( ! empty( $input['registered_after'] ) ) {
142 list( $after, , $err ) = $this->avcf_normalize_post_date( (string) $input['registered_after'] );
143 if ( $err !== null ) {
144 return [ 'total' => 0, 'returned' => 0, 'users' => [], 'message' => 'registered_after: ' . $err ];
145 }
146 $date_query['after'] = $after;
147 }
148 if ( ! empty( $input['registered_before'] ) ) {
149 list( $before, , $err ) = $this->avcf_normalize_post_date( (string) $input['registered_before'] );
150 if ( $err !== null ) {
151 return [ 'total' => 0, 'returned' => 0, 'users' => [], 'message' => 'registered_before: ' . $err ];
152 }
153 $date_query['before'] = $before;
154 }
155 $date_query['inclusive'] = true;
156 $date_query['column'] = 'user_registered';
157 $args['date_query'] = [ $date_query ];
158 }
159
160 $query = new \WP_User_Query( $args );
161 $items = [];
162 foreach ( $query->get_results() as $user ) {
163 $items[] = [
164 'id' => (int) $user->ID,
165 'login' => $user->user_login,
166 'email' => $user->user_email,
167 'display_name' => $user->display_name,
168 'first_name' => (string) get_user_meta( $user->ID, 'first_name', true ),
169 'last_name' => (string) get_user_meta( $user->ID, 'last_name', true ),
170 'roles' => array_values( (array) $user->roles ),
171 'registered' => $user->user_registered,
172 'post_count' => (int) count_user_posts( $user->ID ),
173 ];
174 }
175
176 return [
177 'total' => (int) $query->get_total(),
178 'returned' => count( $items ),
179 'users' => $items,
180 ];
181 },
182 'permission_callback' => function() {
183 return current_user_can( 'list_users' );
184 },
185 'meta' => [
186 'mcp' => [ 'public' => true, 'type' => 'tool' ],
187 'annotations' => [
188 'readonly' => true,
189 'destructive' => false,
190 'idempotent' => true,
191 ],
192 ],
193 ] );
194
195 // ---- get-user ----
196 wp_register_ability( 'atarim/get-user', [
197 'label' => 'Get User',
198 'description' => 'Returns full detail for a single user by ID, login, or email. Optional include_meta_keys reads specific user meta fields (keys starting with "_" are excluded for safety even if requested). Useful before update-user or delete-user.',
199 'category' => 'atarim',
200 'input_schema' => [
201 'type' => 'object',
202 'properties' => [
203 'id' => [
204 'type' => 'integer',
205 'description' => 'User ID. Pass id, login, or email — not multiple.',
206 'minimum' => 1,
207 ],
208 'login' => [
209 'type' => 'string',
210 'description' => 'User login. Pass id, login, or email — not multiple.',
211 'minLength' => 1,
212 ],
213 'email' => [
214 'type' => 'string',
215 'description' => 'User email. Pass id, login, or email — not multiple.',
216 'minLength' => 1,
217 ],
218 'include_meta_keys' => [
219 'type' => 'array',
220 'description' => 'List of user meta keys to read. Keys starting with "_" (private/internal) are excluded even if requested.',
221 'items' => [ 'type' => 'string' ],
222 ],
223 ],
224 'additionalProperties' => false,
225 ],
226 'output_schema' => [
227 'type' => 'object',
228 'properties' => [
229 'success' => [ 'type' => 'boolean' ],
230 'id' => [ 'type' => 'integer' ],
231 'login' => [ 'type' => 'string' ],
232 'email' => [ 'type' => 'string' ],
233 'display_name' => [ 'type' => 'string' ],
234 'first_name' => [ 'type' => 'string' ],
235 'last_name' => [ 'type' => 'string' ],
236 'nickname' => [ 'type' => 'string' ],
237 'description' => [ 'type' => 'string' ],
238 'url' => [ 'type' => 'string' ],
239 'roles' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ],
240 'registered' => [ 'type' => 'string' ],
241 'post_count' => [ 'type' => 'integer' ],
242 'meta' => [ 'type' => 'object' ],
243 'message' => [ 'type' => 'string' ],
244 ],
245 'required' => [ 'success', 'message' ],
246 ],
247 'execute_callback' => function( $input = [] ) {
248 $id = isset( $input['id'] ) ? (int) $input['id'] : 0;
249 $login = isset( $input['login'] ) ? sanitize_user( (string) $input['login'] ) : '';
250 $email = isset( $input['email'] ) ? sanitize_email( (string) $input['email'] ) : '';
251
252 $provided = (int) ( $id > 0 ) + (int) ( $login !== '' ) + (int) ( $email !== '' );
253 if ( $provided === 0 ) {
254 return [ 'success' => false, 'message' => 'Pass one of: id, login, email.' ];
255 }
256 if ( $provided > 1 ) {
257 return [ 'success' => false, 'message' => 'Pass exactly one of: id, login, email.' ];
258 }
259
260 $user = false;
261 if ( $id > 0 ) {
262 $user = get_user_by( 'id', $id );
263 } elseif ( $login !== '' ) {
264 $user = get_user_by( 'login', $login );
265 } else {
266 $user = get_user_by( 'email', $email );
267 }
268
269 if ( ! $user ) {
270 return [ 'success' => false, 'message' => 'User not found.' ];
271 }
272
273 $result = [
274 'success' => true,
275 'id' => (int) $user->ID,
276 'login' => $user->user_login,
277 'email' => $user->user_email,
278 'display_name' => $user->display_name,
279 'first_name' => (string) get_user_meta( $user->ID, 'first_name', true ),
280 'last_name' => (string) get_user_meta( $user->ID, 'last_name', true ),
281 'nickname' => (string) get_user_meta( $user->ID, 'nickname', true ),
282 'description' => (string) get_user_meta( $user->ID, 'description', true ),
283 'url' => $user->user_url,
284 'roles' => array_values( (array) $user->roles ),
285 'registered' => $user->user_registered,
286 'post_count' => (int) count_user_posts( $user->ID ),
287 'message' => 'OK.',
288 ];
289
290 if ( ! empty( $input['include_meta_keys'] ) && is_array( $input['include_meta_keys'] ) ) {
291 $meta = [];
292 foreach ( $input['include_meta_keys'] as $key ) {
293 $key = (string) $key;
294 if ( $key === '' || strpos( $key, '_' ) === 0 ) {
295 continue;
296 }
297 $meta[ $key ] = get_user_meta( $user->ID, $key, true );
298 }
299 $result['meta'] = $meta;
300 }
301
302 return $result;
303 },
304 'permission_callback' => function() {
305 return current_user_can( 'list_users' );
306 },
307 'meta' => [
308 'mcp' => [ 'public' => true, 'type' => 'tool' ],
309 'annotations' => [
310 'readonly' => true,
311 'destructive' => false,
312 'idempotent' => true,
313 ],
314 ],
315 ] );
316
317 // ---- create-user ----
318 wp_register_ability( 'atarim/create-user', [
319 'label' => 'Create User',
320 'description' => 'Creates a new user account. Required: login, email, password, role. Optional: first_name, last_name, display_name, url. WordPress validates email format and uniqueness, login uniqueness, password strength is the caller\'s responsibility. The password is never echoed back in the response.',
321 'category' => 'atarim',
322 'input_schema' => [
323 'type' => 'object',
324 'properties' => [
325 'login' => [
326 'type' => 'string',
327 'description' => 'Username. Must be unique site-wide; WordPress enforces this.',
328 'minLength' => 1,
329 ],
330 'email' => [
331 'type' => 'string',
332 'description' => 'Email address. Must be unique site-wide; WordPress enforces this.',
333 'minLength' => 3,
334 ],
335 'password' => [
336 'type' => 'string',
337 'description' => 'Password. The caller is responsible for strength. The password is hashed before storage and never echoed back.',
338 'minLength' => 1,
339 ],
340 'role' => [
341 'type' => 'string',
342 'description' => 'Initial role slug (e.g. "subscriber", "editor", "administrator"). Defaults to the site default (usually "subscriber"). Pass an explicit role to be safe.',
343 'minLength' => 1,
344 ],
345 'first_name' => [
346 'type' => 'string',
347 ],
348 'last_name' => [
349 'type' => 'string',
350 ],
351 'display_name' => [
352 'type' => 'string',
353 'description' => 'Public display name. Defaults to the login if omitted.',
354 ],
355 'url' => [
356 'type' => 'string',
357 'description' => 'User\'s personal/profile URL.',
358 ],
359 ],
360 'required' => [ 'login', 'email', 'password' ],
361 'additionalProperties' => false,
362 ],
363 'output_schema' => [
364 'type' => 'object',
365 'properties' => [
366 'success' => [ 'type' => 'boolean' ],
367 'id' => [ 'type' => 'integer' ],
368 'login' => [ 'type' => 'string' ],
369 'email' => [ 'type' => 'string' ],
370 'display_name' => [ 'type' => 'string' ],
371 'roles' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ],
372 'message' => [ 'type' => 'string' ],
373 ],
374 'required' => [ 'success', 'message' ],
375 ],
376 'execute_callback' => function( $input = [] ) {
377 $login = isset( $input['login'] ) ? sanitize_user( (string) $input['login'], true ) : '';
378 $email = isset( $input['email'] ) ? sanitize_email( (string) $input['email'] ) : '';
379 $password = isset( $input['password'] ) ? (string) $input['password'] : '';
380
381 if ( $login === '' || $email === '' || $password === '' ) {
382 return [ 'success' => false, 'message' => 'login, email, and password are required.' ];
383 }
384 if ( ! is_email( $email ) ) {
385 return [ 'success' => false, 'message' => sprintf( 'Email "%s" is not a valid email address.', $email ) ];
386 }
387 if ( username_exists( $login ) ) {
388 return [ 'success' => false, 'message' => sprintf( 'A user with login "%s" already exists.', $login ) ];
389 }
390 if ( email_exists( $email ) ) {
391 return [ 'success' => false, 'message' => sprintf( 'A user with email "%s" already exists.', $email ) ];
392 }
393
394 $role = isset( $input['role'] ) ? sanitize_key( (string) $input['role'] ) : '';
395 if ( $role !== '' && ! wp_roles()->is_role( $role ) ) {
396 return [ 'success' => false, 'message' => sprintf( 'Role "%s" does not exist on this site.', $role ) ];
397 }
398
399 $userdata = [
400 'user_login' => $login,
401 'user_email' => $email,
402 'user_pass' => $password,
403 'first_name' => isset( $input['first_name'] ) ? sanitize_text_field( (string) $input['first_name'] ) : '',
404 'last_name' => isset( $input['last_name'] ) ? sanitize_text_field( (string) $input['last_name'] ) : '',
405 'display_name' => isset( $input['display_name'] ) ? sanitize_text_field( (string) $input['display_name'] ) : $login,
406 'user_url' => isset( $input['url'] ) ? esc_url_raw( (string) $input['url'] ) : '',
407 ];
408 if ( $role !== '' ) {
409 $userdata['role'] = $role;
410 }
411
412 $user_id = wp_insert_user( $userdata );
413 if ( is_wp_error( $user_id ) ) {
414 return [ 'success' => false, 'message' => 'Create failed: ' . $user_id->get_error_message() ];
415 }
416
417 $created = get_userdata( $user_id );
418
419 return [
420 'success' => true,
421 'id' => (int) $user_id,
422 'login' => $created->user_login,
423 'email' => $created->user_email,
424 'display_name' => $created->display_name,
425 'roles' => array_values( (array) $created->roles ),
426 'message' => 'User created.',
427 ];
428 },
429 'permission_callback' => function() {
430 return current_user_can( 'create_users' );
431 },
432 'meta' => [
433 'mcp' => [ 'public' => true, 'type' => 'tool' ],
434 'annotations' => [
435 'readonly' => false,
436 'destructive' => false,
437 'idempotent' => false,
438 ],
439 ],
440 ] );
441
442 // ---- update-user ----
443 wp_register_ability( 'atarim/update-user', [
444 'label' => 'Update User',
445 'description' => 'Updates profile fields on an existing user. Only id is required; pass any subset of email, display_name, first_name, last_name, nickname, description, url, password. Omitted fields are left unchanged. To change role, use the dedicated change-user-role ability — it has tighter validation. The password, if changed, is hashed and never echoed back.',
446 'category' => 'atarim',
447 'input_schema' => [
448 'type' => 'object',
449 'properties' => [
450 'id' => [
451 'type' => 'integer',
452 'description' => 'User ID.',
453 'minimum' => 1,
454 ],
455 'email' => [
456 'type' => 'string',
457 'description' => 'New email. WordPress enforces uniqueness.',
458 'minLength' => 3,
459 ],
460 'display_name' => [
461 'type' => 'string',
462 ],
463 'first_name' => [
464 'type' => 'string',
465 ],
466 'last_name' => [
467 'type' => 'string',
468 ],
469 'nickname' => [
470 'type' => 'string',
471 ],
472 'description' => [
473 'type' => 'string',
474 'description' => 'Bio / about field.',
475 ],
476 'url' => [
477 'type' => 'string',
478 ],
479 'password' => [
480 'type' => 'string',
481 'description' => 'New password. Hashed before storage; never echoed back. Caller is responsible for strength.',
482 'minLength' => 1,
483 ],
484 ],
485 'required' => [ 'id' ],
486 'additionalProperties' => false,
487 ],
488 'output_schema' => [
489 'type' => 'object',
490 'properties' => [
491 'success' => [ 'type' => 'boolean' ],
492 'id' => [ 'type' => 'integer' ],
493 'login' => [ 'type' => 'string' ],
494 'email' => [ 'type' => 'string' ],
495 'display_name' => [ 'type' => 'string' ],
496 'updated' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ],
497 'message' => [ 'type' => 'string' ],
498 ],
499 'required' => [ 'success', 'message' ],
500 ],
501 'execute_callback' => function( $input = [] ) {
502 $id = isset( $input['id'] ) ? (int) $input['id'] : 0;
503 if ( $id <= 0 ) {
504 return [ 'success' => false, 'message' => 'id is required and must be a positive integer.' ];
505 }
506
507 $user = get_userdata( $id );
508 if ( ! $user ) {
509 return [ 'success' => false, 'message' => sprintf( 'User %d not found.', $id ) ];
510 }
511
512 $userdata = [ 'ID' => $id ];
513 $updated = [];
514
515 if ( array_key_exists( 'email', $input ) ) {
516 $email = sanitize_email( (string) $input['email'] );
517 if ( ! is_email( $email ) ) {
518 return [ 'success' => false, 'message' => sprintf( 'Email "%s" is not a valid email address.', $email ) ];
519 }
520 // Check uniqueness — only error if a DIFFERENT user already has this email.
521 $existing = email_exists( $email );
522 if ( $existing && (int) $existing !== $id ) {
523 return [ 'success' => false, 'message' => sprintf( 'Another user with email "%s" already exists.', $email ) ];
524 }
525 $userdata['user_email'] = $email;
526 $updated[] = 'email';
527 }
528 if ( array_key_exists( 'display_name', $input ) ) {
529 $userdata['display_name'] = sanitize_text_field( (string) $input['display_name'] );
530 $updated[] = 'display_name';
531 }
532 if ( array_key_exists( 'first_name', $input ) ) {
533 $userdata['first_name'] = sanitize_text_field( (string) $input['first_name'] );
534 $updated[] = 'first_name';
535 }
536 if ( array_key_exists( 'last_name', $input ) ) {
537 $userdata['last_name'] = sanitize_text_field( (string) $input['last_name'] );
538 $updated[] = 'last_name';
539 }
540 if ( array_key_exists( 'nickname', $input ) ) {
541 $userdata['nickname'] = sanitize_text_field( (string) $input['nickname'] );
542 $updated[] = 'nickname';
543 }
544 if ( array_key_exists( 'description', $input ) ) {
545 $userdata['description'] = wp_kses_post( (string) $input['description'] );
546 $updated[] = 'description';
547 }
548 if ( array_key_exists( 'url', $input ) ) {
549 $userdata['user_url'] = esc_url_raw( (string) $input['url'] );
550 $updated[] = 'url';
551 }
552 if ( array_key_exists( 'password', $input ) ) {
553 $pw = (string) $input['password'];
554 if ( $pw === '' ) {
555 return [ 'success' => false, 'message' => 'password cannot be empty.' ];
556 }
557 $userdata['user_pass'] = $pw;
558 $updated[] = 'password';
559 }
560
561 if ( count( $userdata ) === 1 ) {
562 return [ 'success' => false, 'message' => 'No fields provided to update.' ];
563 }
564
565 $result = wp_update_user( $userdata );
566 if ( is_wp_error( $result ) ) {
567 return [ 'success' => false, 'message' => 'Update failed: ' . $result->get_error_message() ];
568 }
569
570 $fresh = get_userdata( $id );
571
572 return [
573 'success' => true,
574 'id' => $id,
575 'login' => $fresh->user_login,
576 'email' => $fresh->user_email,
577 'display_name' => $fresh->display_name,
578 'updated' => $updated,
579 'message' => sprintf( 'Updated: %s.', implode( ', ', $updated ) ),
580 ];
581 },
582 'permission_callback' => function() {
583 return current_user_can( 'edit_users' );
584 },
585 'meta' => [
586 'mcp' => [ 'public' => true, 'type' => 'tool' ],
587 'annotations' => [
588 'readonly' => false,
589 'destructive' => false,
590 'idempotent' => false,
591 ],
592 ],
593 ] );
594
595 // ---- delete-user ----
596 wp_register_ability( 'atarim/delete-user', [
597 'label' => 'Delete User',
598 'description' => 'Deletes a user account. WordPress requires deciding what happens to posts owned by the deleted user. By default this ability HARD-FAILS if the user owns any posts and reassign_to is not provided — the response includes the post count so the AI can choose. Pass reassign_to: <user_id> to reassign content to another user, or reassign_to: 0 explicitly to delete posts along with the user. On multisite this removes the user from the current site only (not the network).',
599 'category' => 'atarim',
600 'input_schema' => [
601 'type' => 'object',
602 'properties' => [
603 'id' => [
604 'type' => 'integer',
605 'description' => 'User ID to delete.',
606 'minimum' => 1,
607 ],
608 'reassign_to' => [
609 'type' => 'integer',
610 'description' => 'Reassign owned posts to this user ID. Pass 0 to delete posts along with the user. Omit to hard-fail when posts exist (forces the AI to make an explicit choice).',
611 'minimum' => 0,
612 ],
613 ],
614 'required' => [ 'id' ],
615 'additionalProperties' => false,
616 ],
617 'output_schema' => [
618 'type' => 'object',
619 'properties' => [
620 'success' => [ 'type' => 'boolean' ],
621 'id' => [ 'type' => 'integer' ],
622 'login' => [ 'type' => 'string' ],
623 'post_count' => [ 'type' => 'integer' ],
624 'reassigned_to' => [ 'type' => 'integer' ],
625 'message' => [ 'type' => 'string' ],
626 ],
627 'required' => [ 'success', 'message' ],
628 ],
629 'execute_callback' => function( $input = [] ) {
630 $id = isset( $input['id'] ) ? (int) $input['id'] : 0;
631 if ( $id <= 0 ) {
632 return [ 'success' => false, 'message' => 'id is required and must be a positive integer.' ];
633 }
634
635 $user = get_userdata( $id );
636 if ( ! $user ) {
637 return [ 'success' => false, 'message' => sprintf( 'User %d not found.', $id ) ];
638 }
639
640 // Refuse to delete the current user (mapped from token) — same as wp-admin behaviour.
641 if ( $id === get_current_user_id() ) {
642 return [ 'success' => false, 'id' => $id, 'login' => $user->user_login, 'message' => 'Cannot delete the user currently authenticated for this request.' ];
643 }
644
645 // Ensure plugin functions are available — wp_delete_user lives in wp-admin/includes/user.php.
646 if ( ! function_exists( 'wp_delete_user' ) ) {
647 require_once ABSPATH . 'wp-admin/includes/user.php';
648 }
649
650 $post_count = (int) count_user_posts( $id );
651
652 $reassign_provided = array_key_exists( 'reassign_to', $input );
653 $reassign_to = $reassign_provided ? (int) $input['reassign_to'] : null;
654
655 // Hard-fail when posts exist and reassign target wasn't explicitly given.
656 if ( $post_count > 0 && ! $reassign_provided ) {
657 return [
658 'success' => false,
659 'id' => $id,
660 'login' => $user->user_login,
661 'post_count' => $post_count,
662 'message' => sprintf(
663 'User "%s" owns %d post(s). Pass reassign_to: <user_id> to reassign them, or reassign_to: 0 to delete the posts along with the user.',
664 $user->user_login,
665 $post_count
666 ),
667 ];
668 }
669
670 if ( $reassign_provided && $reassign_to > 0 ) {
671 if ( $reassign_to === $id ) {
672 return [ 'success' => false, 'message' => 'reassign_to cannot be the same as the user being deleted.' ];
673 }
674 $target = get_userdata( $reassign_to );
675 if ( ! $target ) {
676 return [ 'success' => false, 'message' => sprintf( 'reassign_to target user %d does not exist.', $reassign_to ) ];
677 }
678 }
679
680 // Effective reassign value for wp_delete_user: 0 / positive int, or null which WP treats as "delete posts".
681 $effective_reassign = ( $reassign_provided && $reassign_to > 0 ) ? $reassign_to : null;
682
683 if ( is_multisite() ) {
684 if ( ! function_exists( 'remove_user_from_blog' ) ) {
685 require_once ABSPATH . 'wp-admin/includes/ms.php';
686 }
687 $result = remove_user_from_blog( $id, get_current_blog_id(), $effective_reassign );
688 } else {
689 $result = wp_delete_user( $id, $effective_reassign );
690 }
691
692 if ( is_wp_error( $result ) ) {
693 return [ 'success' => false, 'message' => 'Delete failed: ' . $result->get_error_message() ];
694 }
695 if ( $result === false ) {
696 return [ 'success' => false, 'message' => 'Delete failed: WordPress reported the operation did not complete.' ];
697 }
698
699 return [
700 'success' => true,
701 'id' => $id,
702 'login' => $user->user_login,
703 'post_count' => $post_count,
704 'reassigned_to' => $effective_reassign === null ? 0 : (int) $effective_reassign,
705 'message' => $effective_reassign === null
706 ? sprintf( 'User "%s" deleted; %d post(s) deleted along with the user.', $user->user_login, $post_count )
707 : sprintf( 'User "%s" deleted; %d post(s) reassigned to user %d.', $user->user_login, $post_count, $effective_reassign ),
708 ];
709 },
710 'permission_callback' => function() {
711 return current_user_can( 'delete_users' );
712 },
713 'meta' => [
714 'mcp' => [ 'public' => true, 'type' => 'tool' ],
715 'annotations' => [
716 'readonly' => false,
717 'destructive' => true,
718 'idempotent' => false,
719 ],
720 ],
721 ] );
722
723 // ---- change-user-role ----
724 wp_register_ability( 'atarim/change-user-role', [
725 'label' => 'Change User Role',
726 'description' => 'Changes a user\'s role(s). Three modes: "replace" (default) — set the user\'s roles to exactly the listed role(s), removing all others; "add" — add the listed role(s) without removing existing ones; "remove" — remove the listed role(s) while keeping any others. Most sites use a single role per user — pass mode: replace with a single role for the common case. Refuses to remove the last administrator on the site, and refuses to change the role of the user currently authenticated for this request.',
727 'category' => 'atarim',
728 'input_schema' => [
729 'type' => 'object',
730 'properties' => [
731 'id' => [
732 'type' => 'integer',
733 'description' => 'User ID.',
734 'minimum' => 1,
735 ],
736 'roles' => [
737 'type' => 'array',
738 'description' => 'Role slugs to apply.',
739 'items' => [ 'type' => 'string', 'minLength' => 1 ],
740 'minItems' => 1,
741 ],
742 'mode' => [
743 'type' => 'string',
744 'description' => '"replace" (default): roles becomes exactly the listed roles, all others removed. "add": listed roles are added. "remove": listed roles are removed.',
745 'enum' => [ 'replace', 'add', 'remove' ],
746 'default' => 'replace',
747 ],
748 ],
749 'required' => [ 'id', 'roles' ],
750 'additionalProperties' => false,
751 ],
752 'output_schema' => [
753 'type' => 'object',
754 'properties' => [
755 'success' => [ 'type' => 'boolean' ],
756 'id' => [ 'type' => 'integer' ],
757 'login' => [ 'type' => 'string' ],
758 'previous_roles' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ],
759 'current_roles' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ],
760 'mode' => [ 'type' => 'string' ],
761 'message' => [ 'type' => 'string' ],
762 ],
763 'required' => [ 'success', 'message' ],
764 ],
765 'execute_callback' => function( $input = [] ) {
766 $id = isset( $input['id'] ) ? (int) $input['id'] : 0;
767 if ( $id <= 0 ) {
768 return [ 'success' => false, 'message' => 'id is required and must be a positive integer.' ];
769 }
770
771 $user = get_userdata( $id );
772 if ( ! $user ) {
773 return [ 'success' => false, 'message' => sprintf( 'User %d not found.', $id ) ];
774 }
775 if ( $id === get_current_user_id() ) {
776 return [ 'success' => false, 'message' => 'Cannot change the role of the user currently authenticated for this request.' ];
777 }
778
779 $roles_in = isset( $input['roles'] ) && is_array( $input['roles'] ) ? $input['roles'] : [];
780 $roles = array_values( array_filter( array_map( 'sanitize_key', $roles_in ) ) );
781 if ( empty( $roles ) ) {
782 return [ 'success' => false, 'message' => 'roles is required and must be a non-empty array.' ];
783 }
784
785 $wp_roles = wp_roles();
786 foreach ( $roles as $r ) {
787 if ( ! $wp_roles->is_role( $r ) ) {
788 return [ 'success' => false, 'message' => sprintf( 'Role "%s" does not exist on this site.', $r ) ];
789 }
790 }
791
792 $mode = ( isset( $input['mode'] ) && in_array( $input['mode'], [ 'replace', 'add', 'remove' ], true ) )
793 ? $input['mode']
794 : 'replace';
795
796 $previous_roles = array_values( (array) $user->roles );
797
798 // Last-administrator guard. Trigger if the operation would REMOVE administrator from this user
799 // AND there are no other administrators on the site.
800 $would_lose_admin = false;
801 if ( in_array( 'administrator', $previous_roles, true ) ) {
802 if ( $mode === 'replace' && ! in_array( 'administrator', $roles, true ) ) {
803 $would_lose_admin = true;
804 } elseif ( $mode === 'remove' && in_array( 'administrator', $roles, true ) ) {
805 $would_lose_admin = true;
806 }
807 }
808 if ( $would_lose_admin ) {
809 $other_admins = get_users( [
810 'role' => 'administrator',
811 'exclude' => [ $id ],
812 'number' => 1,
813 'fields' => 'ID',
814 ] );
815 if ( empty( $other_admins ) ) {
816 return [
817 'success' => false,
818 'message' => 'Cannot remove the administrator role from the last admin on the site.',
819 ];
820 }
821 }
822
823 // Apply the change.
824 $u = new \WP_User( $id );
825 if ( $mode === 'replace' ) {
826 // set_role only takes one role. For multi-role replace we wipe and add.
827 if ( count( $roles ) === 1 ) {
828 $u->set_role( $roles[0] );
829 } else {
830 foreach ( $previous_roles as $r ) {
831 $u->remove_role( $r );
832 }
833 foreach ( $roles as $r ) {
834 $u->add_role( $r );
835 }
836 }
837 } elseif ( $mode === 'add' ) {
838 foreach ( $roles as $r ) {
839 $u->add_role( $r );
840 }
841 } else { // remove
842 foreach ( $roles as $r ) {
843 $u->remove_role( $r );
844 }
845 }
846
847 // Reload to confirm.
848 $fresh = get_userdata( $id );
849 $current_roles = array_values( (array) $fresh->roles );
850
851 return [
852 'success' => true,
853 'id' => $id,
854 'login' => $fresh->user_login,
855 'previous_roles' => $previous_roles,
856 'current_roles' => $current_roles,
857 'mode' => $mode,
858 'message' => sprintf( 'Roles updated (mode: %s). Was: [%s]. Now: [%s].', $mode, implode( ', ', $previous_roles ), implode( ', ', $current_roles ) ),
859 ];
860 },
861 'permission_callback' => function() {
862 return current_user_can( 'promote_users' );
863 },
864 'meta' => [
865 'mcp' => [ 'public' => true, 'type' => 'tool' ],
866 'annotations' => [
867 'readonly' => false,
868 'destructive' => false,
869 'idempotent' => true,
870 ],
871 ],
872 ] );
873
874 // ---- get-2fa-status ----
875 wp_register_ability( 'atarim/get-2fa-status', [
876 'label' => 'Get 2FA Status',
877 'description' => 'Returns the two-factor authentication enrollment status for a user, detecting across the three most common 2FA plugins: Two Factor (the WP.org plugin), WP 2FA (Melapress), and Wordfence. The response includes which provider plugin is in use on the site and whether the specified user has 2FA enabled. If no supported 2FA plugin is detected, provider is "none" and enabled is null (unknown — not false).',
878 'category' => 'atarim',
879 'input_schema' => [
880 'type' => 'object',
881 'properties' => [
882 'id' => [
883 'type' => 'integer',
884 'description' => 'User ID to check.',
885 'minimum' => 1,
886 ],
887 ],
888 'required' => [ 'id' ],
889 'additionalProperties' => false,
890 ],
891 'output_schema' => [
892 'type' => 'object',
893 'properties' => [
894 'success' => [ 'type' => 'boolean' ],
895 'id' => [ 'type' => 'integer' ],
896 'provider' => [ 'type' => 'string' ],
897 'enabled' => [ 'type' => [ 'boolean', 'null' ] ],
898 'methods' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ],
899 'message' => [ 'type' => 'string' ],
900 ],
901 'required' => [ 'success', 'message' ],
902 ],
903 'execute_callback' => function( $input = [] ) {
904 $id = isset( $input['id'] ) ? (int) $input['id'] : 0;
905 if ( $id <= 0 ) {
906 return [ 'success' => false, 'provider' => 'none', 'enabled' => null, 'message' => 'id is required and must be a positive integer.' ];
907 }
908 if ( ! get_userdata( $id ) ) {
909 return [ 'success' => false, 'id' => $id, 'provider' => 'none', 'enabled' => null, 'message' => sprintf( 'User %d not found.', $id ) ];
910 }
911
912 $status = $this->avcf_detect_2fa_for_user( $id );
913
914 return [
915 'success' => true,
916 'id' => $id,
917 'provider' => $status['provider'],
918 'enabled' => $status['enabled'],
919 'methods' => $status['methods'],
920 'message' => $status['provider'] === 'none'
921 ? 'No supported 2FA plugin detected on this site.'
922 : sprintf( '%s plugin detected; user 2FA enabled: %s.',
923 $status['provider'],
924 $status['enabled'] === null ? 'unknown' : ( $status['enabled'] ? 'yes' : 'no' )
925 ),
926 ];
927 },
928 'permission_callback' => function() {
929 return current_user_can( 'list_users' );
930 },
931 'meta' => [
932 'mcp' => [ 'public' => true, 'type' => 'tool' ],
933 'annotations' => [
934 'readonly' => true,
935 'destructive' => false,
936 'idempotent' => true,
937 ],
938 ],
939 ] );
940 }
941
942 /**
943 * Detect 2FA enrollment for a user across the supported plugins.
944 *
945 * Returns an array of the form:
946 * [ 'provider' => string, 'enabled' => bool|null, 'methods' => string[] ]
947 *
948 * Providers are checked in order; the first plugin detected wins. If no
949 * supported 2FA plugin is active, returns provider: "none", enabled: null.
950 *
951 * @param int $user_id
952 * @return array
953 */
954 private function avcf_detect_2fa_for_user( $user_id ) {
955 // --- Two Factor (https://wordpress.org/plugins/two-factor/) ---
956 if ( class_exists( '\Two_Factor_Core' ) ) {
957 // Two_Factor_Core::get_enabled_providers_for_user returns array of provider keys
958 // (e.g. 'Two_Factor_Totp', 'Two_Factor_Email'). Empty array means not enrolled.
959 $providers = [];
960 if ( is_callable( [ '\Two_Factor_Core', 'get_enabled_providers_for_user' ] ) ) {
961 $providers = (array) \Two_Factor_Core::get_enabled_providers_for_user( $user_id );
962 }
963 // Normalize method names for the response.
964 $methods = array_map(
965 function( $p ) {
966 $p = str_replace( 'Two_Factor_', '', (string) $p );
967 return strtolower( $p );
968 },
969 $providers
970 );
971 return [
972 'provider' => 'two-factor',
973 'enabled' => ! empty( $providers ),
974 'methods' => array_values( $methods ),
975 ];
976 }
977
978 // --- WP 2FA (Melapress) ---
979 // Sentinel: WP2FA\WP2FA class, or wp2fa_freemius() function (loaded via Freemius).
980 if ( class_exists( '\WP2FA\WP2FA' ) || function_exists( 'wp2fa_freemius' ) ) {
981 // WP 2FA stores enabled methods in user meta keyed by method.
982 // The strongest signal: wp_2fa_totp_key set (TOTP enrolled),
983 // or wp_2fa_email_token_key set (email 2FA enrolled),
984 // or wp_2fa_backup_methods_enabled non-empty.
985 $methods = [];
986 if ( get_user_meta( $user_id, 'wp_2fa_totp_key', true ) ) {
987 $methods[] = 'totp';
988 }
989 if ( get_user_meta( $user_id, 'wp_2fa_email_token_key', true ) ) {
990 $methods[] = 'email';
991 }
992 $backup = get_user_meta( $user_id, 'wp_2fa_backup_methods_enabled', true );
993 if ( ! empty( $backup ) ) {
994 $methods[] = 'backup_codes';
995 }
996 // Fallback: WP 2FA also sets wp_2fa_user_setup_complete on enrollment.
997 $setup_complete = get_user_meta( $user_id, 'wp_2fa_user_setup_complete', true );
998 $enabled = ! empty( $methods ) || ! empty( $setup_complete );
999
1000 return [
1001 'provider' => 'wp-2fa',
1002 'enabled' => $enabled,
1003 'methods' => $methods,
1004 ];
1005 }
1006
1007 // --- Wordfence ---
1008 // Sentinel: WORDFENCE_VERSION constant or wordfence class.
1009 if ( defined( 'WORDFENCE_VERSION' ) || class_exists( '\wordfence' ) ) {
1010 global $wpdb;
1011 $methods = [];
1012 $enabled = null; // we may not be able to read Wordfence's table; default to unknown.
1013 if ( $wpdb ) {
1014 $table = $wpdb->base_prefix . 'wfconfig';
1015 // Wordfence Premium stores 2FA secrets in wfTwoFactor for Premium / wfconfig key for free.
1016 // The cleanest signal we can rely on without poking premium-only internals: a user-meta key.
1017 $wf_meta = get_user_meta( $user_id, '_wf_twoFactorEnabled', true );
1018 if ( $wf_meta ) {
1019 $enabled = true;
1020 $methods[] = 'wordfence';
1021 }
1022 // Fallback: check wfTwoFactor secrets table when present.
1023 if ( $enabled === null ) {
1024 $secrets_table = $wpdb->base_prefix . 'wfTwoFactor';
1025 $exists = $wpdb->get_var( $wpdb->prepare( "SHOW TABLES LIKE %s", $secrets_table ) );
1026 if ( $exists === $secrets_table ) {
1027 $row = $wpdb->get_var( $wpdb->prepare(
1028 "SELECT COUNT(*) FROM `{$secrets_table}` WHERE userID = %d",
1029 $user_id
1030 ) );
1031 if ( $row !== null ) {
1032 $enabled = ( (int) $row > 0 );
1033 if ( $enabled ) {
1034 $methods[] = 'wordfence';
1035 }
1036 }
1037 }
1038 }
1039 if ( $enabled === null ) {
1040 $enabled = false; // Wordfence is active but we found no enrollment signal.
1041 }
1042 }
1043 return [
1044 'provider' => 'wordfence',
1045 'enabled' => $enabled,
1046 'methods' => $methods,
1047 ];
1048 }
1049
1050 return [
1051 'provider' => 'none',
1052 'enabled' => null,
1053 'methods' => [],
1054 ];
1055 }
1056 }
1057