PluginProbe ʕ •ᴥ•ʔ
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback / trunk
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback vtrunk
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-plugins.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 1 week ago class-avcf-abilities-core.php 3 days ago class-avcf-abilities-execute-php.php 2 weeks ago class-avcf-abilities-global-styles.php 3 weeks ago class-avcf-abilities-gutenberg.php 2 weeks ago class-avcf-abilities-media.php 1 week ago class-avcf-abilities-metadata.php 1 week ago class-avcf-abilities-navigation.php 3 weeks ago class-avcf-abilities-patterns.php 3 weeks ago class-avcf-abilities-plugins.php 3 days ago class-avcf-abilities-readonly.php 2 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 2 weeks ago class-avcf-abilities-themes.php 3 days ago class-avcf-abilities-users.php 3 weeks ago class-avcf-abilities-wp-cli.php 3 days ago
class-avcf-abilities-plugins.php
836 lines
1 <?php
2 /**
3 * Plugin management MCP abilities.
4 *
5 * Registers Atarim/* abilities for installing, activating, updating, and
6 * removing WordPress plugins via the AI action layer. Works with both free
7 * WordPress.org plugins (via the WP_Repo) and paid third-party plugins that
8 * register updates through their own update servers.
9 *
10 * Exposed abilities:
11 * atarim/list-plugins All installed plugins + update info.
12 * atarim/install-plugin Install a free plugin from WordPress.org.
13 * atarim/activate-plugin Activate an installed plugin.
14 * atarim/update-plugin Update an installed plugin to latest.
15 * atarim/deactivate-plugin Deactivate an active plugin.
16 * atarim/delete-plugin Permanently remove a plugin from disk.
17 *
18 * Note: ability names registered here must also be added to the $tools array
19 * in doit/class-avcf-mcp.php::avcf_mcp_setup_server() to be exposed by the
20 * MCP server.
21 *
22 * @package atarim-visual-collaboration
23 */
24
25 if ( ! defined('ABSPATH') ) {
26 exit;
27 }
28
29 class AVCF_Abilities_Plugins extends AVCF_Abilities_Base {
30
31 /**
32 * Register all plugin management abilities.
33 * Called from AVCF_MCP::avcf_mcp_register_abilities() on wp_abilities_api_init.
34 */
35 public function register() {
36 // Ensure plugin functions are available in non-admin contexts (MCP requests).
37 if ( ! function_exists( 'get_plugins' ) ) {
38 require_once ABSPATH . 'wp-admin/includes/plugin.php';
39 }
40
41 // ---- list-plugins ----
42 wp_register_ability( 'atarim/list-plugins', [
43 'label' => 'List Plugins',
44 'description' => 'Returns all installed WordPress plugins with their status, version, author, and update availability.',
45 'category' => 'atarim',
46 'input_schema' => [
47 'type' => 'object',
48 'properties' => [
49 'status' => [
50 'type' => 'string',
51 'description' => 'Filter by activation status. Omit for all.',
52 'enum' => [ 'active', 'inactive', 'all' ],
53 'default' => 'all',
54 ],
55 ],
56 'additionalProperties' => false,
57 ],
58 'output_schema' => [
59 'type' => 'object',
60 'properties' => [
61 'total' => [ 'type' => 'integer' ],
62 'plugins' => [
63 'type' => 'array',
64 'items' => [
65 'type' => 'object',
66 'properties' => [
67 'slug' => [ 'type' => 'string' ],
68 'plugin_file' => [ 'type' => 'string' ],
69 'name' => [ 'type' => 'string' ],
70 'version' => [ 'type' => 'string' ],
71 'author' => [ 'type' => 'string' ],
72 'description' => [ 'type' => 'string' ],
73 'status' => [ 'type' => 'string' ],
74 'network_active' => [ 'type' => 'boolean' ],
75 'requires_wp' => [ 'type' => 'string' ],
76 'requires_php' => [ 'type' => 'string' ],
77 'update_available' => [ 'type' => 'boolean' ],
78 'new_version' => [ 'type' => 'string' ],
79 ],
80 ],
81 ],
82 ],
83 'required' => [ 'total', 'plugins' ],
84 ],
85 'execute_callback' => function( $input = [] ) {
86 if ( ! function_exists( 'get_plugins' ) ) {
87 require_once ABSPATH . 'wp-admin/includes/plugin.php';
88 }
89
90 $status_filter = isset( $input['status'] ) ? $input['status'] : 'all';
91 $all_plugins = get_plugins();
92
93 // A cold or emptied transient truthfully reports "no update available"
94 // for every plugin, and an update run clears it via Plugin_Upgrader,
95 // so the listing has to refresh before it reads.
96 wp_update_plugins();
97
98 $updates = get_site_transient( 'update_plugins' );
99 $update_list = ( $updates && ! empty( $updates->response ) ) ? $updates->response : [];
100
101 $plugins = [];
102 foreach ( $all_plugins as $plugin_file => $data ) {
103 $is_active = is_plugin_active( $plugin_file );
104 $is_network_active = is_multisite() && is_plugin_active_for_network( $plugin_file );
105 $current_status = $is_active ? 'active' : 'inactive';
106
107 if ( $status_filter !== 'all' && $status_filter !== $current_status ) {
108 continue;
109 }
110
111 $slug = dirname( $plugin_file );
112 if ( $slug === '.' ) {
113 // Single-file plugin
114 $slug = basename( $plugin_file, '.php' );
115 }
116
117 $has_update = isset( $update_list[ $plugin_file ] );
118 $new_version = $has_update ? $update_list[ $plugin_file ]->new_version : '';
119
120 $plugins[] = [
121 'slug' => $slug,
122 'plugin_file' => $plugin_file,
123 'name' => isset( $data['Name'] ) ? $data['Name'] : '',
124 'version' => isset( $data['Version'] ) ? $data['Version'] : '',
125 'author' => isset( $data['Author'] ) ? wp_strip_all_tags( $data['Author'] ) : '',
126 'description' => isset( $data['Description'] ) ? wp_strip_all_tags( $data['Description'] ) : '',
127 'status' => $current_status,
128 'network_active' => $is_network_active,
129 'requires_wp' => isset( $data['RequiresWP'] ) ? (string) $data['RequiresWP'] : '',
130 'requires_php' => isset( $data['RequiresPHP'] ) ? (string) $data['RequiresPHP'] : '',
131 'update_available' => $has_update,
132 'new_version' => $new_version,
133 ];
134 }
135
136 return [
137 'total' => count( $plugins ),
138 'plugins' => $plugins,
139 ];
140 },
141 'permission_callback' => function() {
142 return current_user_can( 'activate_plugins' );
143 },
144 'meta' => [
145 'mcp' => [ 'public' => true, 'type' => 'tool' ],
146 'annotations' => [
147 'readonly' => true,
148 'destructive' => false,
149 'idempotent' => true,
150 ],
151 ],
152 ] );
153
154 // ---- install-plugin (free plugins only, from wordpress.org) ----
155 wp_register_ability( 'atarim/install-plugin', [
156 'label' => 'Install Plugin',
157 'description' => 'Installs a free plugin from the WordPress.org repository by its slug. Does not activate it.',
158 'category' => 'atarim',
159 'input_schema' => [
160 'type' => 'object',
161 'properties' => [
162 'slug' => [
163 'type' => 'string',
164 'description' => 'The WordPress.org plugin slug (e.g. "contact-form-7"). Must exist in the free repository.',
165 'minLength' => 1,
166 ],
167 ],
168 'required' => [ 'slug' ],
169 'additionalProperties' => false,
170 ],
171 'output_schema' => [
172 'type' => 'object',
173 'properties' => [
174 'success' => [ 'type' => 'boolean' ],
175 'slug' => [ 'type' => 'string' ],
176 'plugin_file' => [ 'type' => 'string' ],
177 'message' => [ 'type' => 'string' ],
178 ],
179 'required' => [ 'success', 'slug', 'message' ],
180 ],
181 'execute_callback' => function( $input = [] ) {
182 $slug = isset( $input['slug'] ) ? sanitize_key( $input['slug'] ) : '';
183 if ( empty( $slug ) ) {
184 return [
185 'success' => false,
186 'slug' => '',
187 'plugin_file' => '',
188 'message' => 'Plugin slug is required.',
189 ];
190 }
191
192 require_once ABSPATH . 'wp-admin/includes/file.php';
193 require_once ABSPATH . 'wp-admin/includes/misc.php';
194 require_once ABSPATH . 'wp-admin/includes/plugin.php';
195 require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
196 require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
197
198 // Query wordpress.org for the plugin — confirms it's a free repo plugin
199 // and gets the verified download_link (signed by w.org).
200 $api = plugins_api( 'plugin_information', [
201 'slug' => $slug,
202 'fields' => [ 'sections' => false ],
203 ] );
204
205 if ( is_wp_error( $api ) ) {
206 return [
207 'success' => false,
208 'slug' => $slug,
209 'plugin_file' => '',
210 'message' => 'Plugin not found in WordPress.org repository: ' . $api->get_error_message(),
211 ];
212 }
213
214 if ( empty( $api->download_link ) ) {
215 return [
216 'success' => false,
217 'slug' => $slug,
218 'plugin_file' => '',
219 'message' => 'No download link available — only free WordPress.org plugins are supported.',
220 ];
221 }
222
223 // Silent upgrader skin — no HTML output during MCP request.
224 $skin = new \WP_Ajax_Upgrader_Skin();
225 $upgrader = new \Plugin_Upgrader( $skin );
226 $result = $upgrader->install( $api->download_link );
227
228 if ( is_wp_error( $result ) ) {
229 return [
230 'success' => false,
231 'slug' => $slug,
232 'plugin_file' => '',
233 'message' => 'Install failed: ' . $result->get_error_message(),
234 ];
235 }
236
237 if ( $result === false ) {
238 $skin_errors = $skin->get_errors();
239 $err_msg = is_wp_error( $skin_errors ) && $skin_errors->has_errors()
240 ? $skin_errors->get_error_message()
241 : 'Unknown installer error (filesystem permissions or unavailable updates).';
242 return [
243 'success' => false,
244 'slug' => $slug,
245 'plugin_file' => '',
246 'message' => 'Install failed: ' . $err_msg,
247 ];
248 }
249
250 $plugin_file = $upgrader->plugin_info();
251
252 return [
253 'success' => true,
254 'slug' => $slug,
255 'plugin_file' => $plugin_file ? $plugin_file : '',
256 'message' => 'Plugin installed successfully. Use activate_plugins capability separately to enable it.',
257 ];
258 },
259 'permission_callback' => function() {
260 return current_user_can( 'install_plugins' );
261 },
262 'meta' => [
263 'mcp' => [ 'public' => true, 'type' => 'tool' ],
264 'annotations' => [
265 'readonly' => false,
266 'destructive' => false,
267 'idempotent' => false,
268 ],
269 ],
270 ] );
271
272 // ---- activate-plugin ----
273 wp_register_ability( 'atarim/activate-plugin', [
274 'label' => 'Activate Plugin',
275 'description' => 'Activates an installed (but inactive) plugin by its plugin file path. Use list-plugins to discover the plugin_file, or use the value returned by install-plugin.',
276 'category' => 'atarim',
277 'input_schema' => [
278 'type' => 'object',
279 'properties' => [
280 'plugin_file' => [
281 'type' => 'string',
282 'description' => 'Plugin file path relative to the plugins directory (e.g. "akismet/akismet.php").',
283 'minLength' => 1,
284 ],
285 'network_wide' => [
286 'type' => 'boolean',
287 'description' => 'On multisite, activate network-wide instead of for the current site. Ignored on single-site installs.',
288 'default' => false,
289 ],
290 ],
291 'required' => [ 'plugin_file' ],
292 'additionalProperties' => false,
293 ],
294 'output_schema' => [
295 'type' => 'object',
296 'properties' => [
297 'success' => [ 'type' => 'boolean' ],
298 'plugin_file' => [ 'type' => 'string' ],
299 'network_active' => [ 'type' => 'boolean' ],
300 'message' => [ 'type' => 'string' ],
301 ],
302 'required' => [ 'success', 'plugin_file', 'message' ],
303 ],
304 'execute_callback' => function( $input = [] ) {
305 if ( ! function_exists( 'activate_plugin' ) ) {
306 require_once ABSPATH . 'wp-admin/includes/plugin.php';
307 }
308
309 $plugin_file = isset( $input['plugin_file'] ) ? $input['plugin_file'] : '';
310 $plugin_file = ltrim( str_replace( [ '..', '\\' ], '', $plugin_file ), '/' );
311
312 if ( empty( $plugin_file ) ) {
313 return [
314 'success' => false,
315 'plugin_file' => '',
316 'network_active' => false,
317 'message' => 'plugin_file is required.',
318 ];
319 }
320
321 $network_wide = ! empty( $input['network_wide'] ) && is_multisite();
322
323 $all_plugins = get_plugins();
324 if ( ! isset( $all_plugins[ $plugin_file ] ) ) {
325 return [
326 'success' => false,
327 'plugin_file' => $plugin_file,
328 'network_active' => false,
329 'message' => 'Plugin not installed.',
330 ];
331 }
332
333 // Hard-fail compatibility checks — mirrors the activate-theme behaviour.
334 // WordPress core also performs these checks in newer versions, but doing
335 // them here means we return a useful structured error to the AI caller
336 // regardless of the WP version on the host site.
337 $plugin_data = $all_plugins[ $plugin_file ];
338 $requires_wp = isset( $plugin_data['RequiresWP'] ) ? (string) $plugin_data['RequiresWP'] : '';
339 $requires_php = isset( $plugin_data['RequiresPHP'] ) ? (string) $plugin_data['RequiresPHP'] : '';
340
341 if ( $requires_wp !== '' ) {
342 global $wp_version;
343 if ( version_compare( $wp_version, $requires_wp, '<' ) ) {
344 return [
345 'success' => false,
346 'plugin_file' => $plugin_file,
347 'network_active' => false,
348 'message' => sprintf(
349 'Plugin requires WordPress %s; this site runs %s. Update WordPress before activating.',
350 $requires_wp,
351 $wp_version
352 ),
353 ];
354 }
355 }
356
357 if ( $requires_php !== '' ) {
358 if ( version_compare( PHP_VERSION, $requires_php, '<' ) ) {
359 return [
360 'success' => false,
361 'plugin_file' => $plugin_file,
362 'network_active' => false,
363 'message' => sprintf(
364 'Plugin requires PHP %s; this site runs %s. Upgrade PHP before activating.',
365 $requires_php,
366 PHP_VERSION
367 ),
368 ];
369 }
370 }
371
372 if ( is_plugin_active( $plugin_file ) && ! $network_wide ) {
373 return [
374 'success' => false,
375 'plugin_file' => $plugin_file,
376 'network_active' => is_multisite() && is_plugin_active_for_network( $plugin_file ),
377 'message' => 'Plugin is already active.',
378 ];
379 }
380
381 if ( $network_wide && is_plugin_active_for_network( $plugin_file ) ) {
382 return [
383 'success' => false,
384 'plugin_file' => $plugin_file,
385 'network_active' => true,
386 'message' => 'Plugin is already network-active.',
387 ];
388 }
389
390 // activate_plugin() runs the plugin's activation hook and may produce
391 // output if the plugin is buggy. Suppress to keep the MCP response clean.
392 // Returns null on success, WP_Error on failure, or a WP_Error if the
393 // plugin triggered a fatal error during activation.
394 $silent = false;
395 $result = activate_plugin( $plugin_file, '', $network_wide, $silent );
396
397 if ( is_wp_error( $result ) ) {
398 return [
399 'success' => false,
400 'plugin_file' => $plugin_file,
401 'network_active' => false,
402 'message' => 'Activation failed: ' . $result->get_error_message(),
403 ];
404 }
405
406 // Re-check; activate_plugin returns null on success but doesn't guarantee state.
407 $now_active = is_plugin_active( $plugin_file );
408 $now_network_active = is_multisite() && is_plugin_active_for_network( $plugin_file );
409
410 return [
411 'success' => $now_active,
412 'plugin_file' => $plugin_file,
413 'network_active' => $now_network_active,
414 'message' => $now_active ? 'Plugin activated.' : 'Activation completed but plugin is not active — check for activation errors.',
415 ];
416 },
417 'permission_callback' => function() {
418 return current_user_can( 'activate_plugins' );
419 },
420 'meta' => [
421 'mcp' => [ 'public' => true, 'type' => 'tool' ],
422 'annotations' => [
423 'readonly' => false,
424 'destructive' => false,
425 'idempotent' => true,
426 ],
427 ],
428 ] );
429
430 // ---- update-plugin ----
431 wp_register_ability( 'atarim/update-plugin', [
432 'label' => 'Update Plugin',
433 'description' => 'Updates an installed plugin to the latest available version. Works with both free WordPress.org plugins and paid/third-party plugins that report updates through their own update server. Fails if no update is available.',
434 'category' => 'atarim',
435 'input_schema' => [
436 'type' => 'object',
437 'properties' => [
438 'plugin_file' => [
439 'type' => 'string',
440 'description' => 'Plugin file path relative to the plugins directory (e.g. "akismet/akismet.php"). Use list-plugins to discover this value.',
441 'minLength' => 1,
442 ],
443 ],
444 'required' => [ 'plugin_file' ],
445 'additionalProperties' => false,
446 ],
447 'output_schema' => [
448 'type' => 'object',
449 'properties' => [
450 'success' => [ 'type' => 'boolean' ],
451 'plugin_file' => [ 'type' => 'string' ],
452 'previous_version' => [ 'type' => 'string' ],
453 'new_version' => [ 'type' => 'string' ],
454 'was_active' => [ 'type' => 'boolean', 'description' => 'Whether the plugin was active before the update.' ],
455 'is_active' => [ 'type' => 'boolean', 'description' => 'Whether the plugin is active after the update. If was_active is true and this is false, the plugin was left switched off.' ],
456 'message' => [ 'type' => 'string' ],
457 ],
458 'required' => [ 'success', 'plugin_file', 'message' ],
459 ],
460 'execute_callback' => function( $input = [] ) {
461 if ( ! function_exists( 'get_plugins' ) ) {
462 require_once ABSPATH . 'wp-admin/includes/plugin.php';
463 }
464 require_once ABSPATH . 'wp-admin/includes/file.php';
465 require_once ABSPATH . 'wp-admin/includes/misc.php';
466 require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
467
468 $plugin_file = isset( $input['plugin_file'] ) ? $input['plugin_file'] : '';
469 $plugin_file = ltrim( str_replace( [ '..', '\\' ], '', $plugin_file ), '/' );
470
471 if ( empty( $plugin_file ) ) {
472 return [
473 'success' => false,
474 'plugin_file' => '',
475 'previous_version' => '',
476 'new_version' => '',
477 'message' => 'plugin_file is required.',
478 ];
479 }
480
481 $all_plugins = get_plugins();
482 if ( ! isset( $all_plugins[ $plugin_file ] ) ) {
483 return [
484 'success' => false,
485 'plugin_file' => $plugin_file,
486 'previous_version' => '',
487 'new_version' => '',
488 'message' => 'Plugin not installed.',
489 ];
490 }
491
492 $current_version = isset( $all_plugins[ $plugin_file ]['Version'] ) ? $all_plugins[ $plugin_file ]['Version'] : '';
493
494 // WordPress can leave a plugin deactivated after an upgrade. Record the
495 // state up front so it can be restored below, rather than reporting
496 // success while the site quietly loses the plugin.
497 $was_active = is_plugin_active( $plugin_file );
498 $was_network_active = is_multisite() && is_plugin_active_for_network( $plugin_file );
499
500 // Force a fresh update check so we don't act on stale transient data.
501 // wp_update_plugins() makes a remote call to api.wordpress.org for free plugins
502 // and triggers third-party update-checker hooks for paid plugins.
503 wp_update_plugins();
504
505 $updates = get_site_transient( 'update_plugins' );
506 $update_list = ( $updates && ! empty( $updates->response ) ) ? $updates->response : [];
507
508 if ( ! isset( $update_list[ $plugin_file ] ) ) {
509 return [
510 'success' => false,
511 'plugin_file' => $plugin_file,
512 'previous_version' => $current_version,
513 'new_version' => '',
514 'message' => 'No update available for this plugin.',
515 ];
516 }
517
518 $new_version = isset( $update_list[ $plugin_file ]->new_version ) ? $update_list[ $plugin_file ]->new_version : '';
519
520 // Filesystem credentials check — same pattern as delete-plugin.
521 ob_start();
522 $creds_ok = WP_Filesystem();
523 ob_end_clean();
524
525 if ( ! $creds_ok ) {
526 return [
527 'success' => false,
528 'plugin_file' => $plugin_file,
529 'previous_version' => $current_version,
530 'new_version' => $new_version,
531 'message' => 'Could not initialize filesystem — server may require FTP credentials.',
532 ];
533 }
534
535 // Silent upgrader skin — no HTML output during MCP request.
536 $skin = new \WP_Ajax_Upgrader_Skin();
537 $upgrader = new \Plugin_Upgrader( $skin );
538
539 // Use bulk_upgrade() (with a single-item list) rather than the
540 // single-plugin upgrade(). In bulk mode WordPress upgrades under
541 // maintenance mode and SKIPS deactivate_plugin_before_upgrade, so an
542 // active plugin is NOT deactivated during the swap — matching WP's
543 // own Updates screen / auto-updater. This avoids the "updated but
544 // switched off" problem at the source instead of undoing it after.
545 // Works identically for free and paid plugins as long as the update
546 // is registered in the update_plugins transient.
547 $results = $upgrader->bulk_upgrade( [ $plugin_file ] );
548 // bulk_upgrade() returns a map keyed by plugin file (per-plugin
549 // result); false/WP_Error/empty for that key means the upgrade did
550 // not succeed.
551 $result = ( is_array( $results ) && array_key_exists( $plugin_file, $results ) ) ? $results[ $plugin_file ] : false;
552
553 // Belt-and-suspenders: bulk mode should not deactivate, but if the
554 // plugin was active and somehow came back off after a successful
555 // upgrade, restore its pre-upgrade activation state before reporting.
556 $reactivation_failed = false;
557 if ( $was_active && ! is_wp_error( $result ) && ! empty( $result ) && ! is_plugin_active( $plugin_file ) ) {
558 $activation = activate_plugin( $plugin_file, '', $was_network_active, true );
559 $reactivation_failed = is_wp_error( $activation );
560 }
561
562 if ( is_wp_error( $result ) ) {
563 return [
564 'success' => false,
565 'plugin_file' => $plugin_file,
566 'previous_version' => $current_version,
567 'new_version' => $new_version,
568 'message' => 'Update failed: ' . $result->get_error_message(),
569 ];
570 }
571
572 if ( empty( $result ) ) {
573 $skin_errors = $skin->get_errors();
574 $err_msg = is_wp_error( $skin_errors ) && $skin_errors->has_errors()
575 ? $skin_errors->get_error_message()
576 : 'Unknown upgrader error.';
577 return [
578 'success' => false,
579 'plugin_file' => $plugin_file,
580 'previous_version' => $current_version,
581 'new_version' => $new_version,
582 'message' => 'Update failed: ' . $err_msg,
583 ];
584 }
585
586 // Re-read plugin headers to confirm the actual installed version.
587 $all_plugins_after = get_plugins();
588 $installed_version = isset( $all_plugins_after[ $plugin_file ]['Version'] )
589 ? $all_plugins_after[ $plugin_file ]['Version']
590 : $new_version;
591
592 $is_active_after = is_plugin_active( $plugin_file );
593
594 return [
595 'success' => true,
596 'plugin_file' => $plugin_file,
597 'previous_version' => $current_version,
598 'new_version' => $installed_version,
599 'was_active' => $was_active,
600 'is_active' => $is_active_after,
601 'message' => $was_active && ! $is_active_after
602 ? sprintf(
603 'Plugin updated from %s to %s, but it was left DEACTIVATED and could not be reactivated automatically%s. Reactivate it before relying on the site.',
604 $current_version,
605 $installed_version,
606 $reactivation_failed ? '' : ' (state unexpectedly changed)'
607 )
608 : sprintf( 'Plugin updated from %s to %s.', $current_version, $installed_version ),
609 ];
610 },
611 'permission_callback' => function() {
612 return current_user_can( 'update_plugins' );
613 },
614 'meta' => [
615 'mcp' => [ 'public' => true, 'type' => 'tool' ],
616 'annotations' => [
617 'readonly' => false,
618 'destructive' => true,
619 'idempotent' => false,
620 ],
621 ],
622 ] );
623
624 // ---- deactivate-plugin ----
625 wp_register_ability( 'atarim/deactivate-plugin', [
626 'label' => 'Deactivate Plugin',
627 'description' => 'Deactivates an installed plugin by its plugin file path (e.g. "akismet/akismet.php").',
628 'category' => 'atarim',
629 'input_schema' => [
630 'type' => 'object',
631 'properties' => [
632 'plugin_file' => [
633 'type' => 'string',
634 'description' => 'Plugin file path relative to the plugins directory (e.g. "akismet/akismet.php"). Use list-plugins to discover this value.',
635 'minLength' => 1,
636 ],
637 ],
638 'required' => [ 'plugin_file' ],
639 'additionalProperties' => false,
640 ],
641 'output_schema' => [
642 'type' => 'object',
643 'properties' => [
644 'success' => [ 'type' => 'boolean' ],
645 'plugin_file' => [ 'type' => 'string' ],
646 'message' => [ 'type' => 'string' ],
647 ],
648 'required' => [ 'success', 'plugin_file', 'message' ],
649 ],
650 'execute_callback' => function( $input = [] ) {
651 if ( ! function_exists( 'deactivate_plugins' ) ) {
652 require_once ABSPATH . 'wp-admin/includes/plugin.php';
653 }
654
655 $plugin_file = isset( $input['plugin_file'] ) ? $input['plugin_file'] : '';
656 // Light path normalization without losing the forward slash.
657 $plugin_file = ltrim( str_replace( [ '..', '\\' ], '', $plugin_file ), '/' );
658
659 if ( empty( $plugin_file ) ) {
660 return [
661 'success' => false,
662 'plugin_file' => '',
663 'message' => 'plugin_file is required.',
664 ];
665 }
666
667 $all_plugins = get_plugins();
668 if ( ! isset( $all_plugins[ $plugin_file ] ) ) {
669 return [
670 'success' => false,
671 'plugin_file' => $plugin_file,
672 'message' => 'Plugin not installed.',
673 ];
674 }
675
676 if ( ! is_plugin_active( $plugin_file ) ) {
677 return [
678 'success' => false,
679 'plugin_file' => $plugin_file,
680 'message' => 'Plugin is already inactive.',
681 ];
682 }
683
684 // Guard against self-deactivation — would break the very request handling this call.
685 if ( $plugin_file === AVCF_PLUGIN_BASE ) {
686 return [
687 'success' => false,
688 'plugin_file' => $plugin_file,
689 'message' => 'Cannot deactivate the Atarim plugin via MCP.',
690 ];
691 }
692
693 deactivate_plugins( $plugin_file );
694
695 // deactivate_plugins() returns void; re-check.
696 $still_active = is_plugin_active( $plugin_file );
697
698 return [
699 'success' => ! $still_active,
700 'plugin_file' => $plugin_file,
701 'message' => $still_active ? 'Deactivation failed.' : 'Plugin deactivated.',
702 ];
703 },
704 'permission_callback' => function() {
705 return current_user_can( 'deactivate_plugins' );
706 },
707 'meta' => [
708 'mcp' => [ 'public' => true, 'type' => 'tool' ],
709 'annotations' => [
710 'readonly' => false,
711 'destructive' => true,
712 'idempotent' => true,
713 ],
714 ],
715 ] );
716
717 // ---- delete-plugin ----
718 wp_register_ability( 'atarim/delete-plugin', [
719 'label' => 'Delete Plugin',
720 'description' => 'Permanently deletes an installed plugin from disk. Plugin must be deactivated first.',
721 'category' => 'atarim',
722 'input_schema' => [
723 'type' => 'object',
724 'properties' => [
725 'plugin_file' => [
726 'type' => 'string',
727 'description' => 'Plugin file path relative to the plugins directory (e.g. "akismet/akismet.php").',
728 'minLength' => 1,
729 ],
730 ],
731 'required' => [ 'plugin_file' ],
732 'additionalProperties' => false,
733 ],
734 'output_schema' => [
735 'type' => 'object',
736 'properties' => [
737 'success' => [ 'type' => 'boolean' ],
738 'plugin_file' => [ 'type' => 'string' ],
739 'message' => [ 'type' => 'string' ],
740 ],
741 'required' => [ 'success', 'plugin_file', 'message' ],
742 ],
743 'execute_callback' => function( $input = [] ) {
744 if ( ! function_exists( 'delete_plugins' ) ) {
745 require_once ABSPATH . 'wp-admin/includes/plugin.php';
746 }
747 require_once ABSPATH . 'wp-admin/includes/file.php';
748
749 $plugin_file = isset( $input['plugin_file'] ) ? $input['plugin_file'] : '';
750 $plugin_file = ltrim( str_replace( [ '..', '\\' ], '', $plugin_file ), '/' );
751
752 if ( empty( $plugin_file ) ) {
753 return [
754 'success' => false,
755 'plugin_file' => '',
756 'message' => 'plugin_file is required.',
757 ];
758 }
759
760 if ( $plugin_file === AVCF_PLUGIN_BASE ) {
761 return [
762 'success' => false,
763 'plugin_file' => $plugin_file,
764 'message' => 'Cannot delete the Atarim plugin via MCP.',
765 ];
766 }
767
768 $all_plugins = get_plugins();
769 if ( ! isset( $all_plugins[ $plugin_file ] ) ) {
770 return [
771 'success' => false,
772 'plugin_file' => $plugin_file,
773 'message' => 'Plugin not installed.',
774 ];
775 }
776
777 if ( is_plugin_active( $plugin_file ) ) {
778 return [
779 'success' => false,
780 'plugin_file' => $plugin_file,
781 'message' => 'Plugin is currently active. Deactivate it before deleting.',
782 ];
783 }
784
785 // delete_plugins() needs filesystem credentials; request them silently.
786 // On direct/ssh/ftpext methods with stored creds this works; otherwise it fails cleanly.
787 ob_start();
788 $creds_ok = WP_Filesystem();
789 ob_end_clean();
790
791 if ( ! $creds_ok ) {
792 return [
793 'success' => false,
794 'plugin_file' => $plugin_file,
795 'message' => 'Could not initialize filesystem — server may require FTP credentials.',
796 ];
797 }
798
799 $result = delete_plugins( [ $plugin_file ] );
800
801 if ( is_wp_error( $result ) ) {
802 return [
803 'success' => false,
804 'plugin_file' => $plugin_file,
805 'message' => 'Delete failed: ' . $result->get_error_message(),
806 ];
807 }
808
809 if ( $result === false || $result === null ) {
810 return [
811 'success' => false,
812 'plugin_file' => $plugin_file,
813 'message' => 'Delete failed (filesystem error).',
814 ];
815 }
816
817 return [
818 'success' => true,
819 'plugin_file' => $plugin_file,
820 'message' => 'Plugin deleted.',
821 ];
822 },
823 'permission_callback' => function() {
824 return current_user_can( 'delete_plugins' );
825 },
826 'meta' => [
827 'mcp' => [ 'public' => true, 'type' => 'tool' ],
828 'annotations' => [
829 'readonly' => false,
830 'destructive' => true,
831 'idempotent' => false,
832 ],
833 ],
834 ] );
835 }
836 }