> */ private $groups = array(); /** @var array> */ private $tools = array(); /** @var Settings */ private $settings; /** @var RateLimiter */ private $limiter; /** @var Log */ private $log; /** @var bool */ private $booted = false; /** * Constructor. * * @param Settings $settings Settings. * @param RateLimiter $limiter Limiter. * @param Log $log Activity log. */ public function __construct( Settings $settings, RateLimiter $limiter, Log $log ) { $this->settings = $settings; $this->limiter = $limiter; $this->log = $log; } /** * Integration classes. Each has a static register( Registry ) method. * * @return string[] */ public static function integrations(): array { $classes = array( \HandyMCP\Integrations\WordPress::class, \HandyMCP\Integrations\Site::class, \HandyMCP\Integrations\Images::class, \HandyMCP\Integrations\Gutenberg::class, \HandyMCP\Integrations\Elementor::class, \HandyMCP\Integrations\Forms::class, \HandyMCP\Integrations\Seo::class, \HandyMCP\Integrations\Extras::class, \HandyMCP\Integrations\Media::class, \HandyMCP\Admin\Diagnostics::class, \HandyMCP\Integrations\Assistant::class, \HandyMCP\Integrations\WooCommerce::class, ); /** * Add or remove integration classes. * * @param string[] $classes Class names with a static register( Registry $r ) method. */ return (array) apply_filters( 'handymcp_integrations', $classes ); } /** * Register all integrations once. */ public function boot(): void { if ( $this->booted ) { return; } $this->booted = true; foreach ( self::integrations() as $class ) { if ( ! is_string( $class ) || ! class_exists( $class ) || ! method_exists( $class, 'register' ) ) { continue; } try { $class::register( $this ); } catch ( \Throwable $e ) { // One broken integration never takes the server down. $this->groups[ 'error_' . sanitize_key( $class ) ] = array( 'id' => 'error_' . sanitize_key( $class ), 'label' => $class, 'detected' => false, 'error' => $e->getMessage(), 'default' => 'off', 'plugins' => array(), ); } } /** * Register extra tools. * * @param Registry $registry Registry. */ do_action( 'handymcp_register_tools', $this ); } /** * Declare a group. * * @param string $id Group id. * @param array $spec label, description, detected (bool), plugins (name => version), default (off|read|write). */ public function group( string $id, array $spec ): void { $this->groups[ $id ] = array_merge( array( 'id' => $id, 'label' => $id, 'description' => '', 'detected' => false, 'plugins' => array(), 'default' => 'write', ), $spec, array( 'id' => $id ) ); } /** * Is a group active on this site? * * @param string $id Group id. */ public function detected( string $id ): bool { return ! empty( $this->groups[ $id ]['detected'] ); } /** * Add a tool. * * Spec keys: name, group, title, description, input (JSON schema of the * arguments), cap (capability string or callable( $args ): bool|WP_Error), * scope, write (bool), confirm (bool: destructive, needs confirm=true), * raw (bool: skip output redaction, for builder layouts that are sent back), * run (callable( $args, Identity ): array|WP_Error). * * @param array $spec Tool spec. */ public function add( array $spec ): void { $spec = array_merge( array( 'title' => '', 'description' => '', 'input' => array(), 'cap' => 'read', 'write' => false, 'confirm' => false, 'raw' => false, ), $spec ); if ( empty( $spec['name'] ) || empty( $spec['group'] ) || ! is_callable( $spec['run'] ?? null ) || ! $this->detected( $spec['group'] ) ) { return; } if ( '' === trim( (string) $spec['description'] ) ) { $spec['description'] = (string) $spec['title'] . '.'; } if ( ! isset( $spec['scope'] ) ) { $spec['scope'] = $spec['write'] ? 'content:write' : 'read'; } $input = array_merge( array( 'type' => 'object', 'properties' => array() ), (array) $spec['input'] ); if ( ! isset( $input['additionalProperties'] ) ) { $input['additionalProperties'] = false; } if ( ! empty( $spec['live_guard'] ) && ! $spec['confirm'] ) { $input['properties']['replace_live'] = array( 'type' => 'boolean', 'description' => 'true only when the user asked to replace THAT published page. For a redesign or new version, create a new draft instead.', ); } if ( $spec['confirm'] ) { $input['properties']['confirm'] = array( 'type' => 'boolean', 'description' => 'true only after the user agreed to this exact action.', ); } if ( $spec['write'] && self::edits_post( $spec ) ) { $input['properties']['expected_modified'] = array( 'type' => 'string', 'description' => 'The "modified" value from your last read. If the post changed since, nothing is saved.', ); } $spec['input'] = $input; $this->tools[ $spec['name'] ] = $spec; } /** * Post ids a write tool will change, from its arguments. A tool can name * its own with "object_args"; otherwise common names are read for content tools. * * @param string $name Tool name. * @param array $tool Tool spec. * @param array $args Normalized arguments. * @return int[] */ private static function object_ids( string $name, array $tool, array $args ): array { if ( isset( $tool['object_args'] ) ) { $keys = (array) $tool['object_args']; } elseif ( in_array( $tool['group'] ?? '', array( 'wordpress', 'content', 'elementor', 'gutenberg', 'woocommerce', 'images', 'seo' ), true ) ) { $keys = array( 'post_id', 'page_id', 'template_id' ); // "id" is a post id only for post tools: terms and menus use their own ids. if ( preg_match( '/^(wp_(update|edit|trash|delete|restore|duplicate|preview)|elementor_|gutenberg_|wpbakery_|avada_|divi_|beaver_|builder_|seo_update|woo_save_product)/', $name ) && ! preg_match( '/_(term|terms|menu|menus|menu_item)$/', $name ) ) { $keys[] = 'id'; $keys[] = 'ids'; } } else { return array(); } $ids = array(); foreach ( $keys as $key ) { if ( ! isset( $args[ $key ] ) ) { continue; } foreach ( is_array( $args[ $key ] ) ? $args[ $key ] : array( $args[ $key ] ) as $v ) { if ( is_int( $v ) || ( is_string( $v ) && ctype_digit( $v ) ) ) { $ids[] = (int) $v; } } } return array_values( array_unique( array_filter( $ids ) ) ); } /** * Tools whose "id" argument is a post that gets edited. * * @param array $spec Tool spec. */ private static function edits_post( array $spec ): bool { if ( isset( $spec['edits_post'] ) ) { return (bool) $spec['edits_post']; } if ( empty( $spec['input']['properties']['id'] ) ) { return false; } return ! empty( $spec['live_guard'] ) || in_array( $spec['name'], array( 'wp_update_post', 'wp_edit_text', 'elementor_update_element', 'elementor_add_element', 'elementor_remove_element', 'elementor_move_element', 'gutenberg_update_block', 'gutenberg_insert_blocks', 'gutenberg_remove_block', 'woo_save_product' ), true ); } /** * Make a schema acceptable to strict validators (ChatGPT, VS Code, * Gemini): every array has "items", every object has "properties", * enums sit on string types only. * * @param array $schema Schema. * @param int $depth Depth. * @return array */ public static function strict_schema( array $schema, int $depth = 0 ): array { if ( $depth > 12 ) { return $schema; } $type = $schema['type'] ?? null; $types = (array) $type; if ( in_array( 'array', $types, true ) && ! isset( $schema['items'] ) ) { $schema['items'] = new \stdClass(); } if ( isset( $schema['items'] ) && is_array( $schema['items'] ) ) { $schema['items'] = self::strict_schema( $schema['items'], $depth + 1 ); } if ( isset( $schema['properties'] ) && is_array( $schema['properties'] ) ) { foreach ( $schema['properties'] as $k => $sub ) { if ( is_array( $sub ) ) { $schema['properties'][ $k ] = self::strict_schema( $sub, $depth + 1 ); } } } if ( isset( $schema['additionalProperties'] ) && is_array( $schema['additionalProperties'] ) ) { $schema['additionalProperties'] = self::strict_schema( $schema['additionalProperties'], $depth + 1 ); } if ( isset( $schema['enum'] ) && null !== $type && ! in_array( 'string', $types, true ) ) { $schema['description'] = trim( ( $schema['description'] ?? '' ) . ' One of: ' . implode( ', ', array_map( 'strval', (array) $schema['enum'] ) ) . '.' ); unset( $schema['enum'] ); } return $schema; } /** * Groups with their current access mode. * * @return array> */ public function groups(): array { $this->boot(); $out = array(); foreach ( $this->groups as $id => $g ) { $g['mode'] = $g['detected'] ? $this->settings->group_mode( $id, (string) $g['default'] ) : 'off'; $g['tools'] = array( 'read' => count( array_filter( $this->tools, static function ( $t ) use ( $id ) { return $t['group'] === $id && ! $t['write']; } ) ), 'write' => count( array_filter( $this->tools, static function ( $t ) use ( $id ) { return $t['group'] === $id && $t['write']; } ) ), ); $out[ $id ] = $g; } return $out; } /** * Is a tool switched on in the dashboard (ignoring who calls it)? * * @param array $tool Tool. */ public function enabled( array $tool ): bool { $mode = $this->settings->group_mode( $tool['group'], (string) ( $this->groups[ $tool['group'] ]['default'] ?? 'write' ) ); if ( 'off' === $mode || ( $tool['write'] && 'write' !== $mode ) ) { return false; } return ! in_array( $tool['name'], (array) $this->settings->get( 'tools_off', array() ), true ); } /** * All registered tools (unfiltered). * * @return array> */ public function all(): array { $this->boot(); return $this->tools; } /** * Why a tool cannot be used: missing permission, missing connection access, or switched off. * * @param string $name Tool name. * @param array|null $tool Tool. * @param Identity $identity Caller. */ private function denied( string $name, ?array $tool, Identity $identity ): \WP_Error { if ( $tool && is_string( $tool['cap'] ) && '' !== $tool['cap'] && ! user_can( $identity->user_id, $tool['cap'] ) ) { return new \WP_Error( 'forbidden', 'Your WordPress account does not have permission for ' . $name . ' (it needs the ' . $tool['cap'] . ' capability).' ); } if ( $tool && ! $identity->has_scope( (string) $tool['scope'] ) ) { return new \WP_Error( 'forbidden', 'This connection was not given "' . $tool['scope'] . '" access, which ' . $name . ' needs. Reconnect the app and allow it, or use an access token with that access.' ); } return new \WP_Error( 'unknown_tool', 'Unknown or disabled tool: ' . $name . self::pro_hint( $name ) ); } /** * Is a tool usable by this identity right now? * * @param array $tool Tool. * @param Identity $identity Caller. */ private function usable( array $tool, Identity $identity ): bool { // wp_site_info stays available to every connection with the read scope, // so an app can always learn why it has no other tools. if ( in_array( $tool['name'], array( 'wp_site_info', 'site_style_guide' ), true ) ) { return $identity->has_scope( 'read' ) && user_can( $identity->user_id, 'read' ); } $mode = $this->settings->group_mode( $tool['group'], (string) ( $this->groups[ $tool['group'] ]['default'] ?? 'write' ) ); if ( 'off' === $mode || ( $tool['write'] && 'write' !== $mode ) ) { return false; } if ( in_array( $tool['name'], (array) $this->settings->get( 'tools_off', array() ), true ) ) { return false; } if ( ! $identity->has_scope( (string) $tool['scope'] ) ) { return false; } if ( is_string( $tool['cap'] ) && '' !== $tool['cap'] && ! user_can( $identity->user_id, $tool['cap'] ) ) { return false; } return true; } /** * Helper tools that are not useful on their own. */ /** * Tool hints without the values MCP clients already assume (read-only false, idempotent false, open world true). * * @return array */ private static function hints( bool $write, bool $destructive, bool $idempotent, bool $open_world ): array { if ( ! $write ) { return $open_world ? array( 'readOnlyHint' => true ) : array( 'readOnlyHint' => true, 'openWorldHint' => false ); } $h = array( 'destructiveHint' => $destructive ); if ( $idempotent ) { $h['idempotentHint'] = true; } if ( ! $open_world ) { $h['openWorldHint'] = false; } return $h; } const BRIEF_TOOLS = '/^(wp_(create_post|update_post|edit_text|duplicate_post|save_menu_item|create_term|update_term|update_media|upload_media)$|gutenberg_(save|insert|update)_|elementor_(save|update|add|copy|duplicate|move)_|sections_(build_page|save)|builder_(edit|update|save)_|beaver_(build_page|add_module)|flatsome_(save_|update_|insert_|custom_code|header_update|footer_update|options_update)|avada_save_|wpbakery_save_|xstore_save_|gp_save_element|blocksy_(save_content_block|update_options)|uae_save_template|snippets_save|global_styles_update|theme_settings_update|(avada|divi)_page_css|woo_(save_product|save_variation|bulk_update|duplicate_product)$|files_(write|edit)$|customizer_update$|brand_(apply|save)$|seo_(update|update_many|social_update|term_update)$|images_add$|videos_add$|icons_add_svg$)/'; const GUIDE_TOOLS = '/^(wp_create_post|wp_duplicate_post|gutenberg_save_content|sections_build_page|sections_save|beaver_build_page|gp_save_element|blocksy_save_content_block|uae_save_template|snippets_save|flatsome_custom_code|global_styles_update|theme_settings_update|avada_page_css|divi_page_css|woo_save_product|files_write|files_edit|customizer_update|brand_apply|brand_save)$|^(elementor|builder|flatsome|avada|wpbakery|xstore)_save_/'; const HELPERS = array( 'wp_site_info', 'site_style_guide', 'tools_find', 'tools_run', 'batch' ); /** * Why this connection cannot use any real tool, or '' when it can. * * @param Identity $identity Caller. * @return string One of '', 'groups_off', 'scopes', 'role', 'read_only'. */ public function no_tools_reason( Identity $identity ): string { $this->boot(); $read_only = (bool) $this->settings->get( 'read_only', false ); $real = array_filter( $this->tools, static function ( $t ) { return ! in_array( $t['name'], self::HELPERS, true ); } ); foreach ( $real as $t ) { if ( $this->usable( $t, $identity ) && ! ( $read_only && $t['write'] ) ) { return ''; } } $on = array_filter( $real, array( $this, 'enabled' ) ); if ( ! $on ) { return 'groups_off'; } $scoped = array_filter( $on, static function ( $t ) use ( $identity ) { return $identity->has_scope( (string) $t['scope'] ); } ); if ( ! $scoped ) { return 'scopes'; } $capped = array_filter( $scoped, static function ( $t ) use ( $identity ) { return ! is_string( $t['cap'] ) || '' === $t['cap'] || user_can( $identity->user_id, $t['cap'] ); } ); return $capped ? 'read_only' : 'role'; } /** * Plain sentence for a connection that cannot use any tool, or ''. * * @param Identity $identity Caller. */ public function no_tools_message( Identity $identity ): string { $reasons = array( 'groups_off' => array( 'every tool group is switched off', 'switch on at least one tool group under HandyMCP > Abilities' ), 'scopes' => array( 'no permissions (scopes) that match the switched-on tools were approved for it', 'reconnect the app and approve the permissions it needs, or switch on tools that match the approved ones' ), 'role' => array( 'its WordPress account\'s role is not allowed to use any of the switched-on tools', 'connect with an account that can edit content (for example an Editor or Administrator)' ), 'read_only' => array( 'the site is in read-only mode and only tools that change the site are switched on', 'turn off read-only mode under HandyMCP > Settings, or switch on some tools that only read' ), ); $why = $this->no_tools_reason( $identity ); if ( ! isset( $reasons[ $why ] ) ) { return ''; } return 'This connection can\'t use any tools: ' . $reasons[ $why ][0] . '. Ask the site owner to ' . $reasons[ $why ][1] . '.'; } /** * Tool definitions for tools/list. * * @param Identity $identity Caller. * @return array> */ public function definitions( Identity $identity, bool $all = false ): array { $this->boot(); $out = array(); $compact = ! $all && 'compact' === $this->settings->get( 'tool_mode', 'compact' ); foreach ( $this->tools as $t ) { if ( ! $this->usable( $t, $identity ) ) { continue; } if ( $compact && ! in_array( $t['name'], self::essentials(), true ) ) { continue; } // Read-only mode: do not offer tools that would only be refused. if ( $t['write'] && ! in_array( $t['name'], array( 'tools_run', 'batch' ), true ) && $this->settings->get( 'read_only', false ) ) { continue; } $input = self::strict_schema( $t['input'] ); if ( empty( $input['properties'] ) ) { $input['properties'] = new \stdClass(); } $out[] = array( 'name' => $t['name'], 'title' => $t['title'], 'description' => $t['description'], 'inputSchema' => $input, 'annotations' => self::hints( (bool) $t['write'], (bool) $t['confirm'], ! empty( $t['idempotent'] ), (bool) ( ! empty( $t['open_world'] ) || in_array( $t['name'], array( 'images_search', 'images_add', 'plugins_search', 'plugins_install', 'themes_install', 'wp_upload_media' ), true ) ) ), ); if ( ! empty( $t['meta'] ) ) { $out[ count( $out ) - 1 ]['_meta'] = $t['meta']; } } return $out; } /** * Tools always listed in compact mode; everything else is reachable * through tools_find / tools_run / batch, which keeps the tool list small. * * @return string[] */ public static function essentials(): array { return (array) apply_filters( 'handymcp_essential_tools', array( 'wp_site_info', 'site_style_guide', 'tools_find', 'tools_run', 'batch', 'history_list', 'history_revert', 'search', 'fetch', 'wp_list_posts', 'wp_get_post', 'wp_create_post', 'wp_update_post', 'wp_duplicate_post', 'wp_search', 'wp_edit_text', 'gutenberg_get_blocks', 'gutenberg_save_content', 'elementor_widgets', 'elementor_widget_schema', 'elementor_get_page', 'elementor_save_page', 'elementor_update_element', 'images_search', 'images_add' ) ); } /** * Is the tool listed for this identity? * * @param string $name Tool name. * @param Identity $identity Caller. */ public function has( string $name, Identity $identity ): bool { $this->boot(); return isset( $this->tools[ $name ] ) && $this->usable( $this->tools[ $name ], $identity ); } /** * Tools that only HandyMCP Pro has, so an app asking for one learns why it is missing. */ const PRO_TOOLS = 'abandoned_carts_list abandoned_carts_report abandonment_email_save abandonment_emails_list abilities_list ability_run acf_fields acf_get_values acf_register_type acf_save_field_group acf_update_values astra_options audit_page audit_site avada_layouts avada_library avada_page_css backup_database backups_cleanup backups_list beaver_add_module beaver_build_page beaver_modules beaver_remove_node beaver_templates blocksy_get_content_block blocksy_get_options blocksy_info blocksy_save_content_block blocksy_update_options brand_apply brand_get brand_save builder_edit_text builder_get_layout builder_update_element cache_db_cleanup cache_preload cache_settings_update cache_status comments_list comments_moderate core_update cptui_list customizer_get customizer_update discount_rule_save discount_rule_status discount_rules_list divi_library divi_page_css edd_customer edd_order edd_orders edd_products edd_resend_receipt edd_save_product edd_stats elementor_addons_info elementor_addons_settings_get elementor_addons_settings_update elementor_addons_toggle events_list events_save fields_get fields_list fields_update files_delete files_edit files_list files_read files_search files_write flatsome_custom_code flatsome_element_schema flatsome_footer_get flatsome_footer_update flatsome_get_page flatsome_header_get flatsome_header_update flatsome_info flatsome_insert_elements flatsome_options flatsome_options_update flatsome_remove_element flatsome_save_block flatsome_save_page flatsome_update_element forms_delete forms_entry_action global_styles_get global_styles_update gp_get_element gp_list_elements gp_save_element gravity_save_form gravity_save_notification gtm_status gtm_update gtranslate_get gtranslate_update hub_disconnect hub_login_link hub_run hub_site_tools hub_sites hub_status jetpack_modules mail_status mail_test mailpoet_lists mailpoet_subscriber metform_save_form options_read packages_install_zip plugins_activate plugins_delete plugins_install plugins_list plugins_search plugins_update redirects_list redirects_save reports_list rest_get rest_request rest_routes schedule_get schedule_run_now schedule_save search_replace_preview search_replace_run sections_build_page sections_list sections_save security_run_scan security_status security_update_settings security_wordfence_issues seo_settings_get seo_settings_update seo_social_update seo_term_update seo_update_many settings_update site_health_check site_logo_icon snippets_debug social_login_get social_login_update snippets_get snippets_list snippets_save tables_get tables_list tables_save theme_settings_get theme_settings_update themes_create_child themes_install themes_list themes_update translation_create translation_files_list translation_strings_get translation_strings_save translations_get uae_info uae_list_templates uae_save_template uae_widgets_toggle updraftplus_backup_now users_admin_email users_change_email users_change_login users_list users_logout users_reset_passwords users_save users_set_role white_label widgets_list widgets_remove woo_attributes woo_email_settings woo_bulk_update woo_create_order woo_customers woo_delete_product woo_duplicate_product woo_payment_gateways woo_refund_order woo_reviews woo_sales_report woo_save_customer woo_save_variation woo_shipping woo_taxes woo_update_order wishlist_get wishlist_update wp_purge_cache wpbakery_page_code wpbakery_templates xstore_info xstore_list_blocks xstore_options xstore_save_block'; /** * Hint for an unknown tool that is part of HandyMCP Pro, or ''. * * @param string $name Tool name. */ public static function pro_hint( string $name ): string { if ( '' === $name || ! in_array( $name, explode( ' ', self::PRO_TOOLS ), true ) ) { return ''; } return '. ' . $name . ' is part of HandyMCP Pro and is not available in the free plugin (https://handymcp.com/#pricing). Tell the user instead of trying workarounds.'; } /** * Run a tool. * * @param string $name Tool name. * @param array $args Arguments. * @param Identity $identity Caller. * @return array{result: mixed, error: bool} */ public function call( string $name, array $args, Identity $identity ): array { $this->boot(); $start = microtime( true ); $tool = $this->tools[ $name ] ?? null; if ( ! $tool || ! $this->usable( $tool, $identity ) ) { return $this->fail( $name, $tool, $identity, $this->denied( $name, $tool, $identity ), $args, $start ); } // Aliases (settings -> set, ID -> id ...) are mapped first, so the read check sees the real arguments. $args = self::normalize_args( $args, $tool ); // Tools that list without change arguments are reads for that call. if ( $tool['write'] && ! empty( $tool['read_unless'] ) && ! array_intersect_key( $args, array_flip( (array) $tool['read_unless'] ) ) ) { $tool['write'] = false; } if ( $tool['write'] && ! in_array( $name, array( 'tools_run', 'batch' ), true ) && $this->settings->get( 'read_only', false ) ) { // Wrappers check each inner tool. return $this->fail( $name, $tool, $identity, new \WP_Error( 'forbidden', 'This site is in read-only mode: AI apps can look but not change anything. The site owner can turn it off under HandyMCP > Settings > Server.' ), $args, $start ); } $gate = apply_filters( 'handymcp_pre_call', null, $name, $tool, $identity ); if ( is_wp_error( $gate ) ) { return $this->fail( $name, $tool, $identity, $gate, $args, $start ); } $bucket = $tool['write'] ? 'write' : 'read'; if ( ! $this->limiter->allow( $identity->rate_key(), $bucket ) ) { return $this->fail( $name, $tool, $identity, new \WP_Error( 'rate_limited', 'Too many requests. Wait about 60 seconds, then try again (retry_after: 60).' ), $args, $start ); } // Act as the approved user, with admin-only capabilities removed, before // any permission check below. \HandyMCP\Auth\CapGuard::apply(); wp_set_current_user( $identity->user_id ); // Object-level check: changing a page needs permission to edit THAT page, // not just edit_posts (an Author must not change someone else's page). // Runs on the normalized arguments, so an argument alias cannot skip it. if ( $tool['write'] ) { foreach ( self::object_ids( $name, $tool, $args ) as $pid ) { $target = get_post( $pid ); if ( $target && ! in_array( $target->post_type, array( 'nav_menu_item', 'revision' ), true ) && ! current_user_can( 'edit_post', $pid ) ) { return $this->fail( $name, $tool, $identity, new \WP_Error( 'forbidden', 'Your account cannot edit item #' . $pid . '.' ), $args, $start ); } } } list( $clean, $errors ) = Schema::validate( $args, $tool['input'] ); if ( $errors ) { $props = array_keys( (array) ( $tool['input']['properties'] ?? array() ) ); $req = (array) ( $tool['input']['required'] ?? array() ); $help = $props ? ' Allowed arguments: ' . implode( ', ', $props ) . ( $req ? ' (required: ' . implode( ', ', $req ) . ')' : '' ) . '.' : ''; return $this->fail( $name, $tool, $identity, new \WP_Error( 'invalid_arguments', 'Invalid arguments: ' . implode( '; ', array_slice( $errors, 0, 8 ) ) . '.' . $help ), $args, $start ); } if ( $tool['confirm'] && true !== ( $clean['confirm'] ?? false ) ) { return $this->fail( $name, $tool, $identity, new \WP_Error( 'confirmation_required', 'This action changes or removes data that is hard to undo. Describe exactly what will happen to the user, and call again with confirm=true only after they agree.' ), $args, $start ); } if ( $tool['confirm'] ) { unset( $clean['confirm'] ); } $allowed =( ! is_string( $tool['cap'] ) && is_callable( $tool['cap'] ) ) ? call_user_func( $tool['cap'], $clean ) : ( '' === $tool['cap'] || current_user_can( $tool['cap'] ) ); if ( is_wp_error( $allowed ) ) { return $this->fail( $name, $tool, $identity, $allowed, $args, $start ); } if ( ! $allowed ) { return $this->fail( $name, $tool, $identity, new \WP_Error( 'forbidden', 'Your WordPress account does not have permission for this action.' ), $args, $start ); } // Checked after the capability check, so nothing about a post is // revealed to an account that may not use this tool. if ( ! empty( $tool['live_guard'] ) ) { $field = (string) $tool['live_guard']; $pid = (int) ( $clean['id'] ?? 0 ); $ok = true === ( $clean['replace_live'] ?? false ); unset( $clean['replace_live'] ); if ( $pid && ! $ok && ( 'content' !== $field || array_key_exists( 'content', $clean ) ) ) { $p = get_post( $pid ); if ( $p && current_user_can( 'edit_post', $pid ) && 'publish' === $p->post_status && ( '' !== trim( (string) $p->post_content ) || get_post_meta( $pid, '_elementor_data', true ) ) ) { $front = (int) get_option( 'page_on_front' ) === $pid ? ' It is the site\'s home page.' : ''; return $this->fail( $name, $tool, $identity, new \WP_Error( 'live_page_protected', 'Post ' . $pid . ' ("' . $p->post_title . '") is live and has content.' . $front . ' Its whole content would be replaced. If the user asked for a new design, a redesign to review, or a draft: create a new page instead (or wp_duplicate_post, then edit the copy). Only if the user clearly asked to change this live page, call again with replace_live=true. If unsure, ask the user one short question first.' ), $args, $start ); } } } $lock_note = ''; if ( $tool['write'] && self::edits_post( $tool ) && ! empty( $clean['id'] ) ) { $pid = (int) $clean['id']; $expected = isset( $clean['expected_modified'] ) ? trim( (string) $clean['expected_modified'] ) : ''; $post = get_post( $pid ); $post = $post && current_user_can( 'edit_post', $pid ) ? $post : null; if ( $post && '' !== $expected && ! in_array( $expected, array( $post->post_modified_gmt, $post->post_modified, mysql2date( 'c', $post->post_modified_gmt, false ) ), true ) ) { return $this->fail( $name, $tool, $identity, new \WP_Error( 'conflict', 'Post ' . $pid . ' was changed at ' . Util::modified( $post ) . ', after you read it (' . $expected . '). Nothing was saved. Read it again and redo the change on the current version.', array( 'modified' => Util::modified( $post ) ) ), $args, $start ); } if ( $post ) { if ( ! function_exists( 'wp_check_post_lock' ) ) { require_once ABSPATH . 'wp-admin/includes/post.php'; } $locker = wp_check_post_lock( $pid ); if ( $locker && (int) $locker !== (int) $identity->user_id ) { $who = get_userdata( (int) $locker ); $lock_note = ( $who ? $who->display_name : 'Another user' ) . ' has this page open in the editor right now. If they save, your change can be overwritten. Tell the user.'; } } } unset( $clean['expected_modified'] ); if ( $tool['write'] ) { History::begin( $name ); } Crash::start( $name, user_can( $identity->user_id, 'manage_options' ) ); try { $result = call_user_func( $tool['run'], $clean, $identity ); } catch ( \Throwable $e ) { $eid = substr( wp_hash( uniqid( '', true ) ), 0, 10 ); if ( defined( 'WP_DEBUG' ) && WP_DEBUG && function_exists( 'error_log' ) ) { error_log( sprintf( '[handymcp %s] %s in %s:%d', $eid, $e->getMessage(), $e->getFile(), $e->getLine() ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions } $owner = Crash::from_throwable( $e ); if ( in_array( $owner['type'], array( 'plugin', 'theme' ), true ) ) { Crash::record( $owner, $name ); $result = new \WP_Error( 'plugin_crash', Crash::message( $owner, $name ) . ' Error id ' . $eid . '.' ); } else { $result = new \WP_Error( 'tool_exception', 'The tool hit an internal error and made no further changes. Error id ' . $eid . ' (details are in the PHP error log).' ); } } Crash::stop(); $op = ( $tool['write'] && History::has_entries() ) ? History::op() : ''; if ( $tool['write'] ) { History::end(); } if ( $op && is_array( $result ) ) { $result['history_id'] = $op; } if ( is_wp_error( $result ) ) { return $this->fail( $name, $tool, $identity, $result, $args, $start ); } $result = is_array( $result ) ? $result : array( 'result' => $result ); if ( $lock_note ) { $result['warnings'] = array_values( array_merge( (array) ( $result['warnings'] ?? array() ), array( $lock_note ) ) ); } if ( $tool['write'] && self::edits_post( $tool ) && ! empty( $clean['id'] ) && ! isset( $result['modified'] ) ) { clean_post_cache( (int) $clean['id'] ); $after = get_post( (int) $clean['id'] ); if ( $after ) { $result['modified'] = Util::modified( $after ); } } if ( $tool['write'] && ( ! empty( $tool['live_guard'] ) || in_array( $name, array( 'elementor_add_element', 'elementor_update_element', 'elementor_save_template', 'wp_create_post', 'xstore_save_block', 'uae_save_template' ), true ) ) ) { $notes = Util::content_warnings( (string) wp_json_encode( $clean, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ) ); if ( $notes ) { $result['warnings'] = array_values( array_merge( (array) ( $result['warnings'] ?? array() ), $notes ) ); } } if ( $tool['raw'] && is_array( $result ) ) { $json = (string) wp_json_encode( $result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); if ( strlen( $json ) < 4000000 && Redactor::scrub_string( $json ) !== $json ) { $result['warnings'] = array_values( array_merge( (array) ( $result['warnings'] ?? array() ), array( 'This layout contains values that look like API keys or passwords. They are included so the layout can be saved back unchanged. Do not repeat them to the user or copy them anywhere else.' ) ) ); } } $brief = self::brief_reminder( $name, $tool, $identity ); if ( $brief ) { $result = array_merge( $result, $brief ); $notes = array(); if ( isset( $brief['site_brief'] ) ) { $notes[] = 'This connection had not read the site brief yet. It is in site_brief: follow it for every change on this site from now on, and check this change against it.'; } if ( isset( $brief['site_style_guide'] ) ) { $notes[] = 'The site owner\'s detailed style guide is in site_style_guide. Follow it for every page, post, product and code change on this site, and fix this change if it does not match.'; } $result['warnings'] = array_values( array_merge( (array) ( $result['warnings'] ?? array() ), $notes ) ); } if ( 'tools_run' === $name && self::$inner_logged ) { self::$inner_logged = false; // The inner tool already wrote its own log entry. } else { $this->log->record( $name, $tool, $identity, 'success', $result, $args, $start ); } /** * After a tool ran successfully. * * @param string $name Tool. * @param array $tool Tool spec. * @param Identity $identity Caller. */ do_action( 'handymcp_tool_done', $name, $tool, $identity ); return array( 'result' => $tool['raw'] ? $result : Redactor::redact( $result ), 'error' => false, ); } /** * The site brief and the detailed style guide, once per connection, on the first change made by * an app that has not read them (wp_site_info counts for the brief, site_style_guide for both). * * @param string $name Tool. * @param array $tool Tool spec. * @param Identity $identity Caller. * @return array */ private static function brief_reminder( string $name, array $tool, Identity $identity ): array { $text = \HandyMCP\Support\SiteBrief::text(); if ( '' === $text ) { return array(); } $guide = \HandyMCP\Support\SiteBrief::guide(); $base = 'handymcp_brief_' . md5( $identity->rate_key() . '|' . md5( $text . $guide ) ); $ttl = 12 * HOUR_IN_SECONDS; if ( 'wp_site_info' === $name || 'site_style_guide' === $name ) { set_transient( $base, 1, $ttl ); if ( 'site_style_guide' === $name ) { set_transient( $base . '_g', 1, $ttl ); } return array(); } if ( empty( $tool['write'] ) || ! preg_match( self::BRIEF_TOOLS, $name ) ) { return array(); } $out = array(); if ( ! get_transient( $base ) ) { $out['site_brief'] = $text; set_transient( $base, 1, $ttl ); } if ( '' !== $guide && preg_match( self::GUIDE_TOOLS, $name ) && ! get_transient( $base . '_g' ) ) { $out['site_style_guide'] = $guide; set_transient( $base . '_g', 1, $ttl ); } return $out; } /** * Build an error result and log it. * * @param string $name Tool name. * @param array|null $tool Tool. * @param Identity $identity Caller. * @param \WP_Error $err Error. * @param array $args Args. * @param float $start Start time. * @return array{result: mixed, error: bool} */ private function fail( string $name, ?array $tool, Identity $identity, \WP_Error $err, array $args, float $start ): array { $result = array( 'error' => array( 'code' => $err->get_error_code(), 'message' => $err->get_error_message(), ), ); $data = $err->get_error_data(); if ( is_array( $data ) && $data ) { unset( $data['status'] ); if ( $data ) { $result['error']['details'] = Redactor::redact( $data ); } } $result['error'] += self::guidance( (string) $err->get_error_code() ); $code = (string) $err->get_error_code(); $status = in_array( $code, array( 'forbidden', 'unknown_tool', 'rate_limited', 'confirmation_required', 'not_allowed', 'use_write_tool', 'protected' ), true ) || 0 === strpos( $code, 'approval_' ) ? 'denied' : 'error'; if ( 'tools_run' === $name && self::$inner_logged ) { self::$inner_logged = false; // The inner tool already logged this error. } else { $this->log->record( $name, $tool, $identity, $status, $result, $args, $start ); } return array( 'result' => $result, 'error' => true, ); } /** * Map argument names AI apps commonly guess to the names a tool uses * (post_id for id, find/replace for old_text/new_text, offset/limit for * line ranges...). A name is only mapped when the tool has the target and * not the guessed name, so real arguments are never changed. * * @param array $args Arguments. * @param array $tool Tool. * @return array */ public static function normalize_args( array $args, array $tool ): array { $props = (array) ( $tool['input']['properties'] ?? array() ); if ( ! $props ) { return $args; } $has = static function ( string $k ) use ( $props ): bool { return array_key_exists( $k, $props ); }; // Line ranges: offset/limit, from/to, max_lines become start_line/end_line. if ( $has( 'start_line' ) && $has( 'end_line' ) ) { $start = null; if ( isset( $args['offset'] ) && ! $has( 'offset' ) && is_numeric( $args['offset'] ) ) { $start = (int) $args['offset'] + 1; unset( $args['offset'] ); } if ( isset( $args['from'] ) && ! $has( 'from' ) && is_numeric( $args['from'] ) ) { $start = (int) $args['from']; unset( $args['from'] ); } if ( null !== $start && ! isset( $args['start_line'] ) ) { $args['start_line'] = max( 1, $start ); } foreach ( array( 'limit', 'max_lines', 'lines' ) as $k ) { if ( isset( $args[ $k ] ) && ! $has( $k ) && is_numeric( $args[ $k ] ) ) { if ( ! isset( $args['end_line'] ) ) { $args['end_line'] = (int) ( $args['start_line'] ?? 1 ) + max( 1, (int) $args[ $k ] ) - 1; } unset( $args[ $k ] ); } } if ( isset( $args['to'] ) && ! $has( 'to' ) && is_numeric( $args['to'] ) ) { if ( ! isset( $args['end_line'] ) ) { $args['end_line'] = (int) $args['to']; } unset( $args['to'] ); } } $aliases = array( 'id' => array( 'post_id', 'page_id', 'ID', 'postId', 'attachment_id', 'media_id' ), 'old_text' => array( 'find', 'search', 'old', 'from_text', 'old_string' ), 'new_text' => array( 'replace', 'replacement', 'new', 'to_text', 'new_string' ), 'query' => array( 'pattern', 'regex', 'search', 'q', 'text', 'term' ), 'names' => array( 'keys', 'options', 'settings', 'name', 'option' ), 'history_id' => array( 'op_id', 'change_id', 'operation_id' ), 'calls' => array( 'operations', 'steps', 'actions', 'requests' ), 'limit' => array( 'per_page', 'number', 'count', 'max' ), 'seo_title' => array( 'title', 'meta_title' ), 'description'=> array( 'meta_description' ), 'post_types' => array( 'post_type', 'type' ), 'path' => array( 'file', 'file_path', 'filepath' ), 'keyword' => array( 'focus_keyword', 'focus_keywords', 'keywords', 'focus_kw' ), 'params' => array( 'body', 'data', 'json', 'payload' ), ); // A wrapper call (tools_run) with the inner tool's arguments sent next to "name" instead of inside "arguments". if ( $has( 'name' ) && $has( 'arguments' ) && 2 === count( $props ) ) { foreach ( $args as $k => $v ) { if ( 'name' !== $k && 'arguments' !== $k ) { if ( ! isset( $args['arguments'] ) || ! is_array( $args['arguments'] ) ) { $args['arguments'] = array(); } if ( ! array_key_exists( $k, $args['arguments'] ) ) { $args['arguments'][ $k ] = $v; } unset( $args[ $k ] ); } } } // Categories and tags sent as their own fields instead of inside terms. if ( $has( 'terms' ) && ! $has( 'categories' ) ) { foreach ( array( 'categories' => 'category', 'category' => 'category', 'tags' => 'post_tag' ) as $k => $tax ) { if ( array_key_exists( $k, $args ) && ! $has( $k ) && ( is_array( $args[ $k ] ) || is_scalar( $args[ $k ] ) ) ) { $terms = isset( $args['terms'] ) && is_array( $args['terms'] ) ? $args['terms'] : array(); if ( ! isset( $terms[ $tax ] ) ) { $terms[ $tax ] = array_values( (array) $args[ $k ] ); $args['terms'] = $terms; } unset( $args[ $k ] ); } } } // A JSON object sent as text where an object is expected (REST params, settings). foreach ( array( 'params', 'set', 'args' ) as $k ) { if ( $has( $k ) && isset( $args[ $k ] ) && is_string( $args[ $k ] ) && 'object' === ( $props[ $k ]['type'] ?? '' ) ) { $decoded = json_decode( $args[ $k ], true ); if ( is_array( $decoded ) ) { $args[ $k ] = $decoded; } } } // batch items written like tools_run calls: {"name": "...", "arguments": {...}}. if ( $has( 'calls' ) && isset( $args['calls'] ) && is_array( $args['calls'] ) ) { foreach ( $args['calls'] as $n => $call ) { if ( ! is_array( $call ) ) { continue; } if ( ! isset( $call['tool'] ) && isset( $call['name'] ) ) { $call['tool'] = $call['name']; unset( $call['name'] ); } if ( ! isset( $call['args'] ) && isset( $call['arguments'] ) ) { $call['args'] = $call['arguments']; unset( $call['arguments'] ); } $args['calls'][ $n ] = $call; } } // Hints some apps add out of habit that change nothing here (grep-style context lines). foreach ( array( 'context', 'context_lines' ) as $k ) { if ( array_key_exists( $k, $args ) && ! $has( $k ) ) { unset( $args[ $k ] ); } } foreach ( $aliases as $target => $names ) { if ( ! $has( $target ) ) { continue; } foreach ( $names as $alias ) { if ( ! array_key_exists( $alias, $args ) || $has( $alias ) ) { continue; } if ( ! array_key_exists( $target, $args ) ) { $v = $args[ $alias ]; // A list sent for a single-value field: take the first item only when there is one. if ( is_array( $v ) && 'array' !== ( $props[ $target ]['type'] ?? '' ) ) { if ( 1 !== count( $v ) ) { continue; } $v = reset( $v ); } elseif ( ! is_array( $v ) && 'array' === ( $props[ $target ]['type'] ?? '' ) ) { $v = array( $v ); } $args[ $target ] = $v; } unset( $args[ $alias ] ); } } // Single ids sent as a one-item "ids" list. if ( $has( 'id' ) && ! $has( 'ids' ) && ! isset( $args['id'] ) && isset( $args['ids'] ) && is_array( $args['ids'] ) && 1 === count( $args['ids'] ) ) { $args['id'] = reset( $args['ids'] ); unset( $args['ids'] ); } // Field names without the post_ prefix. if ( isset( $args['field'] ) && is_string( $args['field'] ) && isset( $props['field']['enum'] ) && ! in_array( $args['field'], (array) $props['field']['enum'], true ) && in_array( 'post_' . $args['field'], (array) $props['field']['enum'], true ) ) { $args['field'] = 'post_' . $args['field']; } return $args; } /** * Tell the agent whether retrying helps and what to do next, so it does * not loop on errors it cannot fix by itself. * * @param string $code Error code. * @return array{retryable: bool, next_step: string} */ public static function guidance( string $code ): array { $map = array( 'invalid_arguments' => array( false, 'fix_arguments' ), 'confirmation_required' => array( false, 'ask_user' ), 'requires_confirmation' => array( false, 'ask_user' ), 'live_page_protected' => array( false, 'ask_user' ), 'forbidden' => array( false, 'stop' ), 'unknown_tool' => array( false, 'use_tools_find' ), 'rate_limited' => array( true, 'wait_and_retry' ), 'conflict' => array( false, 'read_again' ), 'no_match' => array( false, 'read_again' ), 'multiple_matches' => array( false, 'fix_arguments' ), 'not_found' => array( false, 'fix_arguments' ), 'tool_exception' => array( false, 'report_to_user' ), 'plugin_crash' => array( false, 'read_again' ), 'busy' => array( true, 'wait_and_retry' ), 'http_request_failed' => array( true, 'wait_and_retry' ), 'image_api' => array( true, 'wait_and_retry' ), 'not_allowed' => array( false, 'use_other_tool' ), 'php_syntax' => array( false, 'fix_arguments' ), 'json_corrupted' => array( false, 'fix_arguments' ), ); $m = $map[ $code ] ?? array( false, 'read_message' ); return array( 'retryable' => $m[0], 'next_step' => $m[1] ); } }