group( 'assistant', array( 'label' => 'AI helpers and undo', 'description' => 'Batch actions, on-demand tool lookup, undo history, and site search for AI apps.', 'detected' => true, 'plugins' => array( 'Built in' => '' ), ) ); $r->add( array( 'name' => 'batch', 'group' => 'assistant', 'title' => 'Run several tools in one call', 'description' => 'Run up to 30 tool calls in order in a single request, e.g. create all pages of a site, upload images and build the menu at once. Each item is {"tool":"name","args":{...}}. Use "$N.field" in a string arg to insert a value from the result of item N (0-based), e.g. "$0.id". Stops at the first error unless continue_on_error.', 'write' => true, 'cap' => 'read', 'scope' => 'read', 'input' => array( 'required' => array( 'calls' ), 'properties' => array( 'calls' => array( 'type' => 'array', 'minItems' => 1, 'maxItems' => 30, 'items' => array( 'type' => 'object', 'required' => array( 'tool' ), 'properties' => array( 'tool' => array( 'type' => 'string' ), 'args' => array( 'type' => 'object', 'additionalProperties' => true ), ), 'additionalProperties' => false, ), ), 'continue_on_error' => array( 'type' => 'boolean', 'default' => false ), ), ), 'run' => static function ( array $a, $identity ) { $reg = Plugin::instance()->container()->get( 'registry' ); $results = array(); $ok = 0; History::end(); // Every inner call records its own undo entry. foreach ( $a['calls'] as $i => $call ) { if ( 'batch' === $call['tool'] ) { $results[] = array( 'tool' => 'batch', 'error' => array( 'code' => 'nested_batch', 'message' => 'Batches cannot be nested.' ) ); continue; } $args = self::substitute( (array) ( $call['args'] ?? array() ), $results ); if ( ! $reg->has( $call['tool'], $identity ) ) { $res = array( 'result' => array( 'error' => array( 'code' => 'unknown_tool', 'message' => 'Unknown or disabled tool: ' . $call['tool'] . \HandyMCP\Tools\Registry::pro_hint( (string) $call['tool'] ) ) ), 'error' => true ); } else { $res = $reg->call( $call['tool'], $args, $identity ); } $results[] = array_merge( array( 'tool' => $call['tool'] ), is_array( $res['result'] ) ? $res['result'] : array( 'result' => $res['result'] ) ); if ( $res['error'] ) { if ( ! $a['continue_on_error'] ) { break; } } else { ++$ok; } } return array( 'completed' => $ok, 'total' => count( $a['calls'] ), 'results' => $results ); }, ) ); $r->add( array( 'name' => 'tools_find', 'group' => 'assistant', 'title' => 'Find tools', 'description' => 'Search all tools available on this site by keyword (e.g. "elementor template", "menu", "seo") and get their input schemas. Then call them directly or with tools_run.', 'input' => array( 'required' => array( 'query' ), 'properties' => array( 'query' => array( 'type' => 'string' ) ) ), 'run' => static function ( array $a, $identity ) { $words = array_filter( preg_split( '/\s+/', strtolower( $a['query'] ) ) ); $found = array(); foreach ( Plugin::instance()->container()->get( 'registry' )->definitions( $identity, true ) as $t ) { if ( in_array( $t['name'], array( 'tools_find', 'tools_run', 'batch' ), true ) ) { continue; } $hay = strtolower( $t['name'] . ' ' . $t['title'] . ' ' . $t['description'] ); $score = 0; $hits = 0; $named = 0; foreach ( $words as $w ) { $in = substr_count( $hay, $w ); $nm = false !== strpos( $t['name'], $w ); $hits += $in ? 1 : 0; $named += $nm ? 1 : 0; $score += min( $in, 3 ) + ( $nm ? 4 : 0 ); } if ( $score && $hits * 2 >= count( $words ) ) { $found[] = array( 'score' => $score, 'named' => $named, 'tool' => array( 'name' => $t['name'], 'description' => '' !== $t['description'] ? $t['description'] : $t['title'], 'inputSchema' => $t['inputSchema'] ) + ( empty( $t['annotations']['destructiveHint'] ) ? array() : array( 'destructive' => true ) ) ); } } usort( $found, static function ( $x, $y ) { return $y['score'] <=> $x['score']; } ); $min = $found ? $found[0]['score'] * 0.4 : 0; $named = $found && $found[0]['named'] * 2 > count( $words ); $top = array_filter( $found, static function ( $f ) use ( $min ) { return $f['score'] >= $min; } ); $out = array( 'tools' => array_column( array_slice( $top, 0, $named ? 6 : 3 ), 'tool' ) ); if ( ! $named ) { $out['note'] = 'No tool matches. HandyMCP Pro adds tools for WooCommerce, other page builders, plugins and themes, files and more (https://handymcp.com/#pricing).'; } return $out; }, ) ); $r->add( array( 'name' => 'tools_run', 'group' => 'assistant', 'title' => 'Run a tool by name', 'description' => 'Run any tool found with tools_find: {"name":"...","arguments":{...}}.', 'write' => true, 'scope' => 'read', 'input' => array( // Inner tool arguments sent next to "name" are moved into "arguments" below. 'additionalProperties' => true, 'required' => array( 'name' ), 'properties' => array( 'name' => array( 'type' => 'string' ), 'arguments' => array( 'type' => 'object', 'additionalProperties' => true ), ), ), 'run' => static function ( array $a, $identity ) { $reg = Plugin::instance()->container()->get( 'registry' ); if ( in_array( $a['name'], array( 'tools_run', 'batch' ), true ) || ! $reg->has( $a['name'], $identity ) ) { return new \WP_Error( 'unknown_tool', 'Unknown or disabled tool: ' . $a['name'] . \HandyMCP\Tools\Registry::pro_hint( (string) $a['name'] ) ); } // Arguments sent next to "name" instead of inside "arguments": merge them, explicit arguments win. $args = is_array( $a['arguments'] ?? null ) ? $a['arguments'] : array(); $extra = array_diff_key( $a, array( 'name' => 1, 'arguments' => 1 ) ); $args = array_merge( $extra, $args ); History::end(); $res = $reg->call( $a['name'], $args, $identity ); \HandyMCP\Tools\Registry::$inner_logged = true; if ( $res['error'] ) { $e = $res['result']['error']; return new \WP_Error( $e['code'], $e['message'], $e['details'] ?? null ); } return is_array( $res['result'] ) ? $res['result'] : array( 'result' => $res['result'] ); }, ) ); $r->add( array( 'name' => 'activity_list', 'group' => 'assistant', 'title' => 'Read the activity log', 'description' => 'Recent requests from AI apps, newest first, as shown in HandyMCP > Activity. Default: errors from the last 7 days. Use it to find out why a change failed or what an app did. Administrators only.', 'cap' => 'manage_options', 'input' => array( 'properties' => array( 'status' => array( 'type' => 'string', 'enum' => array( 'error', 'denied', 'ok', 'any' ), 'default' => 'error' ), 'hours' => array( 'type' => 'integer', 'minimum' => 1, 'maximum' => 720, 'default' => 168, 'description' => 'How far back to look.' ), 'tool' => array( 'type' => 'string', 'maxLength' => 120, 'pattern' => '^[a-z0-9_]*$' ), 'limit' => array( 'type' => 'integer', 'minimum' => 1, 'maximum' => 100, 'default' => 30 ), ), ), 'run' => static function ( array $a ) { global $wpdb; $status = (string) ( $a['status'] ?? 'error' ); $since = gmdate( 'Y-m-d H:i:s', time() - (int) ( $a['hours'] ?? 168 ) * HOUR_IN_SECONDS ); $tool = (string) ( $a['tool'] ?? '' ); $limit = (int) ( $a['limit'] ?? 30 ); $table = \HandyMCP\Database\Installer::table( 'log' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table, must be current. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT created_at, client, tool, status, object_ref, summary, duration_ms FROM %i WHERE created_at >= %s AND ( %s = %s OR status = %s ) AND ( %s = %s OR tool = %s ) ORDER BY id DESC LIMIT %d', $table, $since, $status, 'any', $status, $tool, '', $tool, $limit ), ARRAY_A ); $out = array(); foreach ( (array) $rows as $row ) { $out[] = array( 'when' => (string) $row['created_at'] . ' UTC', 'app' => mb_substr( (string) $row['client'], 0, 60 ), 'tool' => (string) $row['tool'], 'status' => (string) $row['status'], 'item' => (string) $row['object_ref'], 'message' => \HandyMCP\Support\Redactor::scrub_string( mb_substr( (string) $row['summary'], 0, 500 ) ), 'ms' => (int) $row['duration_ms'], ); } return array( 'entries' => $out, 'count' => count( $out ) ); }, ) ); $r->add( array( 'name' => 'history_changes', 'group' => 'assistant', 'title' => 'Show what a change did', 'description' => 'Before and now, field by field, for one change from history_list (history_id): title, text, SEO fields, prices, terms and settings. Use it to tell the user exactly what was changed, or to check a change before undoing it.', 'cap' => 'edit_posts', 'input' => array( 'required' => array( 'history_id' ), 'properties' => array( 'history_id' => array( 'type' => 'string', 'pattern' => '^op_[0-9]{12}_[a-f0-9]{6}$' ), ), ), 'run' => static function ( array $a ) { $op = (string) $a['history_id']; $owner = History::op_user( $op ); if ( ! $owner ) { return new \WP_Error( 'not_found', 'This change is no longer in the history.' ); } if ( ! current_user_can( 'manage_options' ) && get_current_user_id() !== $owner ) { return new \WP_Error( 'forbidden', 'You can only view your own changes.' ); } return array( 'history_id' => $op, 'items' => \HandyMCP\Support\ChangeView::for_ai( $op ) ); }, ) ); $r->add( array( 'name' => 'history_list', 'group' => 'assistant', 'title' => 'List recent changes (undo history)', 'description' => 'Changes made through MCP, newest first. Each has a history_id; changes from the same conversation share a task_id. Pass post_id for one page.', 'cap' => 'edit_posts', 'input' => array( 'properties' => array( 'post_id' => array( 'type' => 'integer', 'minimum' => 1 ), 'limit' => array( 'type' => 'integer', 'minimum' => 1, 'maximum' => 100, 'default' => 20 ), ), ), 'run' => static function ( array $a ) { $limit = (int) $a['limit']; $post = (int) ( $a['post_id'] ?? 0 ); if ( current_user_can( 'manage_options' ) ) { $rows = History::operations( $limit, 0, $post ); } else { // Non-administrators only see their own changes. $rows = array(); $me = get_current_user_id(); for ( $offset = 0; $offset < 1000 && count( $rows ) < $limit; $offset += 100 ) { $page = History::operations( 100, $offset, $post ); foreach ( $page as $row ) { if ( (int) ( $row['user_id'] ?? 0 ) === $me ) { $rows[] = $row; } } if ( count( $page ) < 100 ) { break; } } $rows = array_slice( $rows, 0, $limit ); } return array( 'changes' => array_map( static function ( $r ) { return array( 'history_id' => $r['op_id'], 'task_id' => $r['task'] ?? '', 'when' => $r['created_at'], 'tool' => $r['tool'], 'touched' => mb_substr( (string) $r['labels'], 0, 300 ), 'undone' => (bool) $r['reverted_at'] ); }, $rows ), ); }, ) ); $r->add( array( 'name' => 'history_revert', 'group' => 'assistant', 'title' => 'Undo changes', 'description' => 'Undo one change (history_id), every change from one conversation (task_id), or every change made to one page or post (post_id, optionally only since a time). Posts, pages, builder layouts, templates, media, menus and terms are restored. The last 20 changes are kept. The undo is recorded too, so it can be redone by undoing it.', 'write' => true, 'cap' => 'edit_posts', 'input' => array( 'properties' => array( 'history_id' => array( 'type' => 'string', 'pattern' => '^op_[0-9]{12}_[a-f0-9]{6}$' ), 'task_id' => array( 'type' => 'string', 'pattern' => '^[a-z0-9-]{4,40}$' ), 'post_id' => array( 'type' => 'integer', 'minimum' => 1 ), 'since' => array( 'type' => 'string', 'description' => 'With post_id: only undo changes after this time (YYYY-MM-DD HH:MM:SS, UTC).' ), ), ), 'run' => static function ( array $a, $identity = null ) { global $wpdb; if ( ! empty( $a['history_id'] ) ) { $ops = array( $a['history_id'] ); } elseif ( ! empty( $a['task_id'] ) || ! empty( $a['post_id'] ) ) { $ops = History::ops_for( (string) ( $a['task_id'] ?? '' ), (int) ( $a['post_id'] ?? 0 ), (string) ( $a['since'] ?? '' ) ); if ( ! $ops ) { return new \WP_Error( 'nothing_to_undo', 'No changes found to undo.' ); } } else { return new \WP_Error( 'missing_target', 'Pass history_id, task_id or post_id.' ); } $t = \HandyMCP\Database\Installer::table( 'history' ); $in = implode( ',', array_fill( 0, count( $ops ), '%s' ) ); $owners = array_map( 'intval', (array) $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT user_id FROM %i WHERE op_id IN ($in)", array_merge( array( $t ), $ops ) ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- custom table; $in is placeholders built from count($ops). if ( ! current_user_can( 'manage_options' ) && array_diff( $owners, array( get_current_user_id() ) ) ) { return new \WP_Error( 'forbidden', 'Only an administrator can undo another user\'s change.' ); } // Free records posts, pages, media, menus and terms only. Other changes were made with Pro. $kinds = array_unique( (array) $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT kind FROM %i WHERE op_id IN ($in)", array_merge( array( $t ), $ops ) ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- custom table; $in is placeholders built from count($ops). if ( array_diff( $kinds, array( 'post', 'created_post', 'term', 'created_term' ) ) ) { return new \WP_Error( 'not_supported', 'Some of these changes were made with HandyMCP Pro (settings, files, plugins or users) and can only be undone there.' ); } return 1 === count( $ops ) ? History::revert( $ops[0] ) : History::revert_many( $ops ); }, ) ); // ChatGPT connectors (deep research / company knowledge) call these two by name. $r->add( array( 'name' => 'search', 'group' => 'assistant', 'title' => 'Search site content', 'description' => 'Search published and draft posts, pages and products. Returns ids for fetch.', 'cap' => 'edit_posts', 'input' => array( 'required' => array( 'query' ), 'properties' => array( 'query' => array( 'type' => 'string' ) ) ), 'run' => static function ( array $a ) { $q = new \WP_Query( array( 's' => $a['query'], 'post_type' => array_values( get_post_types( array( 'public' => true ) ) ), 'post_status' => array( 'publish', 'draft', 'private', 'pending' ), 'perm' => 'readable', 'posts_per_page' => 10 ) ); $out = array(); foreach ( $q->posts as $p ) { if ( ! current_user_can( 'read_post', $p->ID ) ) { continue; } $out[] = array( 'id' => (string) $p->ID, 'title' => get_the_title( $p ), 'url' => get_permalink( $p ) ); } return array( 'results' => $out ); }, ) ); $r->add( array( 'name' => 'fetch', 'group' => 'assistant', 'title' => 'Fetch a post or page', 'description' => 'Full text of a post, page or product by id (from search), or by its address on this site (url).', 'cap' => 'edit_posts', 'input' => array( 'properties' => array( 'id' => array( 'type' => 'string' ), 'url' => array( 'type' => 'string', 'maxLength' => 2000 ) ) ), 'run' => static function ( array $a ) { $id = (int) ( $a['id'] ?? 0 ); if ( ! $id && ! empty( $a['url'] ) ) { $host = strtolower( (string) wp_parse_url( (string) $a['url'], PHP_URL_HOST ) ); if ( '' !== $host && strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ) !== $host ) { return new \WP_Error( 'not_found', 'That address is not on this site.' ); } $id = url_to_postid( (string) $a['url'] ); if ( ! $id && '/' === untrailingslashit( (string) wp_parse_url( (string) $a['url'], PHP_URL_PATH ) ) . '/' ) { $id = (int) get_option( 'page_on_front' ); } } if ( ! $id ) { return new \WP_Error( 'not_found', 'Pass the id from search, or the address of a page on this site.' ); } $p = Util::post( $id, null, 'read' ); if ( is_wp_error( $p ) ) { return $p; } // Form configs and stored messages (Flamingo, form plugins), non-public types and password-protected posts need edit rights. $config = array( 'flamingo_inbound', 'flamingo_outbound', 'flamingo_contact', 'wpcf7_contact_form', 'wpforms', 'metform-form', 'metform-entry', 'jet-form-builder', 'jet-fb-records', 'forminator_forms', 'nf_sub', 'frm_form_actions' ); if ( ( ! is_post_type_viewable( $p->post_type ) || in_array( $p->post_type, $config, true ) || '' !== (string) $p->post_password ) && ! current_user_can( 'edit_post', $p->ID ) ) { return new \WP_Error( 'forbidden', 'You cannot read this item.' ); } $text = trim( preg_replace( '/\s+/', ' ', wp_strip_all_tags( do_blocks( $p->post_content ) ) ) ); if ( '' === $text && 'builder' === get_post_meta( $p->ID, '_elementor_edit_mode', true ) ) { $text = wp_strip_all_tags( (string) get_post_meta( $p->ID, '_elementor_data', true ) ); } return array( 'id' => (string) $p->ID, 'title' => get_the_title( $p ), 'text' => mb_substr( $text, 0, 20000 ), 'url' => get_permalink( $p ), 'metadata' => array( 'type' => $p->post_type, 'status' => $p->post_status, 'builder' => Util::builder_of( $p->ID ), 'modified' => Util::modified( $p ) ) ); }, ) ); } /** * Replace "$N.path" strings with values from earlier results. * * @param mixed $value Value. * @param array> $results Results. * @return mixed */ private static function substitute( $value, array $results ) { if ( is_array( $value ) ) { foreach ( $value as $k => $v ) { $value[ $k ] = self::substitute( $v, $results ); } return $value; } if ( is_string( $value ) && preg_match( '/^\$(\d+)\.([A-Za-z0-9_.]+)$/', $value, $m ) && isset( $results[ (int) $m[1] ] ) ) { $cur = $results[ (int) $m[1] ]; foreach ( explode( '.', $m[2] ) as $part ) { if ( is_array( $cur ) && array_key_exists( $part, $cur ) ) { $cur = $cur[ $part ]; } else { return $value; } } return $cur; } return $value; } }