register_mcp_hooks(); } elseif (method_exists($this, 'register_mcp_routes')) { $this->register_mcp_hooks(); } if (method_exists($this, 'init_mcp_oauth')) { $this->init_mcp_oauth(); } } public function register_mcp_hooks() { add_action('admin_menu', [$this, 'add_mcp_menu']); add_action('rest_api_init', [$this, 'init_mcp_routes']); add_filter('determine_current_user', [$this, 'determine_current_user'], 20); add_action('admin_post_aiutoma_download_mcpb', [$this, 'download_mcpb']); add_action('admin_post_aiutoma_download_mcp_json', [$this, 'download_mcp_json']); add_action('admin_post_aiutoma_download_log', [$this, 'download_log_file']); add_action('admin_enqueue_scripts', [$this, 'enqueue_mcp_scripts']); // Support for official WordPress MCP Adapter via mcp_adapter_init hook add_action('mcp_adapter_init', [$this, 'register_mcp_adapter_server'], 50); if (class_exists('\WP\MCP\Core\McpAdapter')) { \WP\MCP\Core\McpAdapter::instance(); } else { add_action('plugins_loaded', function () { if (class_exists('\WP\MCP\Core\McpAdapter')) { \WP\MCP\Core\McpAdapter::instance(); } }, 30); } } public function enqueue_mcp_scripts($hook) { if ($hook === 'aiutoma_page_aiutoma-mcp') { wp_enqueue_style('aiutoma-mcp-style', AIUTOMA_URL . 'modules/mcp/assets/css/mcp.css', [], filemtime(AIUTOMA_PATH . 'modules/mcp/assets/css/mcp.css')); wp_enqueue_script('aiutoma-mcp-script', AIUTOMA_URL . 'modules/mcp/assets/js/mcp.js', [], filemtime(AIUTOMA_PATH . 'modules/mcp/assets/js/mcp.js'), true); } } public function add_mcp_menu() { add_submenu_page( 'aiutoma', __('MCP & GPT Integrations', 'aiutoma'), __('MCP & GPT', 'aiutoma'), 'manage_options', 'aiutoma-mcp', [$this, 'aiutoma_mcp_page_html'] ); } public function init_mcp_routes() { // MCP JSON-RPC Endpoint (Stateless) register_rest_route('aiutoma/v1', '/mcp', [ 'methods' => 'POST', 'callback' => [$this, 'handle_mcp_request'], 'permission_callback' => [$this, 'mcp_permission_check'] ]); // OpenAPI Schema Endpoint (for GPT Custom Actions) register_rest_route('aiutoma/v1', '/openapi.json', [ 'methods' => 'GET', 'callback' => [$this, 'generate_openapi_schema'], 'permission_callback' => '__return_true' ]); // OpenAPI Tool Execution Endpoint register_rest_route('aiutoma/v1', '/mcp/tool/(?P[a-zA-Z0-9_-]+)', [ 'methods' => 'POST', 'callback' => [$this, 'handle_openapi_tool_call'], 'permission_callback' => [$this, 'mcp_permission_check'] ]); // Webhook Receiver Endpoint for AI Logs register_rest_route('aiutoma/v1', '/mcp/webhook', [ 'methods' => 'POST', 'callback' => [$this, 'handle_webhook_request'], 'permission_callback' => [$this, 'mcp_webhook_permission_check'] ]); } /** * Determine the current user for REST API requests. * Integrates custom bearer tokens (OAuth 2.1) and static API keys into WordPress core's * determine_current_user filter pipeline so user context is established by WordPress core * without calling wp_set_current_user() directly. * * @param int|false $user_id User ID if already determined, or false/0. * @return int|false Determined user ID or original value. */ public function determine_current_user($user_id) { if (!empty($user_id)) { return $user_id; } // 1. Check Bearer Token (OAuth 2.1 for MCP clients like Claude Desktop) $auth_header = ''; if (!empty($_SERVER['HTTP_AUTHORIZATION'])) { $auth_header = sanitize_text_field(wp_unslash($_SERVER['HTTP_AUTHORIZATION'])); } elseif (!empty($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) { $auth_header = sanitize_text_field(wp_unslash($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])); } elseif (function_exists('apache_request_headers')) { $headers = apache_request_headers(); if (!empty($headers['Authorization'])) { $auth_header = sanitize_text_field($headers['Authorization']); } elseif (!empty($headers['authorization'])) { $auth_header = sanitize_text_field($headers['authorization']); } } if (!empty($auth_header) && preg_match('/^Bearer\s+(.+)$/i', $auth_header, $matches)) { $token = trim($matches[1]); if (method_exists($this, 'validate_token')) { $oauth_token = $this->validate_token($token); if (!empty($oauth_token) && !empty($oauth_token['user_id'])) { return (int) $oauth_token['user_id']; } } } // 2. Check Static MCP API Key (if provided and configured) $api_key = ''; if (!empty($_SERVER['HTTP_X_MCP_API_KEY'])) { $api_key = sanitize_text_field(wp_unslash($_SERVER['HTTP_X_MCP_API_KEY'])); } elseif (function_exists('apache_request_headers')) { $headers = apache_request_headers(); if (!empty($headers['X-MCP-API-Key'])) { $api_key = sanitize_text_field($headers['X-MCP-API-Key']); } elseif (!empty($headers['x-mcp-api-key'])) { $api_key = sanitize_text_field($headers['x-mcp-api-key']); } } $saved_token = get_option('aiutoma_mcp_token', ''); if (!empty($saved_token) && is_string($api_key) && !empty($api_key) && hash_equals($saved_token, $api_key)) { $acting_user_id = (int) apply_filters('aiutoma_mcp_acting_user_id', (int) get_option('aiutoma_mcp_acting_user', 0)); if ($acting_user_id > 0) { $user = get_userdata($acting_user_id); if ($user && $user->has_cap('manage_options')) { return $acting_user_id; } } } return $user_id; } public function mcp_permission_check(\WP_REST_Request $request) { if (apply_filters('aiutoma_mcp_permission_check', current_user_can('manage_options'), $request)) { return true; } return new \WP_Error( 'rest_forbidden', __('Sorry, you are not allowed to access this resource. Please authenticate with an administrator account, WordPress Application Password, or valid OAuth token.', 'aiutoma'), ['status' => rest_authorization_required_code()] ); } /** * Register Aiutoma server on the official WordPress MCP Adapter if available. * * @param \WP\MCP\Core\McpAdapter $adapter MCP Adapter instance. */ public function register_mcp_adapter_server($adapter) { if (!is_object($adapter) || !method_exists($adapter, 'create_server')) { return; } if (method_exists($adapter, 'get_server') && $adapter->get_server('aiutoma')) { return; } $aiutoma_abilities = function_exists('wp_get_abilities') ? wp_get_abilities() : []; $aiutoma_abilities = apply_filters('aiutoma/abilities', $aiutoma_abilities); $tools = []; foreach ($aiutoma_abilities as $ability) { $name = $ability->get_name(); if (!self::is_ability_allowed_for_mcp($name)) { continue; } $meta = $ability->get_meta(); if (isset($meta['mcp']['public']) && $meta['mcp']['public'] === false) { continue; } $tools[] = $name; } if (empty($tools)) { return; } $transports = [ \WP\MCP\Transport\HttpTransport::class ]; $error_handler = class_exists('\WP\MCP\Infrastructure\ErrorHandling\ErrorLogMcpErrorHandler') ? \WP\MCP\Infrastructure\ErrorHandling\ErrorLogMcpErrorHandler::class : null; $observability = class_exists('\WP\MCP\Infrastructure\Observability\NullMcpObservabilityHandler') ? \WP\MCP\Infrastructure\Observability\NullMcpObservabilityHandler::class : null; $server_id = 'aiutoma'; $server_namespace = 'aiutoma/v1'; $server_route = 'mcp-adapter'; $server_name = 'Aiutoma MCP Server'; $server_desc = 'Aiutoma abilities and tools via official WordPress MCP Adapter'; $server_version = defined('AIUTOMA_VERSION') ? AIUTOMA_VERSION : '1.0.0'; try { $adapter->create_server( $server_id, $server_namespace, $server_route, $server_name, $server_desc, $server_version, $transports, $error_handler, $observability, $tools, [], // resources [], // prompts [$this, 'mcp_adapter_permission_check'] ); } catch (\Throwable $e) { if (defined('WP_DEBUG') && WP_DEBUG) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log('[Aiutoma MCP] Failed to register server with MCP Adapter: ' . $e->getMessage()); } } } /** * Check if an ability is safe and permitted for remote MCP / OpenAPI execution. * In accordance with WordPress.org guidelines, remote administrative actions (e.g. managing plugins, * themes, users, modifying site code, or sensitive options) are strictly prohibited. * Remote execution is strictly limited to non-administrative work (content, media, comments, * WooCommerce catalog/orders, WPML translations, search, and cache flushing). * * @param string $ability_name Ability identifier. * @return bool */ public static function is_ability_allowed_for_mcp($ability_name) { if (empty($ability_name) || !is_string($ability_name)) { return false; } // Explicitly block any administrative, user, filesystem, or core modification abilities $blocked_patterns = [ 'manage-plugins', 'manage-themes', 'manage-users', 'manage-options', 'manage-system', 'manage-debug', 'flush_rewrite_rules', 'read-file', 'list-directory', 'run-wp-cli', 'execute_php', 'execute-php', 'db-query', 'modify-file', 'patch-file', 'grep-files', 'inspect-hooks', 'scaffold-theme', ]; foreach ($blocked_patterns as $blocked) { if (strpos($ability_name, $blocked) !== false) { return (bool) apply_filters('aiutoma_mcp_allow_blocked_ability', false, $ability_name); } } return true; } /** * Permission callback for the official MCP Adapter transport. * * @param mixed $request REST request object. * @return bool */ public function mcp_adapter_permission_check($request = null) { return (bool) apply_filters('aiutoma_mcp_permission_check', current_user_can('manage_options'), $request); } /** * Format formal MCP annotations (readonly, destructive, idempotent) matching MCP 2025-11-25 specifications. * * @param \WP_Ability|object $ability Ability instance. * @return array */ public static function format_mcp_annotations($ability) { $meta = (is_object($ability) && method_exists($ability, 'get_meta')) ? $ability->get_meta() : []; $raw_annotations = (isset($meta['annotations']) && is_array($meta['annotations'])) ? $meta['annotations'] : []; $name = (is_object($ability) && method_exists($ability, 'get_name')) ? $ability->get_name() : ''; $action = basename($name); // Determine readonly if (isset($raw_annotations['readonly'])) { $readonly = (bool) $raw_annotations['readonly']; } elseif (isset($raw_annotations['readOnlyHint'])) { $readonly = (bool) $raw_annotations['readOnlyHint']; } else { $readonly = (bool) preg_match('/^(get|list|read|search|view|check|inspect|discover)-?/', $action); } // Determine destructive if (isset($raw_annotations['destructive'])) { $destructive = (bool) $raw_annotations['destructive']; } elseif (isset($raw_annotations['destructiveHint'])) { $destructive = (bool) $raw_annotations['destructiveHint']; } else { $destructive = (bool) preg_match('/^(delete|remove|trash|purge|destroy|drop)-?/', $action); } // Determine idempotent if (isset($raw_annotations['idempotent'])) { $idempotent = (bool) $raw_annotations['idempotent']; } elseif (isset($raw_annotations['idempotentHint'])) { $idempotent = (bool) $raw_annotations['idempotentHint']; } else { $idempotent = $readonly || (bool) preg_match('/^(update|set|delete|remove|trash|purge)-?/', $action); } $annotations = [ 'readonly' => $readonly, 'destructive' => $destructive, 'idempotent' => $idempotent, // MCP 2025-11-25 ToolAnnotations spec fields (*Hint) 'readOnlyHint' => $readonly, 'destructiveHint' => $destructive, 'idempotentHint' => $idempotent, ]; if (isset($raw_annotations['openWorldHint'])) { $annotations['openWorldHint'] = (bool) $raw_annotations['openWorldHint']; } if (!empty($raw_annotations['title'])) { $annotations['title'] = (string) $raw_annotations['title']; } else { $label = (is_object($ability) && method_exists($ability, 'get_label')) ? $ability->get_label() : ''; if (!empty($label)) { $annotations['title'] = $label; } } return $annotations; } public function handle_mcp_request(\WP_REST_Request $request) { $body = $request->get_json_params(); if (empty($body)) { $body = json_decode($request->get_body(), true); } $is_batch = is_array($body) && isset($body[0]); $requests = $is_batch ? $body : [$body]; $responses = []; $aiutoma_abilities = function_exists('wp_get_abilities') ? wp_get_abilities() : []; $aiutoma_abilities = apply_filters('aiutoma/abilities', $aiutoma_abilities); foreach ($requests as $req) { $id = isset($req['id']) ? $req['id'] : null; $method = isset($req['method']) ? $req['method'] : ''; $params = isset($req['params']) ? $req['params'] : []; $response = ['jsonrpc' => '2.0']; if ($id !== null) { $response['id'] = $id; } try { if ($method === 'initialize') { $response['result'] = [ 'protocolVersion' => '2024-11-05', 'capabilities' => [ 'tools' => (object)[ 'listChanged' => false ], 'resources' => (object)[ 'subscribe' => false, 'listChanged' => false ], 'prompts' => (object)[ 'listChanged' => false ] ], 'serverInfo' => [ 'name' => 'aiutoma', 'version' => defined('AIUTOMA_VERSION') ? AIUTOMA_VERSION : '1.0.0' ] ]; } elseif ($method === 'notifications/initialized') { $response['result'] = (object)[]; } elseif ($method === 'ping') { $response['result'] = (object)[]; } elseif ($method === 'tools/list') { $tools = [ [ 'name' => 'aiutoma_discover_abilities', 'description' => 'List all available WordPress abilities (tools) that can be executed. Use this first to find what you can do.', 'inputSchema' => [ 'type' => 'object', 'properties' => (object)[] ], 'annotations' => [ 'readonly' => true, 'destructive' => false, 'idempotent' => true, 'readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'title' => 'Discover Abilities' ] ], [ 'name' => 'aiutoma_get_ability_schema', 'description' => 'Get the JSON schema (required arguments) for a specific ability before executing it.', 'inputSchema' => [ 'type' => 'object', 'properties' => [ 'ability_name' => [ 'type' => 'string', 'description' => 'The name of the ability to get the schema for (e.g. aiutoma/create-post or aiutoma-manage-posts). Can also be passed as \'ability\' or \'name\'.' ], 'ability' => [ 'type' => 'string', 'description' => 'Alias for ability_name.' ] ] ], 'annotations' => [ 'readonly' => true, 'destructive' => false, 'idempotent' => true, 'readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'title' => 'Get Ability Schema' ] ], [ 'name' => 'aiutoma_execute_ability', 'description' => 'Execute a specific WordPress ability with the required parameters.', 'inputSchema' => [ 'type' => 'object', 'properties' => [ 'ability_name' => [ 'type' => 'string', 'description' => 'The name of the ability to execute (e.g. aiutoma/create-post or aiutoma-manage-posts). Can also be passed as \'ability\' or \'name\'.' ], 'ability' => [ 'type' => 'string', 'description' => 'Alias for ability_name.' ], 'parameters' => [ 'type' => 'object', 'description' => 'The parameters required by the ability schema. Can also be passed as \'args\' or \'arguments\'.' ], 'args' => [ 'type' => 'object', 'description' => 'Alias for parameters.' ] ] ], 'annotations' => [ 'readonly' => false, 'destructive' => false, 'idempotent' => false, 'readOnlyHint' => false, 'destructiveHint' => false, 'idempotentHint' => false, 'openWorldHint' => true, 'title' => 'Execute Ability' ] ] ]; // Expose direct tools matching MCP naming convention with annotations foreach ($aiutoma_abilities as $ability) { $ab_name = $ability->get_name(); if (!self::is_ability_allowed_for_mcp($ab_name)) { continue; } $meta = $ability->get_meta(); if (isset($meta['mcp']['public']) && $meta['mcp']['public'] === false) { continue; } $sanitized_name = str_replace('/', '-', $ab_name); $tools[] = [ 'name' => $sanitized_name, 'title' => $ability->get_label() ?: $sanitized_name, 'description' => $ability->get_description() ?: '', 'inputSchema' => $ability->get_input_schema() ?: [ 'type' => 'object', 'properties' => (object)[] ], 'annotations' => self::format_mcp_annotations($ability) ]; } $tools_result = $tools; // Optional search/filter by keyword $search_filter = isset($params['search']) ? trim((string)$params['search']) : (isset($params['filter']) ? trim((string)$params['filter']) : ''); if ($search_filter !== '') { $search_lower = strtolower($search_filter); $tools_result = array_values(array_filter($tools_result, function($t) use ($search_lower) { $name = strtolower($t['name'] ?? ''); $title = strtolower($t['title'] ?? ''); $desc = strtolower($t['description'] ?? ''); return strpos($name, $search_lower) !== false || strpos($title, $search_lower) !== false || strpos($desc, $search_lower) !== false; })); } // Optional filter by category or prefix (e.g. 'gutenberg', 'scf', 'aiutoma', 'rank-math') $category_filter = isset($params['category']) ? trim((string)$params['category']) : (isset($params['prefix']) ? trim((string)$params['prefix']) : ''); if ($category_filter !== '') { $cat_clean = strtolower(rtrim($category_filter, '-/_')); $cat_prefix = $cat_clean . '-'; $tools_result = array_values(array_filter($tools_result, function($t) use ($cat_clean, $cat_prefix) { $name = strtolower($t['name'] ?? ''); return strpos($name, $cat_prefix) === 0 || $name === $cat_clean; })); } // Optional compact/summary mode (strips verbose schemas to save tokens) if (!empty($params['compact']) || !empty($params['summary'])) { $tools_result = array_map(function($t) { return [ 'name' => $t['name'], 'title' => $t['title'] ?? $t['name'], 'description' => $t['description'] ?? '', 'inputSchema' => [ 'type' => 'object', 'properties' => (object)[] ], 'annotations' => $t['annotations'] ?? [] ]; }, $tools_result); } // Optional pagination via cursor or limit $limit = isset($params['limit']) ? max(1, intval($params['limit'])) : 0; $cursor = isset($params['cursor']) ? max(0, intval($params['cursor'])) : 0; if ($limit > 0) { $total_matched = count($tools_result); $page_tools = array_slice($tools_result, $cursor, $limit); $result_payload = ['tools' => $page_tools]; if ($cursor + $limit < $total_matched) { $result_payload['nextCursor'] = (string)($cursor + $limit); } $response['result'] = $result_payload; } else { $response['result'] = ['tools' => $tools_result]; } } elseif ($method === 'tools/call') { $name = isset($params['name']) ? $params['name'] : ''; $args = isset($params['arguments']) ? $params['arguments'] : []; $agent_name = $request->get_header('authorization') ? 'OAuth Client (MCP)' : 'Static Client (MCP)'; $resolve_ability = function($tool_input_name) use ($aiutoma_abilities) { if (empty($tool_input_name) || !is_string($tool_input_name)) { return [null, '']; } if (function_exists('wp_has_ability') && wp_has_ability($tool_input_name)) { return [wp_get_ability($tool_input_name), $tool_input_name]; } $clean_input_hyphen = str_replace('_', '-', $tool_input_name); $clean_input_under = str_replace('-', '_', $tool_input_name); foreach ($aiutoma_abilities as $ab) { $ab_name = $ab->get_name(); $ab_hyphen = str_replace('/', '-', $ab_name); $ab_under = str_replace(['/', '-'], '_', $ab_name); if ($ab_name === $tool_input_name || $ab_hyphen === $tool_input_name || $ab_hyphen === $clean_input_hyphen || $ab_under === $clean_input_under) { return [$ab, $ab_name]; } if (strpos($ab_name, '/') !== false) { $short_name = explode('/', $ab_name, 2)[1]; $short_hyphen = str_replace('_', '-', $short_name); $short_under = str_replace('-', '_', $short_name); if ($short_name === $tool_input_name || $short_hyphen === $clean_input_hyphen || $short_under === $clean_input_under) { return [$ab, $ab_name]; } } } $candidate = str_replace('_', '/', $tool_input_name); if (function_exists('wp_has_ability') && wp_has_ability($candidate)) { return [wp_get_ability($candidate), $candidate]; } $candidate_dash = preg_replace('/-/', '/', $tool_input_name, 1); if (function_exists('wp_has_ability') && wp_has_ability($candidate_dash)) { return [wp_get_ability($candidate_dash), $candidate_dash]; } // Check standard alias map $alias_map = [ 'core/content-query' => 'core/read-content', 'core/read-content' => 'core/content-query', 'core/users-query' => 'core/read-users', 'core/read-users' => 'core/users-query', 'core/settings-get' => 'core/read-settings', 'core/read-settings' => 'core/settings-get', 'ai/get-post-details' => 'core/content-query', ]; $check_candidates = [$tool_input_name, $candidate, $candidate_dash]; foreach ($check_candidates as $ck) { if (isset($alias_map[$ck]) && function_exists('wp_has_ability') && wp_has_ability($alias_map[$ck])) { return [wp_get_ability($alias_map[$ck]), $alias_map[$ck]]; } } return [null, $tool_input_name]; }; if ($name === 'aiutoma_discover_abilities') { $list = []; foreach ($aiutoma_abilities as $ability) { $ab_name = $ability->get_name(); if (!self::is_ability_allowed_for_mcp($ab_name)) { continue; } $meta = $ability->get_meta(); if (isset($meta['mcp']['public']) && $meta['mcp']['public'] === false) { continue; } $list[] = [ 'name' => $ab_name, 'label' => $ability->get_label() ?: $ab_name, 'description' => $ability->get_description() ?: '', 'annotations' => self::format_mcp_annotations($ability) ]; } $result_data = [ 'content' => [ [ 'type' => 'text', 'text' => wp_json_encode(['abilities' => $list], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ] ] ]; $this->write_file_log($request, $agent_name, 'Discover Abilities', $result_data, $args); $response['result'] = $result_data; } elseif ($name === 'aiutoma_get_ability_schema') { $input_ability_name = isset($args['ability_name']) ? (string)$args['ability_name'] : (isset($args['ability']) ? (string)$args['ability'] : (isset($args['name']) ? (string)$args['name'] : (isset($args['tool_name']) ? (string)$args['tool_name'] : (isset($args['tool']) ? (string)$args['tool'] : '')))); list($ability, $ability_name) = $resolve_ability($input_ability_name); if ($ability) { if (!self::is_ability_allowed_for_mcp($ability_name)) { throw new \Exception("Ability not permitted in this context: {$ability_name}"); } $meta = $ability->get_meta(); if (isset($meta['mcp']['public']) && $meta['mcp']['public'] === false) { throw new \Exception("Ability not permitted in this context: {$ability_name}"); } $schema_data = [ 'name' => $ability->get_name(), 'label' => $ability->get_label() ?: $ability->get_name(), 'description' => $ability->get_description() ?: '', 'inputSchema' => $ability->get_input_schema() ?: ['type' => 'object', 'properties' => (object)[]], 'outputSchema' => $ability->get_output_schema() ?: (object)[], 'annotations' => self::format_mcp_annotations($ability) ]; $result_data = [ 'content' => [ [ 'type' => 'text', 'text' => wp_json_encode($schema_data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ] ] ]; $this->write_file_log($request, $agent_name, 'Get Schema: ' . $ability_name, $result_data, $args); $response['result'] = $result_data; } else { throw new \Exception("Ability not found: {$input_ability_name}"); } } elseif ($name === 'aiutoma_execute_ability') { $input_ability_name = isset($args['ability_name']) ? (string)$args['ability_name'] : (isset($args['ability']) ? (string)$args['ability'] : (isset($args['name']) ? (string)$args['name'] : (isset($args['tool_name']) ? (string)$args['tool_name'] : (isset($args['tool']) ? (string)$args['tool'] : '')))); list($ability, $ability_name) = $resolve_ability($input_ability_name); $ability_params = isset($args['parameters']) ? $args['parameters'] : (isset($args['args']) ? $args['args'] : (isset($args['arguments']) ? $args['arguments'] : (isset($args['params']) ? $args['params'] : []))); if ($ability) { if (!self::is_ability_allowed_for_mcp($ability_name)) { throw new \Exception("Ability not permitted in this context: {$ability_name}"); } $meta = $ability->get_meta(); if (isset($meta['mcp']['public']) && $meta['mcp']['public'] === false) { throw new \Exception("Ability not permitted in this context: {$ability_name}"); } $result = $ability->execute($ability_params); if (is_wp_error($result)) { $this->write_file_log($request, $agent_name, 'Tool Error: ' . $ability_name, ['error' => $result->get_error_message()], $ability_params); throw new \Exception($result->get_error_message()); } $result_data = [ 'content' => [ [ 'type' => 'text', 'text' => is_string($result) ? $result : wp_json_encode($result, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ] ] ]; $this->write_file_log($request, $agent_name, 'Tool Executed: ' . $ability_name, $result_data, $ability_params); $response['result'] = $result_data; } else { throw new \Exception("Ability not found: {$input_ability_name}"); } } else { // Direct tool calls (supports hyphenated, underscored, and slash names) list($ability, $real_name) = $resolve_ability($name); if ($ability) { if (!self::is_ability_allowed_for_mcp($real_name)) { throw new \Exception("Tool not permitted in this context: {$real_name}"); } $meta = $ability->get_meta(); if (isset($meta['mcp']['public']) && $meta['mcp']['public'] === false) { throw new \Exception("Ability not permitted in this context: {$real_name}"); } $result = $ability->execute($args); if (is_wp_error($result)) { $this->write_file_log($request, $agent_name, 'Tool Error: ' . $real_name, ['error' => $result->get_error_message()], $args); throw new \Exception($result->get_error_message()); } $result_data = [ 'content' => [ [ 'type' => 'text', 'text' => is_string($result) ? $result : wp_json_encode($result, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ] ] ]; $this->write_file_log($request, $agent_name, 'Tool Executed: ' . $real_name, $result_data, $args); $response['result'] = $result_data; } else { throw new \Exception("Tool not found: {$name}"); } } } elseif ($method === 'resources/list') { $all_skills = \Aiutoma\Modules\Ai\Ai::instance()->get_all_skills(); $resources = []; foreach ($all_skills as $skill) { $resources[] = [ 'uri' => 'aiutoma://skills/' . ($skill['slug'] ?? $skill['id']), 'name' => 'Skill: ' . ($skill['name'] ?? $skill['id']), 'description' => !empty($skill['description']) ? $skill['description'] : ('AI Skill instructions for ' . ($skill['slug'] ?? $skill['id'])), 'mimeType' => 'text/markdown' ]; } $response['result'] = ['resources' => $resources]; } elseif ($method === 'resources/read') { $uri = isset($params['uri']) ? $params['uri'] : ''; if (strpos($uri, 'aiutoma://skills/') === 0) { $skill_id = str_replace('aiutoma://skills/', '', $uri); $skill = \Aiutoma\Modules\Ai\Ai::instance()->get_skill_by_id($skill_id); if ($skill !== null && !empty($skill['content'])) { $response['result'] = [ 'contents' => [ [ 'uri' => $uri, 'mimeType' => 'text/markdown', 'text' => $skill['content'] ] ] ]; } else { throw new \Exception("Resource not found: {$uri}"); } } else { throw new \Exception("Unsupported resource URI: {$uri}"); } } elseif ($method === 'prompts/list') { $response['result'] = ['prompts' => []]; } else { throw new \Exception("Method not found", -32601); } } catch (\Exception $e) { $response['error'] = [ 'code' => $e->getCode() ?: -32000, 'message' => $e->getMessage() ]; } $responses[] = $response; } return rest_ensure_response($is_batch ? $responses : $responses[0]); } public function generate_openapi_schema(\WP_REST_Request $request) { $aiutoma_abilities = function_exists('wp_get_abilities') ? wp_get_abilities() : []; $aiutoma_abilities = apply_filters('aiutoma/abilities', $aiutoma_abilities); $site_url = get_site_url(); $schema = [ 'openapi' => '3.0.0', 'info' => [ 'title' => get_bloginfo('name') . ' API', 'description' => 'API for interacting with ' . get_bloginfo('name'), 'version' => '1.0.0' ], 'servers' => [ ['url' => $site_url . '/wp-json/aiutoma/v1'] ], 'paths' => [], 'components' => [ 'securitySchemes' => [ 'ApiKeyAuth' => [ 'type' => 'apiKey', 'in' => 'header', 'name' => 'X-MCP-API-Key' ] ] ], 'security' => [['ApiKeyAuth' => []]] ]; foreach ($aiutoma_abilities as $ability) { $name = $ability->get_name(); if (!self::is_ability_allowed_for_mcp($name)) { continue; } $meta = $ability->get_meta(); if (isset($meta['mcp']['public']) && $meta['mcp']['public'] === false) { continue; } $path_name = str_replace('/', '_', $name); $schema['paths']['/mcp/tool/' . $path_name] = [ 'post' => [ 'operationId' => $path_name, 'summary' => $ability->get_label() ?: $path_name, 'description' => $ability->get_description() ?: '', 'requestBody' => [ 'required' => true, 'content' => [ 'application/json' => [ 'schema' => $ability->get_input_schema() ?: ['type' => 'object'] ] ] ], 'responses' => [ '200' => [ 'description' => 'Successful response', 'content' => ['application/json' => ['schema' => ['type' => 'object']]] ] ] ] ]; } return rest_ensure_response($schema); } public function handle_openapi_tool_call(\WP_REST_Request $request) { $tool_name = $request->get_param('tool_name'); $name = str_replace('_', '/', $tool_name); $args = $request->get_json_params() ?: json_decode($request->get_body(), true); if (!self::is_ability_allowed_for_mcp($name)) { return new \WP_Error('forbidden', __('Remote execution of this ability is not allowed.', 'aiutoma'), ['status' => 403]); } $ability = function_exists('wp_get_ability') ? wp_get_ability($name) : null; if (!$ability) { return new \WP_Error('tool_not_found', 'Tool not found', ['status' => 404]); } $meta = method_exists($ability, 'get_meta') ? $ability->get_meta() : []; if (isset($meta['mcp']['public']) && $meta['mcp']['public'] === false) { return new \WP_Error('forbidden', __('Ability not permitted in this context.', 'aiutoma'), ['status' => 403]); } $agent_name = $request->get_header('authorization') ? 'OAuth Client (REST)' : 'Static Client (REST)'; $result = $ability->execute($args); if (is_wp_error($result)) { $this->write_file_log($request, $agent_name, 'Tool Error: ' . $name, ['error' => $result->get_error_message()], $args); return $result; } $this->write_file_log($request, $agent_name, 'Tool Executed: ' . $name, $result, $args); return rest_ensure_response(['success' => true, 'result' => $result]); } public function download_mcpb() { if (!current_user_can('manage_options')) { wp_die(esc_html__('You are not allowed to download this file.', 'aiutoma')); } check_admin_referer('aiutoma_download_mcpb'); if (!class_exists('ZipArchive')) { wp_die(esc_html__('ZipArchive extension is required.', 'aiutoma')); } $token = get_option('aiutoma_mcp_token'); if (empty($token)) { wp_die(esc_html__('No MCP token found. Please save the settings first.', 'aiutoma')); } $site_name = trim(get_bloginfo('name')); $mcp_name = sanitize_file_name($site_name ?: 'aiutoma'); $display_name = $site_name !== '' ? 'Aiutoma - ' . $site_name : 'Aiutoma'; $rest_url = rest_url('aiutoma/v1/mcp'); $manifest = [ 'manifest_version' => '0.3', 'name' => 'aiutoma-' . strtolower($mcp_name), 'display_name' => $display_name, 'version' => '1.0.0', 'description' => __('Aiutoma MCP Server (Static Token Bridge).', 'aiutoma'), 'author' => ['name' => 'Aiutoma'], 'server' => [ 'type' => 'node', 'entry_point' => 'server/index.js', 'mcp_config' => [ 'command' => 'npx', 'args' => ['-y', '@automattic/mcp-wordpress-remote@latest'], 'env' => [ 'WP_API_URL' => $rest_url, 'CUSTOM_HEADERS' => wp_json_encode(['X-MCP-API-Key' => $token]) ] ], ], ]; $manifest_json = wp_json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $stub = "// Aiutoma MCP Server Stub\n// This file is required by the .mcpb spec but the real server is launched via mcp_config.\n"; $tmp = wp_tempnam('aiutoma-mcpb'); $zip = new \ZipArchive(); if ($tmp === '' || $zip->open($tmp, \ZipArchive::OVERWRITE) !== true) { wp_die(esc_html__('Could not create bundle archive.', 'aiutoma')); } $zip->addFromString('manifest.json', $manifest_json); $zip->addFromString('server/index.js', $stub); $zip->close(); $host = wp_parse_url(home_url(), PHP_URL_HOST); $filename = 'aiutoma-' . sanitize_file_name($host ?: 'site') . '.mcpb'; nocache_headers(); header('Content-Type: application/octet-stream'); header('Content-Disposition: attachment; filename="' . $filename . '"'); header('Content-Length: ' . filesize($tmp)); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile readfile($tmp); wp_delete_file($tmp); exit; } public function download_mcp_json() { if (!current_user_can('manage_options')) { wp_die(esc_html__('You are not allowed to download this file.', 'aiutoma')); } check_admin_referer('aiutoma_download_mcp_json'); $token = get_option('aiutoma_mcp_token', ''); if (empty($token)) { $token = wp_generate_password(24, false); update_option('aiutoma_mcp_token', $token); } $site_name = trim(get_bloginfo('name')); $server_key = sanitize_title($site_name ?: 'aiutoma'); $rest_url = rest_url('aiutoma/v1/mcp'); $headers = []; if (apply_filters('aiutoma_mcp_include_basic_auth_header', true) && !empty($_GET['auth'])) { $auth_val = sanitize_text_field(wp_unslash($_GET['auth'])); if (preg_match('/^[a-zA-Z0-9+\/]+=*$/', $auth_val)) { $headers['Authorization'] = 'Basic ' . $auth_val; } } $headers['X-MCP-API-Key'] = $token; $config = [ 'servers' => [ $server_key => [ 'type' => 'http', 'url' => $rest_url, 'headers' => $headers, ], ], ]; nocache_headers(); header('Content-Type: application/json; charset=utf-8'); header('Content-Disposition: attachment; filename="mcp.json"'); echo wp_json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); exit; } private function get_log_dir() { if (class_exists('\Aiutoma\Modules\Ai\Ai') && method_exists('\Aiutoma\Modules\Ai\Ai', 'get_storage_dir')) { $base = \Aiutoma\Modules\Ai\Ai::get_storage_dir(); } else { $upload_dir = function_exists('wp_upload_dir') ? wp_upload_dir() : ['basedir' => '/tmp']; $base = $upload_dir['basedir'] . '/aiutoma'; } $log_dir = $base . '/logs/mcp'; if (@is_dir($log_dir) || (function_exists('wp_mkdir_p') && wp_mkdir_p($log_dir))) { return $log_dir; } return false; } public function download_log_file() { if (!current_user_can('manage_options')) { wp_die(esc_html__('You are not allowed to download this file.', 'aiutoma')); } $file = isset($_GET['file']) ? sanitize_file_name(wp_unslash($_GET['file'])) : ''; if (empty($file) || !preg_match('/^mcp-[a-zA-Z0-9_-]+\.log$/', $file)) { wp_die(esc_html__('Invalid log file.', 'aiutoma')); } if (!isset($_GET['_wpnonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_GET['_wpnonce'])), 'aiutoma_download_log_' . $file)) { wp_die(esc_html__('Security check failed.', 'aiutoma')); } $filepath = $this->get_log_dir() . '/' . $file; if (!file_exists($filepath)) { wp_die(esc_html__('Log file not found.', 'aiutoma')); } nocache_headers(); header('Content-Type: text/plain'); header('Content-Disposition: attachment; filename="' . $file . '"'); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile readfile($filepath); exit; } /** * Permission callback for MCP webhook receiver endpoint. * * @param \WP_REST_Request $request REST request. * @return true|\WP_Error True if authorized, WP_Error otherwise. */ public function mcp_webhook_permission_check(\WP_REST_Request $request) { $token = $request->get_header('X-MCP-API-Key') ?: $request->get_param('token'); $saved_token = get_option('aiutoma_mcp_webhook_token', ''); if (empty($saved_token)) { return new \WP_Error('unconfigured', __('Webhook token not configured. Please visit settings.', 'aiutoma'), ['status' => 503]); } if (!is_string($token) || !hash_equals($saved_token, $token)) { return new \WP_Error('unauthorized', __('Invalid webhook token.', 'aiutoma'), ['status' => 401]); } return true; } public function handle_webhook_request(\WP_REST_Request $request) { $body_raw = $request->get_body(); if (strlen($body_raw) > 524288) { // 512KB max return new \WP_Error('payload_too_large', 'Request body exceeds size limit', ['status' => 413]); } $body = $request->get_json_params(); if (empty($body)) { $body = json_decode($request->get_body(), true) ?: $request->get_params(); } $title = isset($body['title']) ? sanitize_text_field($body['title']) : 'AI Webhook'; $author = isset($body['agent']) ? sanitize_text_field($body['agent']) : (isset($body['tool']) ? sanitize_text_field($body['tool']) : 'Aiutoma'); $content_data = isset($body['content']) ? $body['content'] : $body; $this->write_file_log($request, $author, $title, ['success' => true], $content_data); return rest_ensure_response(['success' => true]); } private function redact_sensitive_keys(array &$data) { $sensitive = ['token', 'secret', 'authorization', 'api_key', 'password', 'cookie']; foreach ($data as $key => &$value) { if (is_array($value)) { $this->redact_sensitive_keys($value); } elseif (in_array(strtolower((string)$key), $sensitive, true)) { $value = '[REDACTED]'; } } } private function write_file_log(\WP_REST_Request $request, $agent, $title, $response_data, $request_body = null) { $log_dir = $this->get_log_dir(); if (!$log_dir) { return; } if (!file_exists($log_dir)) { wp_mkdir_p($log_dir); file_put_contents($log_dir . '/.htaccess', "Order deny,allow\nDeny from all"); file_put_contents($log_dir . '/index.php', 'get_headers(); if (isset($headers['authorization'])) $headers['authorization'] = ['[REDACTED]']; if (isset($headers['x-mcp-api-key'])) $headers['x-mcp-api-key'] = ['[REDACTED]']; if (isset($headers['cookie'])) $headers['cookie'] = ['[REDACTED]']; $req_data = $request_body !== null ? $request_body : $request->get_params(); if (is_array($req_data)) { $this->redact_sensitive_keys($req_data); } $log_entry = [ 'session_id' => $session_id, 'timestamp' => gmdate('Y-m-d\TH:i:s\Z'), 'who' => $agent, 'what' => $title, 'where' => $request->get_route(), 'how' => $request->get_method(), 'headers' => $headers, 'request' => $req_data, 'response' => $response_data, ]; $log_line = wp_json_encode($log_entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; file_put_contents($log_file, $log_line, FILE_APPEND | LOCK_EX); } public function aiutoma_mcp_page_html() { if (isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['aiutoma_mcp_acting_user']) && check_admin_referer('aiutoma_mcp_save_settings')) { update_option('aiutoma_mcp_acting_user', intval(wp_unslash($_POST['aiutoma_mcp_acting_user']))); echo '

' . esc_html__('Settings saved.', 'aiutoma') . '

'; } $token = get_option('aiutoma_mcp_token', wp_generate_password(24, false)); if (empty(get_option('aiutoma_mcp_token'))) { update_option('aiutoma_mcp_token', $token); } $webhook_token = get_option('aiutoma_mcp_webhook_token', wp_generate_password(32, false)); if (empty(get_option('aiutoma_mcp_webhook_token'))) { update_option('aiutoma_mcp_webhook_token', $webhook_token); } $acting_user = get_option('aiutoma_mcp_acting_user', ''); $current_user_id = get_current_user_id(); $selected_prompt_user_id = (int) apply_filters('aiutoma_mcp_selected_user_id', $current_user_id); $current_user = wp_get_current_user(); $current_user_login = ($current_user && !empty($current_user->user_login)) ? $current_user->user_login : 'user'; $selected_user = get_userdata($selected_prompt_user_id); $selected_user_login = ($selected_user && !empty($selected_user->user_login)) ? $selected_user->user_login : $current_user_login; $has_application_password = false; if (class_exists('\WP_Application_Passwords') && $current_user_id) { $user_passwords = \WP_Application_Passwords::get_user_application_passwords($current_user_id); $has_application_password = !empty($user_passwords); } $user_edit_url = get_edit_user_link($current_user_id); if (!$user_edit_url) { $user_edit_url = admin_url('profile.php'); } $app_pass_url = $user_edit_url . '#application-passwords-section'; $prompt_users = get_users(['capability' => 'manage_options']); if (empty($prompt_users)) { $prompt_users = get_users(['role' => 'administrator']); } $found_current = false; foreach ($prompt_users as $u) { if ($u->ID === $current_user_id) { $found_current = true; break; } } if (!$found_current && $current_user && $current_user->exists()) { array_unshift($prompt_users, $current_user); } ?>


You are managing a WordPress site that exposes an MCP Server over REST API. You must communicate with it by using your terminal/command tools to execute `curl` requests.

Use the JSON-RPC method `tools/list` to discover capabilities, and `tools/call` to execute them.

Endpoint:
MCP Adapter Endpoint:
' . esc_html__('Authentication Options:', 'aiutoma') . '
' . "\n" . '- ' . esc_html__('WordPress Application Password (Standard):', 'aiutoma') . ' Authorization: Basic <base64(' . esc_html($selected_user_login) . ':app_password)> (or curl -u "' . esc_html($selected_user_login) . ':your_application_password")
' . "\n" . '- ' . esc_html__('API Key:', 'aiutoma') . ' X-MCP-API-Key: ' . esc_html($token) . ''; echo wp_kses_post(apply_filters('aiutoma_mcp_prompt_auth_section', $default_auth_content, $token, $selected_user_login)); ?>

prefix . 'aiutoma_oauth_clients'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $clients_table)) === $clients_table) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $apps = $wpdb->get_results("SELECT * FROM `{$clients_table}` ORDER BY created DESC"); if (empty($apps)) { echo '

' . esc_html__('No applications have connected via OAuth yet.', 'aiutoma') . '

'; } else { echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; foreach ($apps as $app) { echo ''; echo ''; echo ''; echo ''; echo ''; } echo '
' . esc_html__('Application Name', 'aiutoma') . '' . esc_html__('Client ID', 'aiutoma') . '' . esc_html__('Connected On', 'aiutoma') . '
' . esc_html($app->client_name) . '' . esc_html($app->client_id) . '' . esc_html($app->created) . '
'; } } ?>

' . esc_html__('No AI logs recorded yet.', 'aiutoma') . '

'; } else { rsort($log_files); echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; foreach (array_slice($log_files, 0, 30) as $filepath) { $filename = basename($filepath); $size = size_format(filesize($filepath)); $download_url = wp_nonce_url(admin_url('admin-post.php?action=aiutoma_download_log&file=' . $filename), 'aiutoma_download_log_' . $filename); echo ''; echo ''; echo ''; echo ''; echo ''; } echo '
' . esc_html__('Log File', 'aiutoma') . '' . esc_html__('Size', 'aiutoma') . '' . esc_html__('Action', 'aiutoma') . '
' . esc_html($filename) . '' . esc_html($size) . '' . esc_html__('Download', 'aiutoma') . '
'; } ?>