Initial geladen: WP App Portal

This commit is contained in:
2026-04-10 11:32:42 +02:00
parent a772a0ad53
commit fdfd055748
5658 changed files with 1968631 additions and 0 deletions
@@ -0,0 +1,751 @@
<?php
namespace Royal_MCP\API;
if (!defined('ABSPATH')) {
exit;
}
class REST_Controller {
private $namespace = 'royal-mcp/v1';
public function register_routes() {
// Posts endpoints
register_rest_route($this->namespace, '/posts', [
[
'methods' => 'GET',
'callback' => [$this, 'get_posts'],
'permission_callback' => [$this, 'check_permission'],
],
[
'methods' => 'POST',
'callback' => [$this, 'create_post'],
'permission_callback' => [$this, 'check_permission'],
],
]);
register_rest_route($this->namespace, '/posts/(?P<id>\d+)', [
[
'methods' => 'GET',
'callback' => [$this, 'get_post'],
'permission_callback' => [$this, 'check_permission'],
],
[
'methods' => 'PUT',
'callback' => [$this, 'update_post'],
'permission_callback' => [$this, 'check_permission'],
],
[
'methods' => 'DELETE',
'callback' => [$this, 'delete_post'],
'permission_callback' => [$this, 'check_permission'],
],
]);
// Pages endpoints
register_rest_route($this->namespace, '/pages', [
[
'methods' => 'GET',
'callback' => [$this, 'get_pages'],
'permission_callback' => [$this, 'check_permission'],
],
[
'methods' => 'POST',
'callback' => [$this, 'create_page'],
'permission_callback' => [$this, 'check_permission'],
],
]);
register_rest_route($this->namespace, '/pages/(?P<id>\d+)', [
[
'methods' => 'GET',
'callback' => [$this, 'get_page'],
'permission_callback' => [$this, 'check_permission'],
],
[
'methods' => 'PUT',
'callback' => [$this, 'update_page'],
'permission_callback' => [$this, 'check_permission'],
],
[
'methods' => 'DELETE',
'callback' => [$this, 'delete_page'],
'permission_callback' => [$this, 'check_permission'],
],
]);
// Media endpoints
register_rest_route($this->namespace, '/media', [
[
'methods' => 'GET',
'callback' => [$this, 'get_media'],
'permission_callback' => [$this, 'check_permission'],
],
[
'methods' => 'POST',
'callback' => [$this, 'upload_media'],
'permission_callback' => [$this, 'check_permission'],
],
]);
register_rest_route($this->namespace, '/media/(?P<id>\d+)', [
[
'methods' => 'GET',
'callback' => [$this, 'get_media_item'],
'permission_callback' => [$this, 'check_permission'],
],
[
'methods' => 'DELETE',
'callback' => [$this, 'delete_media'],
'permission_callback' => [$this, 'check_permission'],
],
]);
// Site info endpoint
register_rest_route($this->namespace, '/site', [
'methods' => 'GET',
'callback' => [$this, 'get_site_info'],
'permission_callback' => [$this, 'check_permission'],
]);
// Search endpoint
register_rest_route($this->namespace, '/search', [
'methods' => 'GET',
'callback' => [$this, 'search_content'],
'permission_callback' => [$this, 'check_permission'],
]);
}
public function check_permission($request) {
$api_key = $request->get_header('X-Royal-MCP-API-Key');
if (empty($api_key)) {
return new \WP_Error(
'missing_api_key',
__('API key is required', 'royal-mcp'),
['status' => 401]
);
}
$settings = get_option('royal_mcp_settings', []);
if (!isset($settings['enabled']) || !$settings['enabled']) {
return new \WP_Error(
'plugin_disabled',
__('Royal MCP integration is disabled', 'royal-mcp'),
['status' => 403]
);
}
if (!isset($settings['api_key']) || $api_key !== $settings['api_key']) {
$this->log_request($request, 'unauthorized', 'Invalid API key');
return new \WP_Error(
'invalid_api_key',
__('Invalid API key', 'royal-mcp'),
['status' => 403]
);
}
return true;
}
// Posts methods
public function get_posts($request) {
$params = $request->get_params();
$args = [
'post_type' => 'post',
'post_status' => $params['status'] ?? 'publish',
'posts_per_page' => $params['per_page'] ?? 10,
'paged' => $params['page'] ?? 1,
'orderby' => $params['orderby'] ?? 'date',
'order' => $params['order'] ?? 'DESC',
];
if (isset($params['search'])) {
$args['s'] = sanitize_text_field($params['search']);
}
$query = new \WP_Query($args);
$posts = array_map(function($post) {
return $this->prepare_post_data($post);
}, $query->posts);
$this->log_request($request, 'success', 'Retrieved posts');
return rest_ensure_response([
'posts' => $posts,
'total' => $query->found_posts,
'pages' => $query->max_num_pages,
]);
}
public function get_post($request) {
$post_id = $request['id'];
$post = get_post($post_id);
if (!$post || $post->post_type !== 'post') {
return new \WP_Error(
'post_not_found',
__('Post not found', 'royal-mcp'),
['status' => 404]
);
}
$this->log_request($request, 'success', "Retrieved post {$post_id}");
return rest_ensure_response($this->prepare_post_data($post));
}
public function create_post($request) {
$params = $request->get_json_params();
$post_data = [
'post_title' => sanitize_text_field($params['title'] ?? ''),
'post_content' => wp_kses_post($params['content'] ?? ''),
'post_status' => sanitize_text_field($params['status'] ?? 'draft'),
'post_type' => 'post',
'post_author' => $params['author_id'] ?? get_current_user_id(),
];
if (isset($params['excerpt'])) {
$post_data['post_excerpt'] = sanitize_text_field($params['excerpt']);
}
if (isset($params['categories'])) {
$post_data['post_category'] = array_map('intval', (array) $params['categories']);
}
$post_id = wp_insert_post($post_data);
if (is_wp_error($post_id)) {
$this->log_request($request, 'error', $post_id->get_error_message());
return $post_id;
}
// Handle tags
if (isset($params['tags'])) {
wp_set_post_tags($post_id, $params['tags']);
}
// Handle featured image
if (isset($params['featured_media'])) {
set_post_thumbnail($post_id, intval($params['featured_media']));
}
$this->log_request($request, 'success', "Created post {$post_id}");
return rest_ensure_response([
'id' => $post_id,
'post' => $this->prepare_post_data(get_post($post_id)),
]);
}
public function update_post($request) {
$post_id = $request['id'];
$params = $request->get_json_params();
$post = get_post($post_id);
if (!$post || $post->post_type !== 'post') {
return new \WP_Error(
'post_not_found',
__('Post not found', 'royal-mcp'),
['status' => 404]
);
}
$post_data = ['ID' => $post_id];
if (isset($params['title'])) {
$post_data['post_title'] = sanitize_text_field($params['title']);
}
if (isset($params['content'])) {
$post_data['post_content'] = wp_kses_post($params['content']);
}
if (isset($params['status'])) {
$post_data['post_status'] = sanitize_text_field($params['status']);
}
if (isset($params['excerpt'])) {
$post_data['post_excerpt'] = sanitize_text_field($params['excerpt']);
}
if (isset($params['categories'])) {
$post_data['post_category'] = array_map('intval', (array) $params['categories']);
}
$result = wp_update_post($post_data);
if (is_wp_error($result)) {
$this->log_request($request, 'error', $result->get_error_message());
return $result;
}
if (isset($params['tags'])) {
wp_set_post_tags($post_id, $params['tags']);
}
if (isset($params['featured_media'])) {
set_post_thumbnail($post_id, intval($params['featured_media']));
}
$this->log_request($request, 'success', "Updated post {$post_id}");
return rest_ensure_response([
'id' => $post_id,
'post' => $this->prepare_post_data(get_post($post_id)),
]);
}
public function delete_post($request) {
$post_id = $request['id'];
$force = $request->get_param('force') === 'true';
$post = get_post($post_id);
if (!$post || $post->post_type !== 'post') {
return new \WP_Error(
'post_not_found',
__('Post not found', 'royal-mcp'),
['status' => 404]
);
}
$result = wp_delete_post($post_id, $force);
if (!$result) {
$this->log_request($request, 'error', "Failed to delete post {$post_id}");
return new \WP_Error(
'delete_failed',
__('Failed to delete post', 'royal-mcp'),
['status' => 500]
);
}
$this->log_request($request, 'success', "Deleted post {$post_id}");
return rest_ensure_response(['success' => true, 'id' => $post_id]);
}
// Pages methods
public function get_pages($request) {
$params = $request->get_params();
$args = [
'post_type' => 'page',
'post_status' => $params['status'] ?? 'publish',
'posts_per_page' => $params['per_page'] ?? 10,
'paged' => $params['page'] ?? 1,
'orderby' => $params['orderby'] ?? 'date',
'order' => $params['order'] ?? 'DESC',
];
$query = new \WP_Query($args);
$pages = array_map(function($post) {
return $this->prepare_post_data($post);
}, $query->posts);
$this->log_request($request, 'success', 'Retrieved pages');
return rest_ensure_response([
'pages' => $pages,
'total' => $query->found_posts,
'pages_count' => $query->max_num_pages,
]);
}
public function get_page($request) {
$post_id = $request['id'];
$post = get_post($post_id);
if (!$post || $post->post_type !== 'page') {
return new \WP_Error(
'page_not_found',
__('Page not found', 'royal-mcp'),
['status' => 404]
);
}
$this->log_request($request, 'success', "Retrieved page {$post_id}");
return rest_ensure_response($this->prepare_post_data($post));
}
public function create_page($request) {
$params = $request->get_json_params();
$post_data = [
'post_title' => sanitize_text_field($params['title'] ?? ''),
'post_content' => wp_kses_post($params['content'] ?? ''),
'post_status' => sanitize_text_field($params['status'] ?? 'draft'),
'post_type' => 'page',
'post_author' => $params['author_id'] ?? get_current_user_id(),
];
if (isset($params['parent_id'])) {
$post_data['post_parent'] = intval($params['parent_id']);
}
if (isset($params['template'])) {
$post_data['page_template'] = sanitize_text_field($params['template']);
}
$post_id = wp_insert_post($post_data);
if (is_wp_error($post_id)) {
$this->log_request($request, 'error', $post_id->get_error_message());
return $post_id;
}
if (isset($params['featured_media'])) {
set_post_thumbnail($post_id, intval($params['featured_media']));
}
$this->log_request($request, 'success', "Created page {$post_id}");
return rest_ensure_response([
'id' => $post_id,
'page' => $this->prepare_post_data(get_post($post_id)),
]);
}
public function update_page($request) {
$post_id = $request['id'];
$params = $request->get_json_params();
$post = get_post($post_id);
if (!$post || $post->post_type !== 'page') {
return new \WP_Error(
'page_not_found',
__('Page not found', 'royal-mcp'),
['status' => 404]
);
}
$post_data = ['ID' => $post_id];
if (isset($params['title'])) {
$post_data['post_title'] = sanitize_text_field($params['title']);
}
if (isset($params['content'])) {
$post_data['post_content'] = wp_kses_post($params['content']);
}
if (isset($params['status'])) {
$post_data['post_status'] = sanitize_text_field($params['status']);
}
if (isset($params['parent_id'])) {
$post_data['post_parent'] = intval($params['parent_id']);
}
$result = wp_update_post($post_data);
if (is_wp_error($result)) {
$this->log_request($request, 'error', $result->get_error_message());
return $result;
}
if (isset($params['featured_media'])) {
set_post_thumbnail($post_id, intval($params['featured_media']));
}
$this->log_request($request, 'success', "Updated page {$post_id}");
return rest_ensure_response([
'id' => $post_id,
'page' => $this->prepare_post_data(get_post($post_id)),
]);
}
public function delete_page($request) {
$post_id = $request['id'];
$force = $request->get_param('force') === 'true';
$post = get_post($post_id);
if (!$post || $post->post_type !== 'page') {
return new \WP_Error(
'page_not_found',
__('Page not found', 'royal-mcp'),
['status' => 404]
);
}
$result = wp_delete_post($post_id, $force);
if (!$result) {
$this->log_request($request, 'error', "Failed to delete page {$post_id}");
return new \WP_Error(
'delete_failed',
__('Failed to delete page', 'royal-mcp'),
['status' => 500]
);
}
$this->log_request($request, 'success', "Deleted page {$post_id}");
return rest_ensure_response(['success' => true, 'id' => $post_id]);
}
// Media methods
public function get_media($request) {
$params = $request->get_params();
$args = [
'post_type' => 'attachment',
'post_status' => 'inherit',
'posts_per_page' => $params['per_page'] ?? 10,
'paged' => $params['page'] ?? 1,
];
if (isset($params['mime_type'])) {
$args['post_mime_type'] = sanitize_text_field($params['mime_type']);
}
$query = new \WP_Query($args);
$media = array_map(function($post) {
return $this->prepare_media_data($post);
}, $query->posts);
$this->log_request($request, 'success', 'Retrieved media');
return rest_ensure_response([
'media' => $media,
'total' => $query->found_posts,
'pages' => $query->max_num_pages,
]);
}
public function get_media_item($request) {
$media_id = $request['id'];
$media = get_post($media_id);
if (!$media || $media->post_type !== 'attachment') {
return new \WP_Error(
'media_not_found',
__('Media not found', 'royal-mcp'),
['status' => 404]
);
}
$this->log_request($request, 'success', "Retrieved media {$media_id}");
return rest_ensure_response($this->prepare_media_data($media));
}
public function upload_media($request) {
require_once(ABSPATH . 'wp-admin/includes/file.php');
require_once(ABSPATH . 'wp-admin/includes/media.php');
require_once(ABSPATH . 'wp-admin/includes/image.php');
$files = $request->get_file_params();
if (empty($files['file'])) {
return new \WP_Error(
'no_file',
__('No file was uploaded', 'royal-mcp'),
['status' => 400]
);
}
$file = $files['file'];
// Check file type
$allowed_types = get_allowed_mime_types();
$filetype = wp_check_filetype($file['name']);
if (!in_array($filetype['type'], $allowed_types)) {
return new \WP_Error(
'invalid_file_type',
__('Invalid file type', 'royal-mcp'),
['status' => 400]
);
}
$upload = wp_handle_upload($file, ['test_form' => false]);
if (isset($upload['error'])) {
$this->log_request($request, 'error', $upload['error']);
return new \WP_Error(
'upload_failed',
$upload['error'],
['status' => 500]
);
}
$attachment = [
'post_mime_type' => $upload['type'],
'post_title' => sanitize_file_name(pathinfo($file['name'], PATHINFO_FILENAME)),
'post_content' => '',
'post_status' => 'inherit',
];
$attachment_id = wp_insert_attachment($attachment, $upload['file']);
if (is_wp_error($attachment_id)) {
$this->log_request($request, 'error', $attachment_id->get_error_message());
return $attachment_id;
}
$attachment_data = wp_generate_attachment_metadata($attachment_id, $upload['file']);
wp_update_attachment_metadata($attachment_id, $attachment_data);
$this->log_request($request, 'success', "Uploaded media {$attachment_id}");
return rest_ensure_response([
'id' => $attachment_id,
'media' => $this->prepare_media_data(get_post($attachment_id)),
]);
}
public function delete_media($request) {
$media_id = $request['id'];
$force = $request->get_param('force') === 'true';
$media = get_post($media_id);
if (!$media || $media->post_type !== 'attachment') {
return new \WP_Error(
'media_not_found',
__('Media not found', 'royal-mcp'),
['status' => 404]
);
}
$result = wp_delete_attachment($media_id, $force);
if (!$result) {
$this->log_request($request, 'error', "Failed to delete media {$media_id}");
return new \WP_Error(
'delete_failed',
__('Failed to delete media', 'royal-mcp'),
['status' => 500]
);
}
$this->log_request($request, 'success', "Deleted media {$media_id}");
return rest_ensure_response(['success' => true, 'id' => $media_id]);
}
// Site info
public function get_site_info($request) {
$this->log_request($request, 'success', 'Retrieved site info');
return rest_ensure_response([
'name' => get_bloginfo('name'),
'description' => get_bloginfo('description'),
'url' => get_bloginfo('url'),
'admin_email' => get_bloginfo('admin_email'),
'language' => get_bloginfo('language'),
'timezone' => get_option('timezone_string'),
'date_format' => get_option('date_format'),
'time_format' => get_option('time_format'),
'posts_per_page' => get_option('posts_per_page'),
'wordpress_version' => get_bloginfo('version'),
]);
}
// Search
public function search_content($request) {
$params = $request->get_params();
$search_query = sanitize_text_field($params['query'] ?? '');
if (empty($search_query)) {
return new \WP_Error(
'empty_query',
__('Search query is required', 'royal-mcp'),
['status' => 400]
);
}
$args = [
'post_type' => $params['type'] ?? ['post', 'page'],
'post_status' => 'publish',
's' => $search_query,
'posts_per_page' => $params['per_page'] ?? 10,
];
$query = new \WP_Query($args);
$results = array_map(function($post) {
return $this->prepare_post_data($post);
}, $query->posts);
$this->log_request($request, 'success', "Searched for: {$search_query}");
return rest_ensure_response([
'results' => $results,
'total' => $query->found_posts,
'query' => $search_query,
]);
}
// Helper methods
private function prepare_post_data($post) {
$author = get_userdata($post->post_author);
return [
'id' => $post->ID,
'title' => $post->post_title,
'content' => $post->post_content,
'excerpt' => $post->post_excerpt,
'status' => $post->post_status,
'type' => $post->post_type,
'slug' => $post->post_name,
'date' => $post->post_date,
'modified' => $post->post_modified,
'author' => [
'id' => $post->post_author,
'name' => $author ? $author->display_name : '',
],
'featured_media' => get_post_thumbnail_id($post->ID),
'categories' => wp_get_post_categories($post->ID),
'tags' => wp_get_post_tags($post->ID, ['fields' => 'names']),
'permalink' => get_permalink($post->ID),
];
}
private function prepare_media_data($media) {
$metadata = wp_get_attachment_metadata($media->ID);
return [
'id' => $media->ID,
'title' => $media->post_title,
'description' => $media->post_content,
'caption' => $media->post_excerpt,
'alt_text' => get_post_meta($media->ID, '_wp_attachment_image_alt', true),
'mime_type' => $media->post_mime_type,
'url' => wp_get_attachment_url($media->ID),
'date' => $media->post_date,
'modified' => $media->post_modified,
'sizes' => $metadata['sizes'] ?? [],
'width' => $metadata['width'] ?? null,
'height' => $metadata['height'] ?? null,
];
}
private function log_request($request, $status, $message = '') {
global $wpdb;
$table_name = $wpdb->prefix . 'royal_mcp_logs';
$wpdb->insert(
$table_name,
[
'mcp_server' => 'internal',
'action' => $request->get_route(),
'request_data' => json_encode([
'method' => $request->get_method(),
'params' => $request->get_params(),
]),
'response_data' => $message,
'status' => $status,
],
['%s', '%s', '%s', '%s', '%s']
);
}
}
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
@@ -0,0 +1,427 @@
<?php
namespace Royal_MCP\Admin;
use Royal_MCP\Platform\Registry;
if (!defined('ABSPATH')) {
exit;
}
class Settings_Page {
public function __construct() {
add_action('admin_menu', [$this, 'add_menu_page']);
add_action('admin_init', [$this, 'register_settings']);
add_action('admin_enqueue_scripts', [$this, 'enqueue_scripts']);
add_filter('admin_footer_text', [$this, 'admin_footer_text']);
// AJAX handlers
add_action('wp_ajax_royal_mcp_test_connection', [$this, 'ajax_test_connection']);
add_action('wp_ajax_royal_mcp_get_platform_fields', [$this, 'ajax_get_platform_fields']);
}
public function add_menu_page() {
add_menu_page(
__('Royal MCP Settings', 'royal-mcp'),
__('Royal MCP', 'royal-mcp'),
'manage_options',
'royal-mcp',
[$this, 'render_settings_page'],
'dashicons-networking',
80
);
add_submenu_page(
'royal-mcp',
__('Settings', 'royal-mcp'),
__('Settings', 'royal-mcp'),
'manage_options',
'royal-mcp',
[$this, 'render_settings_page']
);
add_submenu_page(
'royal-mcp',
__('Activity Log', 'royal-mcp'),
__('Activity Log', 'royal-mcp'),
'manage_options',
'royal-mcp-logs',
[$this, 'render_logs_page']
);
}
public function register_settings() {
register_setting('royal_mcp_settings_group', 'royal_mcp_settings', [
'sanitize_callback' => [$this, 'sanitize_settings'],
]);
}
public function sanitize_settings($input) {
$sanitized = [];
$settings = get_option('royal_mcp_settings', []);
$sanitized['enabled'] = isset($input['enabled']) ? (bool) $input['enabled'] : false;
// Sanitize API key
if (isset($input['api_key']) && !empty($input['api_key'])) {
$sanitized['api_key'] = sanitize_text_field($input['api_key']);
} elseif (isset($input['regenerate_api_key'])) {
$sanitized['api_key'] = wp_generate_password(32, false);
} else {
$sanitized['api_key'] = $settings['api_key'] ?? wp_generate_password(32, false);
}
// Sanitize OAuth settings
if (isset($input['oauth_client_id']) && !empty($input['oauth_client_id'])) {
$sanitized['oauth_client_id'] = sanitize_text_field($input['oauth_client_id']);
} else {
$sanitized['oauth_client_id'] = $settings['oauth_client_id'] ?? '';
}
if (isset($input['oauth_client_secret']) && !empty($input['oauth_client_secret'])) {
$sanitized['oauth_client_secret'] = sanitize_text_field($input['oauth_client_secret']);
} else {
$sanitized['oauth_client_secret'] = $settings['oauth_client_secret'] ?? '';
}
// Sanitize AI Platforms (new structure)
$sanitized['platforms'] = [];
if (isset($input['platforms']) && is_array($input['platforms'])) {
foreach ($input['platforms'] as $index => $platform_config) {
if (empty($platform_config['platform'])) {
continue;
}
$platform_id = sanitize_text_field($platform_config['platform']);
$platform = Registry::get_platform($platform_id);
if (!$platform) {
continue;
}
$sanitized_platform = [
'platform' => $platform_id,
'enabled' => isset($platform_config['enabled']) ? (bool) $platform_config['enabled'] : true,
];
// Sanitize each field based on platform configuration
foreach ($platform['fields'] as $field_id => $field_config) {
if (isset($platform_config[$field_id])) {
switch ($field_config['type']) {
case 'url':
$sanitized_platform[$field_id] = esc_url_raw($platform_config[$field_id]);
break;
case 'password':
case 'text':
case 'select':
default:
$sanitized_platform[$field_id] = sanitize_text_field($platform_config[$field_id]);
break;
}
} elseif (isset($field_config['default'])) {
$sanitized_platform[$field_id] = $field_config['default'];
}
}
$sanitized['platforms'][] = $sanitized_platform;
}
}
// Legacy: Also keep mcp_servers for backward compatibility
$sanitized['mcp_servers'] = [];
if (isset($input['mcp_servers']) && is_array($input['mcp_servers'])) {
foreach ($input['mcp_servers'] as $server) {
if (!empty($server['name']) && !empty($server['url'])) {
$sanitized['mcp_servers'][] = [
'name' => sanitize_text_field($server['name']),
'url' => esc_url_raw($server['url']),
'api_key' => sanitize_text_field($server['api_key'] ?? ''),
'enabled' => isset($server['enabled']) ? (bool) $server['enabled'] : true,
];
}
}
}
return $sanitized;
}
public function enqueue_scripts($hook) {
if (strpos($hook, 'royal-mcp') === false) {
return;
}
wp_enqueue_style(
'royal-mcp-admin',
ROYAL_MCP_PLUGIN_URL . 'assets/css/admin.css',
[],
ROYAL_MCP_VERSION
);
wp_enqueue_script(
'royal-mcp-admin',
ROYAL_MCP_PLUGIN_URL . 'assets/js/admin.js',
['jquery'],
ROYAL_MCP_VERSION,
true
);
// Get platform data for JavaScript
$platforms = Registry::get_platforms();
$platform_groups = Registry::get_platform_groups();
wp_localize_script('royal-mcp-admin', 'royalMcp', [
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('royal_mcp_nonce'),
'restUrl' => rest_url('royal-mcp/v1/'),
'platforms' => $platforms,
'platformGroups' => $platform_groups,
'strings' => [
'selectPlatform' => esc_html__('Select a platform...', 'royal-mcp'),
'testConnection' => esc_html__('Test Connection', 'royal-mcp'),
'testing' => esc_html__('Testing...', 'royal-mcp'),
'connectionSuccess' => esc_html__('Connection successful!', 'royal-mcp'),
'connectionFailed' => esc_html__('Connection failed', 'royal-mcp'),
'removePlatform' => esc_html__('Remove', 'royal-mcp'),
'getApiKey' => esc_html__('Get API Key', 'royal-mcp'),
'documentation' => esc_html__('Documentation', 'royal-mcp'),
'confirmRemove' => esc_html__('Are you sure you want to remove this platform?', 'royal-mcp'),
'confirmRegenerate' => esc_html__('Are you sure? This will invalidate the current API key.', 'royal-mcp'),
],
]);
}
public function render_settings_page() {
if (!current_user_can('manage_options')) {
return;
}
$settings = get_option('royal_mcp_settings', [
'enabled' => false,
'platforms' => [],
'mcp_servers' => [],
'api_key' => wp_generate_password(32, false),
]);
$platforms = Registry::get_platforms();
$platform_groups = Registry::get_platform_groups();
include ROYAL_MCP_PLUGIN_DIR . 'templates/admin/settings.php';
}
public function render_logs_page() {
if (!current_user_can('manage_options')) {
return;
}
global $wpdb;
// Table name constructed safely from prefix + hardcoded string, then escaped
$table_name = esc_sql($wpdb->prefix . 'royal_mcp_logs');
// Verify nonce for page navigation
$nonce_valid = isset($_GET['_wpnonce']) ? wp_verify_nonce(sanitize_text_field(wp_unslash($_GET['_wpnonce'])), 'royal_mcp_logs_page') : true;
$page = ($nonce_valid && isset($_GET['paged'])) ? max(1, absint($_GET['paged'])) : 1;
$per_page = 20;
$offset = ($page - 1) * $per_page;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Custom plugin logs table, table name escaped via esc_sql()
$total_items = $wpdb->get_var("SELECT COUNT(*) FROM `{$table_name}`");
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
// Custom plugin logs table - table name escaped via esc_sql()
$logs = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM `{$table_name}` ORDER BY timestamp DESC LIMIT %d OFFSET %d",
$per_page,
$offset
)
);
// phpcs:enable
include ROYAL_MCP_PLUGIN_DIR . 'templates/admin/logs.php';
}
/**
* AJAX handler for testing platform connections
*/
public function ajax_test_connection() {
check_ajax_referer('royal_mcp_nonce', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(['message' => esc_html__('Unauthorized', 'royal-mcp')]);
}
$platform_id = isset($_POST['platform']) ? sanitize_text_field(wp_unslash($_POST['platform'])) : '';
$config = [];
// Get config from POST data
if (isset($_POST['config']) && is_array($_POST['config'])) {
$posted_config = map_deep(wp_unslash($_POST['config']), 'sanitize_text_field');
foreach ($posted_config as $key => $value) {
$config[sanitize_text_field($key)] = sanitize_text_field($value);
}
}
if (empty($platform_id)) {
wp_send_json_error(['message' => esc_html__('No platform selected', 'royal-mcp')]);
}
$result = Registry::test_connection($platform_id, $config);
if ($result['success']) {
wp_send_json_success($result);
} else {
wp_send_json_error($result);
}
}
/**
* AJAX handler to get platform field HTML
*/
public function ajax_get_platform_fields() {
check_ajax_referer('royal_mcp_nonce', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(['message' => esc_html__('Unauthorized', 'royal-mcp')]);
}
$platform_id = isset($_POST['platform']) ? sanitize_text_field(wp_unslash($_POST['platform'])) : '';
$index = isset($_POST['index']) ? absint($_POST['index']) : 0;
$platform = Registry::get_platform($platform_id);
if (!$platform) {
wp_send_json_error(['message' => esc_html__('Invalid platform', 'royal-mcp')]);
}
ob_start();
$this->render_platform_fields($platform, $index);
$html = ob_get_clean();
wp_send_json_success([
'html' => $html,
'platform' => $platform,
]);
}
/**
* Render platform-specific fields
*/
public function render_platform_fields($platform, $index, $values = []) {
foreach ($platform['fields'] as $field_id => $field) {
$field_name = "royal_mcp_settings[platforms][{$index}][{$field_id}]";
$field_value = $values[$field_id] ?? ($field['default'] ?? '');
$required = !empty($field['required']) ? 'required' : '';
?>
<tr class="platform-field platform-field-<?php echo esc_attr($field_id); ?>">
<th scope="row">
<label for="platform-<?php echo esc_attr($index); ?>-<?php echo esc_attr($field_id); ?>">
<?php echo esc_html($field['label']); ?>
<?php if (!empty($field['required'])) : ?>
<span class="required">*</span>
<?php endif; ?>
</label>
</th>
<td>
<?php
switch ($field['type']) {
case 'select':
?>
<select
name="<?php echo esc_attr($field_name); ?>"
id="platform-<?php echo esc_attr($index); ?>-<?php echo esc_attr($field_id); ?>"
class="regular-text"
<?php echo esc_attr($required); ?>
data-field="<?php echo esc_attr($field_id); ?>"
>
<?php foreach ($field['options'] as $value => $label) : ?>
<option value="<?php echo esc_attr($value); ?>" <?php selected($field_value, $value); ?>>
<?php echo esc_html($label); ?>
</option>
<?php endforeach; ?>
</select>
<?php
break;
case 'password':
?>
<input
type="password"
name="<?php echo esc_attr($field_name); ?>"
id="platform-<?php echo esc_attr($index); ?>-<?php echo esc_attr($field_id); ?>"
value="<?php echo esc_attr($field_value); ?>"
class="regular-text"
placeholder="<?php echo esc_attr($field['placeholder'] ?? ''); ?>"
<?php echo esc_attr($required); ?>
data-field="<?php echo esc_attr($field_id); ?>"
autocomplete="new-password"
>
<button type="button" class="button toggle-password" title="<?php esc_attr_e('Show/Hide', 'royal-mcp'); ?>">
<span class="dashicons dashicons-visibility"></span>
</button>
<?php
break;
case 'url':
?>
<input
type="url"
name="<?php echo esc_attr($field_name); ?>"
id="platform-<?php echo esc_attr($index); ?>-<?php echo esc_attr($field_id); ?>"
value="<?php echo esc_attr($field_value); ?>"
class="regular-text"
placeholder="<?php echo esc_attr($field['placeholder'] ?? ''); ?>"
<?php echo esc_attr($required); ?>
data-field="<?php echo esc_attr($field_id); ?>"
>
<?php
break;
case 'text':
default:
?>
<input
type="text"
name="<?php echo esc_attr($field_name); ?>"
id="platform-<?php echo esc_attr($index); ?>-<?php echo esc_attr($field_id); ?>"
value="<?php echo esc_attr($field_value); ?>"
class="regular-text"
placeholder="<?php echo esc_attr($field['placeholder'] ?? ''); ?>"
<?php echo esc_attr($required); ?>
data-field="<?php echo esc_attr($field_id); ?>"
>
<?php
break;
}
if (!empty($field['help'])) :
?>
<p class="description"><?php echo esc_html($field['help']); ?></p>
<?php
endif;
?>
</td>
</tr>
<?php
}
}
public function admin_footer_text($text) {
$current_screen = get_current_screen();
// Add footer to all admin pages
$footer_text = sprintf(
/* translators: %s: Royal Plugins link */
__('Built By %s', 'royal-mcp'),
'<a href="https://www.royalplugins.com" target="_blank" rel="noopener noreferrer">Royal Plugins</a>'
);
// If we're on our plugin pages, add it before the existing text
if ($current_screen && strpos($current_screen->id, 'royal-mcp') !== false) {
return $footer_text . ' | ' . $text;
}
// For all other admin pages, return original text unchanged
return $text;
}
}
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
@@ -0,0 +1,176 @@
<?php
namespace Royal_MCP\Integrations;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* GuardPress MCP Integration
*
* Registers MCP tools for GuardPress security features.
* Only loaded when GuardPress is active.
*/
class GuardPress {
public static function is_available() {
return class_exists( 'GuardPress' );
}
public static function get_tools() {
if ( ! self::is_available() ) {
return [];
}
return [
[
'name' => 'gp_get_security_status',
'description' => 'Get current security score, grade, and factor breakdown',
'inputSchema' => [ 'type' => 'object', 'properties' => new \stdClass() ],
],
[
'name' => 'gp_get_security_stats',
'description' => 'Get security statistics (failed logins, blocked IPs, alerts, etc) for a time period',
'inputSchema' => [
'type' => 'object',
'properties' => [
'period' => [ 'type' => 'string', 'description' => 'Time period: day, week, month', 'enum' => [ 'day', 'week', 'month' ] ],
],
],
],
[
'name' => 'gp_run_vulnerability_scan',
'description' => 'Run a vulnerability scan checking for outdated plugins, themes, and security misconfigurations',
'inputSchema' => [ 'type' => 'object', 'properties' => new \stdClass() ],
],
[
'name' => 'gp_get_vulnerability_results',
'description' => 'Get the latest vulnerability scan results',
'inputSchema' => [ 'type' => 'object', 'properties' => new \stdClass() ],
],
[
'name' => 'gp_get_failed_logins',
'description' => 'Get failed login attempt statistics and top offending IPs',
'inputSchema' => [
'type' => 'object',
'properties' => [
'period' => [ 'type' => 'string', 'description' => 'Time period: day, week, month', 'enum' => [ 'day', 'week', 'month' ] ],
],
],
],
[
'name' => 'gp_get_blocked_ips',
'description' => 'Get list of currently blocked IP addresses',
'inputSchema' => [ 'type' => 'object', 'properties' => new \stdClass() ],
],
[
'name' => 'gp_get_audit_log',
'description' => 'Get recent security audit log entries',
'inputSchema' => [
'type' => 'object',
'properties' => [
'limit' => [ 'type' => 'integer', 'description' => 'Number of entries (max 100)' ],
'severity' => [ 'type' => 'string', 'description' => 'Filter by severity', 'enum' => [ 'info', 'warning', 'critical' ] ],
],
],
],
];
}
public static function execute_tool( $name, $args ) {
if ( ! self::is_available() ) {
throw new \Exception( 'GuardPress is not active' );
}
$guardpress = \GuardPress::get_instance();
switch ( $name ) {
case 'gp_get_security_status':
if ( ! method_exists( 'GuardPress_Settings', 'get_security_score' ) ) {
throw new \Exception( 'Security score not available in this version of GuardPress' );
}
return \GuardPress_Settings::get_security_score();
case 'gp_get_security_stats':
if ( ! method_exists( 'GuardPress_Logger', 'get_security_stats' ) ) {
throw new \Exception( 'Security stats not available' );
}
$period = in_array( $args['period'] ?? 'week', [ 'day', 'week', 'month' ] ) ? $args['period'] : 'week';
return \GuardPress_Logger::get_security_stats( $period );
case 'gp_run_vulnerability_scan':
$scanner = $guardpress->get_module( 'vulnerability-scanner' );
if ( ! $scanner ) {
throw new \Exception( 'Vulnerability scanner module not available' );
}
$results = $scanner->run_scan();
$stats = $scanner->get_statistics();
return [
'message' => 'Vulnerability scan completed',
'vulnerabilities_found' => is_array( $results ) ? count( $results ) : 0,
'statistics' => $stats,
];
case 'gp_get_vulnerability_results':
$scanner = $guardpress->get_module( 'vulnerability-scanner' );
if ( ! $scanner ) {
throw new \Exception( 'Vulnerability scanner module not available' );
}
$results = $scanner->get_results();
$stats = $scanner->get_statistics();
return [
'last_scan' => $scanner->get_last_scan(),
'statistics' => $stats,
'results' => is_array( $results ) ? $results : [],
];
case 'gp_get_failed_logins':
$brute_force = $guardpress->get_module( 'brute-force' );
if ( ! $brute_force || ! method_exists( $brute_force, 'get_statistics' ) ) {
throw new \Exception( 'Brute force module not available' );
}
$period = in_array( $args['period'] ?? 'week', [ 'day', 'week', 'month' ] ) ? $args['period'] : 'week';
return $brute_force->get_statistics( $period );
case 'gp_get_blocked_ips':
$brute_force = $guardpress->get_module( 'brute-force' );
if ( ! $brute_force || ! method_exists( $brute_force, 'get_blocked_ips' ) ) {
throw new \Exception( 'Brute force module not available' );
}
$blocked = $brute_force->get_blocked_ips();
return array_map( function( $ip ) {
return [
'ip_address' => $ip->ip_address,
'blocked_at' => $ip->blocked_at,
'reason' => $ip->reason ?? 'brute_force',
'is_permanent' => ! empty( $ip->is_permanent ),
];
}, $blocked );
case 'gp_get_audit_log':
if ( ! method_exists( 'GuardPress_Logger', 'get_audit_logs' ) ) {
throw new \Exception( 'Audit log not available' );
}
$log_args = [
'limit' => min( intval( $args['limit'] ?? 50 ), 100 ),
];
if ( ! empty( $args['severity'] ) ) {
$log_args['severity'] = sanitize_text_field( $args['severity'] );
}
$logs = \GuardPress_Logger::get_audit_logs( $log_args );
return array_map( function( $log ) {
return [
'action' => $log->action,
'description' => $log->description,
'severity' => $log->severity,
'ip_address' => $log->ip_address,
'username' => $log->username,
'created_at' => $log->created_at,
];
}, $logs );
default:
throw new \Exception( 'Unknown GuardPress tool: ' . esc_html( $name ) );
}
}
}
@@ -0,0 +1,215 @@
<?php
namespace Royal_MCP\Integrations;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* SiteVault MCP Integration
*
* Registers MCP tools for SiteVault backup management.
* Only loaded when SiteVault Pro is active.
*/
class SiteVault {
public static function is_available() {
return class_exists( 'RB_Backup_Manager' );
}
public static function get_tools() {
if ( ! self::is_available() ) {
return [];
}
return [
[
'name' => 'sv_get_backups',
'description' => 'List available SiteVault backups',
'inputSchema' => [
'type' => 'object',
'properties' => [
'limit' => [ 'type' => 'integer', 'description' => 'Number of backups to return (max 50)' ],
'status' => [ 'type' => 'string', 'description' => 'Filter by status', 'enum' => [ 'completed', 'failed', 'in_progress' ] ],
'type' => [ 'type' => 'string', 'description' => 'Filter by backup type', 'enum' => [ 'full', 'database', 'files', 'plugins', 'themes', 'uploads' ] ],
],
],
],
[
'name' => 'sv_get_backup',
'description' => 'Get details of a specific backup by ID',
'inputSchema' => [
'type' => 'object',
'properties' => [
'id' => [ 'type' => 'integer', 'description' => 'Backup ID' ],
],
'required' => [ 'id' ],
],
],
[
'name' => 'sv_create_backup',
'description' => 'Trigger a new backup (runs asynchronously)',
'inputSchema' => [
'type' => 'object',
'properties' => [
'type' => [ 'type' => 'string', 'description' => 'Backup type', 'enum' => [ 'full', 'database', 'files', 'plugins', 'themes', 'uploads' ] ],
'name' => [ 'type' => 'string', 'description' => 'Backup name (optional)' ],
],
],
],
[
'name' => 'sv_get_backup_status',
'description' => 'Check the progress of an in-progress backup',
'inputSchema' => [
'type' => 'object',
'properties' => [
'id' => [ 'type' => 'integer', 'description' => 'Backup ID' ],
],
'required' => [ 'id' ],
],
],
[
'name' => 'sv_get_backup_stats',
'description' => 'Get overall backup statistics (total count, size, last backup)',
'inputSchema' => [ 'type' => 'object', 'properties' => new \stdClass() ],
],
[
'name' => 'sv_get_schedules',
'description' => 'List backup schedules',
'inputSchema' => [ 'type' => 'object', 'properties' => new \stdClass() ],
],
];
}
public static function execute_tool( $name, $args ) {
if ( ! self::is_available() ) {
throw new \Exception( 'SiteVault is not active' );
}
$manager = \RB_Backup_Manager::instance();
switch ( $name ) {
case 'sv_get_backups':
$query_args = [
'limit' => min( intval( $args['limit'] ?? 20 ), 50 ),
];
if ( ! empty( $args['status'] ) ) {
$query_args['status'] = sanitize_text_field( $args['status'] );
}
if ( ! empty( $args['type'] ) ) {
$query_args['type'] = sanitize_text_field( $args['type'] );
}
$backups = $manager->get_backups( $query_args );
return array_map( [ __CLASS__, 'format_backup' ], $backups );
case 'sv_get_backup':
$backup = $manager->get_backup( intval( $args['id'] ) );
if ( ! $backup ) {
throw new \Exception( 'Backup not found' );
}
return self::format_backup( $backup );
case 'sv_create_backup':
$backup_args = [
'type' => in_array( $args['type'] ?? 'full', [ 'full', 'database', 'files', 'plugins', 'themes', 'uploads' ] ) ? $args['type'] : 'full',
];
if ( ! empty( $args['name'] ) ) {
$backup_args['name'] = sanitize_text_field( $args['name'] );
}
// Check if a backup is already running
if ( $manager->has_active_backup() ) {
throw new \Exception( 'A backup is already in progress. Please wait for it to complete.' );
}
// Use async backup to avoid timeout
if ( class_exists( 'RB_Async_Backup' ) ) {
$backup_id = \RB_Async_Backup::instance()->start_backup( $backup_args );
} else {
$backup_id = \RB_Backup_Engine::instance()->create_backup( $backup_args );
}
if ( is_wp_error( $backup_id ) ) {
throw new \Exception( esc_html( $backup_id->get_error_message() ) );
}
return [
'id' => $backup_id,
'message' => 'Backup started successfully. Use sv_get_backup_status to check progress.',
'type' => $backup_args['type'],
];
case 'sv_get_backup_status':
$backup_id = intval( $args['id'] );
$backup = $manager->get_backup( $backup_id );
if ( ! $backup ) {
throw new \Exception( 'Backup not found' );
}
$result = [
'id' => $backup_id,
'status' => $backup->status,
'type' => $backup->backup_type,
];
// If in progress, get detailed progress
if ( $backup->status === 'in_progress' && class_exists( 'RB_Async_Backup' ) ) {
$progress = \RB_Async_Backup::instance()->get_status( $backup_id );
if ( is_array( $progress ) ) {
$result['percent'] = $progress['percent'] ?? 0;
$result['step'] = $progress['step'] ?? '';
$result['message'] = $progress['message'] ?? '';
}
} elseif ( $backup->status === 'completed' ) {
$result['size'] = $backup->backup_size;
$result['size_human'] = size_format( $backup->backup_size );
$result['completed_at'] = $backup->completed_at;
} elseif ( $backup->status === 'failed' ) {
$meta = json_decode( $backup->metadata ?? '{}', true );
$result['error'] = $meta['error'] ?? 'Unknown error';
}
return $result;
case 'sv_get_backup_stats':
if ( ! method_exists( $manager, 'get_stats' ) ) {
throw new \Exception( 'Backup stats not available in this version' );
}
return $manager->get_stats();
case 'sv_get_schedules':
if ( ! class_exists( 'RB_Backup_Scheduler' ) ) {
throw new \Exception( 'Backup scheduler not available' );
}
$schedules = \RB_Backup_Scheduler::instance()->get_schedules();
return array_map( function( $s ) {
return [
'id' => $s->id,
'name' => $s->schedule_name,
'type' => $s->backup_type,
'frequency' => $s->frequency,
'is_active' => (bool) $s->is_active,
'last_run' => $s->last_run,
'next_run' => $s->next_run,
];
}, $schedules );
default:
throw new \Exception( 'Unknown SiteVault tool: ' . esc_html( $name ) );
}
}
private static function format_backup( $backup ) {
return [
'id' => (int) $backup->id,
'name' => $backup->backup_name,
'type' => $backup->backup_type,
'status' => $backup->status,
'size' => (int) $backup->backup_size,
'size_human' => size_format( $backup->backup_size ),
'cloud_synced' => (bool) $backup->cloud_synced,
'created_at' => $backup->created_at,
'completed_at' => $backup->completed_at,
];
}
}
@@ -0,0 +1,432 @@
<?php
namespace Royal_MCP\Integrations;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* WooCommerce MCP Integration
*
* Registers MCP tools for WooCommerce product, order, and customer management.
* Only loaded when WooCommerce is active.
*/
class WooCommerce {
/**
* Check if WooCommerce is available.
*/
public static function is_available() {
return class_exists( 'WooCommerce' );
}
/**
* Get tool definitions for MCP tools/list response.
*/
public static function get_tools() {
if ( ! self::is_available() ) {
return [];
}
return [
[
'name' => 'wc_get_products',
'description' => 'Get WooCommerce products',
'inputSchema' => [
'type' => 'object',
'properties' => [
'per_page' => [ 'type' => 'integer', 'description' => 'Number of products (max 100)' ],
'status' => [ 'type' => 'string', 'description' => 'Product status (publish, draft, etc)' ],
'category' => [ 'type' => 'string', 'description' => 'Category slug to filter by' ],
'search' => [ 'type' => 'string', 'description' => 'Search term' ],
'type' => [ 'type' => 'string', 'description' => 'Product type (simple, variable, grouped, external)' ],
],
],
],
[
'name' => 'wc_get_product',
'description' => 'Get single WooCommerce product by ID',
'inputSchema' => [
'type' => 'object',
'properties' => [
'id' => [ 'type' => 'integer', 'description' => 'Product ID' ],
],
'required' => [ 'id' ],
],
],
[
'name' => 'wc_create_product',
'description' => 'Create a WooCommerce product',
'inputSchema' => [
'type' => 'object',
'properties' => [
'name' => [ 'type' => 'string', 'description' => 'Product name' ],
'type' => [ 'type' => 'string', 'enum' => [ 'simple', 'variable', 'grouped', 'external' ] ],
'regular_price' => [ 'type' => 'string', 'description' => 'Regular price' ],
'sale_price' => [ 'type' => 'string', 'description' => 'Sale price' ],
'description' => [ 'type' => 'string', 'description' => 'Full description' ],
'short_description' => [ 'type' => 'string', 'description' => 'Short description' ],
'sku' => [ 'type' => 'string', 'description' => 'SKU' ],
'status' => [ 'type' => 'string', 'enum' => [ 'publish', 'draft' ] ],
'stock_quantity' => [ 'type' => 'integer', 'description' => 'Stock quantity' ],
'categories' => [ 'type' => 'array', 'items' => [ 'type' => 'integer' ], 'description' => 'Category IDs' ],
],
'required' => [ 'name' ],
],
],
[
'name' => 'wc_update_product',
'description' => 'Update a WooCommerce product',
'inputSchema' => [
'type' => 'object',
'properties' => [
'id' => [ 'type' => 'integer' ],
'name' => [ 'type' => 'string' ],
'regular_price' => [ 'type' => 'string' ],
'sale_price' => [ 'type' => 'string' ],
'description' => [ 'type' => 'string' ],
'short_description' => [ 'type' => 'string' ],
'sku' => [ 'type' => 'string' ],
'status' => [ 'type' => 'string' ],
'stock_quantity' => [ 'type' => 'integer' ],
],
'required' => [ 'id' ],
],
],
[
'name' => 'wc_get_orders',
'description' => 'Get WooCommerce orders',
'inputSchema' => [
'type' => 'object',
'properties' => [
'per_page' => [ 'type' => 'integer', 'description' => 'Number of orders (max 100)' ],
'status' => [ 'type' => 'string', 'description' => 'Order status (processing, completed, on-hold, etc)' ],
],
],
],
[
'name' => 'wc_get_order',
'description' => 'Get single WooCommerce order by ID',
'inputSchema' => [
'type' => 'object',
'properties' => [
'id' => [ 'type' => 'integer', 'description' => 'Order ID' ],
],
'required' => [ 'id' ],
],
],
[
'name' => 'wc_update_order_status',
'description' => 'Update WooCommerce order status',
'inputSchema' => [
'type' => 'object',
'properties' => [
'id' => [ 'type' => 'integer', 'description' => 'Order ID' ],
'status' => [ 'type' => 'string', 'description' => 'New status (processing, completed, on-hold, cancelled, refunded)' ],
'note' => [ 'type' => 'string', 'description' => 'Optional order note' ],
],
'required' => [ 'id', 'status' ],
],
],
[
'name' => 'wc_get_customers',
'description' => 'Get WooCommerce customers',
'inputSchema' => [
'type' => 'object',
'properties' => [
'per_page' => [ 'type' => 'integer', 'description' => 'Number of customers (max 100)' ],
'search' => [ 'type' => 'string', 'description' => 'Search by name or email' ],
],
],
],
[
'name' => 'wc_get_store_stats',
'description' => 'Get WooCommerce store statistics (revenue, orders, products)',
'inputSchema' => [
'type' => 'object',
'properties' => [
'period' => [ 'type' => 'string', 'description' => 'Period: today, week, month, year', 'enum' => [ 'today', 'week', 'month', 'year' ] ],
],
],
],
];
}
/**
* Execute a WooCommerce MCP tool.
*
* @param string $name Tool name.
* @param array $args Tool arguments.
* @return mixed Result data.
* @throws \Exception If tool fails.
*/
public static function execute_tool( $name, $args ) {
if ( ! self::is_available() ) {
throw new \Exception( 'WooCommerce is not active' );
}
switch ( $name ) {
case 'wc_get_products':
$query_args = [
'limit' => min( intval( $args['per_page'] ?? 10 ), 100 ),
'status' => sanitize_text_field( $args['status'] ?? 'publish' ),
'return' => 'objects',
];
if ( ! empty( $args['search'] ) ) {
$query_args['s'] = sanitize_text_field( $args['search'] );
}
if ( ! empty( $args['category'] ) ) {
$query_args['category'] = [ sanitize_text_field( $args['category'] ) ];
}
if ( ! empty( $args['type'] ) ) {
$query_args['type'] = sanitize_text_field( $args['type'] );
}
$products = wc_get_products( $query_args );
return array_map( [ __CLASS__, 'format_product_summary' ], $products );
case 'wc_get_product':
$product = wc_get_product( intval( $args['id'] ) );
if ( ! $product ) {
throw new \Exception( 'Product not found' );
}
return self::format_product_detail( $product );
case 'wc_create_product':
$product = new \WC_Product_Simple();
$product->set_name( sanitize_text_field( $args['name'] ) );
if ( isset( $args['regular_price'] ) ) {
$product->set_regular_price( sanitize_text_field( $args['regular_price'] ) );
}
if ( isset( $args['sale_price'] ) ) {
$product->set_sale_price( sanitize_text_field( $args['sale_price'] ) );
}
if ( isset( $args['description'] ) ) {
$product->set_description( wp_kses_post( $args['description'] ) );
}
if ( isset( $args['short_description'] ) ) {
$product->set_short_description( wp_kses_post( $args['short_description'] ) );
}
if ( isset( $args['sku'] ) ) {
$product->set_sku( sanitize_text_field( $args['sku'] ) );
}
if ( isset( $args['stock_quantity'] ) ) {
$product->set_manage_stock( true );
$product->set_stock_quantity( intval( $args['stock_quantity'] ) );
}
if ( isset( $args['categories'] ) ) {
$product->set_category_ids( array_map( 'intval', $args['categories'] ) );
}
$product->set_status( in_array( $args['status'] ?? 'draft', [ 'publish', 'draft' ] ) ? $args['status'] : 'draft' );
$product_id = $product->save();
if ( ! $product_id ) {
throw new \Exception( 'Failed to create product' );
}
return [ 'id' => $product_id, 'message' => 'Product created successfully', 'url' => get_permalink( $product_id ) ];
case 'wc_update_product':
$product = wc_get_product( intval( $args['id'] ) );
if ( ! $product ) {
throw new \Exception( 'Product not found' );
}
if ( isset( $args['name'] ) ) {
$product->set_name( sanitize_text_field( $args['name'] ) );
}
if ( isset( $args['regular_price'] ) ) {
$product->set_regular_price( sanitize_text_field( $args['regular_price'] ) );
}
if ( isset( $args['sale_price'] ) ) {
$product->set_sale_price( sanitize_text_field( $args['sale_price'] ) );
}
if ( isset( $args['description'] ) ) {
$product->set_description( wp_kses_post( $args['description'] ) );
}
if ( isset( $args['short_description'] ) ) {
$product->set_short_description( wp_kses_post( $args['short_description'] ) );
}
if ( isset( $args['sku'] ) ) {
$product->set_sku( sanitize_text_field( $args['sku'] ) );
}
if ( isset( $args['status'] ) ) {
$product->set_status( sanitize_text_field( $args['status'] ) );
}
if ( isset( $args['stock_quantity'] ) ) {
$product->set_manage_stock( true );
$product->set_stock_quantity( intval( $args['stock_quantity'] ) );
}
$product->save();
return [ 'id' => $args['id'], 'message' => 'Product updated successfully' ];
case 'wc_get_orders':
$limit = min( intval( $args['per_page'] ?? 10 ), 100 );
$status = ! empty( $args['status'] ) ? sanitize_text_field( $args['status'] ) : 'any';
$orders = wc_get_orders( [
'limit' => $limit,
'status' => $status,
'orderby' => 'date',
'order' => 'DESC',
] );
return array_map( [ __CLASS__, 'format_order_summary' ], $orders );
case 'wc_get_order':
$order = wc_get_order( intval( $args['id'] ) );
if ( ! $order ) {
throw new \Exception( 'Order not found' );
}
return self::format_order_detail( $order );
case 'wc_update_order_status':
$order = wc_get_order( intval( $args['id'] ) );
if ( ! $order ) {
throw new \Exception( 'Order not found' );
}
$allowed_statuses = [ 'pending', 'processing', 'on-hold', 'completed', 'cancelled', 'refunded', 'failed' ];
$new_status = sanitize_text_field( $args['status'] );
if ( ! in_array( $new_status, $allowed_statuses ) ) {
throw new \Exception( 'Invalid order status' );
}
$note = ! empty( $args['note'] ) ? sanitize_text_field( $args['note'] ) : '';
$order->update_status( $new_status, $note );
return [ 'id' => $args['id'], 'status' => $new_status, 'message' => 'Order status updated' ];
case 'wc_get_customers':
$limit = min( intval( $args['per_page'] ?? 10 ), 100 );
$customer_args = [
'number' => $limit,
'role' => 'customer',
];
if ( ! empty( $args['search'] ) ) {
$customer_args['search'] = '*' . sanitize_text_field( $args['search'] ) . '*';
$customer_args['search_columns'] = [ 'user_login', 'user_email', 'display_name' ];
}
$customers = get_users( $customer_args );
return array_map( function( $user ) {
$customer = new \WC_Customer( $user->ID );
return [
'id' => $user->ID,
'display_name' => $user->display_name,
'order_count' => $customer->get_order_count(),
'total_spent' => $customer->get_total_spent(),
'city' => $customer->get_billing_city(),
'country' => $customer->get_billing_country(),
];
}, $customers );
case 'wc_get_store_stats':
return self::get_store_stats( $args['period'] ?? 'month' );
default:
throw new \Exception( 'Unknown WooCommerce tool: ' . esc_html( $name ) );
}
}
private static function format_product_summary( $product ) {
return [
'id' => $product->get_id(),
'name' => $product->get_name(),
'type' => $product->get_type(),
'status' => $product->get_status(),
'price' => $product->get_price(),
'regular_price' => $product->get_regular_price(),
'sale_price' => $product->get_sale_price(),
'sku' => $product->get_sku(),
'stock_status' => $product->get_stock_status(),
'url' => get_permalink( $product->get_id() ),
];
}
private static function format_product_detail( $product ) {
return [
'id' => $product->get_id(),
'name' => $product->get_name(),
'type' => $product->get_type(),
'status' => $product->get_status(),
'description' => $product->get_description(),
'short_description' => $product->get_short_description(),
'price' => $product->get_price(),
'regular_price' => $product->get_regular_price(),
'sale_price' => $product->get_sale_price(),
'sku' => $product->get_sku(),
'stock_status' => $product->get_stock_status(),
'stock_quantity' => $product->get_stock_quantity(),
'weight' => $product->get_weight(),
'categories' => wp_get_post_terms( $product->get_id(), 'product_cat', [ 'fields' => 'names' ] ),
'tags' => wp_get_post_terms( $product->get_id(), 'product_tag', [ 'fields' => 'names' ] ),
'url' => get_permalink( $product->get_id() ),
'date_created' => $product->get_date_created() ? $product->get_date_created()->format( 'Y-m-d H:i:s' ) : null,
];
}
private static function format_order_summary( $order ) {
return [
'id' => $order->get_id(),
'status' => $order->get_status(),
'total' => $order->get_total(),
'currency' => $order->get_currency(),
'items' => $order->get_item_count(),
'customer' => $order->get_billing_first_name() . ' ' . $order->get_billing_last_name(),
'date' => $order->get_date_created() ? $order->get_date_created()->format( 'Y-m-d H:i:s' ) : null,
];
}
private static function format_order_detail( $order ) {
$items = [];
foreach ( $order->get_items() as $item ) {
$items[] = [
'name' => $item->get_name(),
'quantity' => $item->get_quantity(),
'total' => $item->get_total(),
'sku' => $item->get_product() ? $item->get_product()->get_sku() : '',
];
}
return [
'id' => $order->get_id(),
'status' => $order->get_status(),
'total' => $order->get_total(),
'subtotal' => $order->get_subtotal(),
'tax' => $order->get_total_tax(),
'shipping' => $order->get_shipping_total(),
'currency' => $order->get_currency(),
'payment_method' => $order->get_payment_method_title(),
'customer_name' => $order->get_billing_first_name() . ' ' . $order->get_billing_last_name(),
'billing_city' => $order->get_billing_city(),
'billing_country' => $order->get_billing_country(),
'items' => $items,
'date_created' => $order->get_date_created() ? $order->get_date_created()->format( 'Y-m-d H:i:s' ) : null,
'date_paid' => $order->get_date_paid() ? $order->get_date_paid()->format( 'Y-m-d H:i:s' ) : null,
];
}
private static function get_store_stats( $period ) {
$periods = [
'today' => '-1 day',
'week' => '-7 days',
'month' => '-30 days',
'year' => '-365 days',
];
$after = gmdate( 'Y-m-d', strtotime( $periods[ $period ] ?? $periods['month'] ) );
$orders = wc_get_orders( [
'limit' => -1,
'status' => [ 'completed', 'processing' ],
'date_after' => $after,
'return' => 'objects',
] );
$revenue = 0;
$order_count = count( $orders );
foreach ( $orders as $order ) {
$revenue += (float) $order->get_total();
}
$product_count = wp_count_posts( 'product' );
return [
'period' => $period,
'revenue' => round( $revenue, 2 ),
'order_count' => $order_count,
'average_order' => $order_count > 0 ? round( $revenue / $order_count, 2 ) : 0,
'total_products' => (int) $product_count->publish,
'currency' => get_woocommerce_currency(),
];
}
}
@@ -0,0 +1,4 @@
<?php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
@@ -0,0 +1,181 @@
<?php
namespace Royal_MCP\MCP;
if (!defined('ABSPATH')) {
exit;
}
class Client {
private $servers = [];
public function __construct() {
$this->load_servers();
}
private function load_servers() {
$settings = get_option('royal_mcp_settings', []);
$this->servers = $settings['mcp_servers'] ?? [];
}
public function get_enabled_servers() {
return array_filter($this->servers, function($server) {
return isset($server['enabled']) && $server['enabled'];
});
}
public function send_request($server_name, $method, $endpoint, $data = [], $headers = []) {
$server = $this->get_server_by_name($server_name);
if (!$server) {
return new \WP_Error(
'server_not_found',
/* translators: %s: MCP server name */
sprintf(__('MCP server "%s" not found', 'royal-mcp'), $server_name),
['status' => 404]
);
}
if (!isset($server['enabled']) || !$server['enabled']) {
return new \WP_Error(
'server_disabled',
/* translators: %s: MCP server name */
sprintf(__('MCP server "%s" is disabled', 'royal-mcp'), $server_name),
['status' => 403]
);
}
$url = trailingslashit($server['url']) . ltrim($endpoint, '/');
// SSRF protection: validate URL before making request
$url_check = \Royal_MCP\Platform\Registry::validate_external_url( $url );
if ( is_wp_error( $url_check ) ) {
return $url_check;
}
$args = [
'method' => strtoupper($method),
'headers' => array_merge([
'Content-Type' => 'application/json',
], $headers),
'timeout' => 30,
];
if (!empty($server['api_key'])) {
$args['headers']['Authorization'] = 'Bearer ' . $server['api_key'];
}
if (!empty($data)) {
if ($method === 'GET') {
$url = add_query_arg($data, $url);
} else {
$args['body'] = json_encode($data);
}
}
$response = wp_remote_request($url, $args);
if (is_wp_error($response)) {
$this->log_mcp_request($server_name, $endpoint, $data, $response->get_error_message(), 'error');
return $response;
}
$status_code = wp_remote_retrieve_response_code($response);
$body = wp_remote_retrieve_body($response);
$decoded = json_decode($body, true);
$this->log_mcp_request($server_name, $endpoint, $data, $body, $status_code >= 200 && $status_code < 300 ? 'success' : 'error');
if ($status_code >= 200 && $status_code < 300) {
return $decoded;
} else {
return new \WP_Error(
'mcp_request_failed',
/* translators: %d: HTTP status code */
sprintf(__('MCP request failed with status %d', 'royal-mcp'), $status_code),
['status' => $status_code, 'body' => $decoded]
);
}
}
public function get_server_by_name($name) {
foreach ($this->servers as $server) {
if ($server['name'] === $name) {
return $server;
}
}
return null;
}
public function test_connection($server_name) {
$server = $this->get_server_by_name($server_name);
if (!$server) {
return [
'success' => false,
'message' => __('Server not found', 'royal-mcp'),
];
}
// SSRF protection: validate URL before making request
$url_check = \Royal_MCP\Platform\Registry::validate_external_url( $server['url'] );
if ( is_wp_error( $url_check ) ) {
return [
'success' => false,
'message' => $url_check->get_error_message(),
];
}
$response = wp_remote_get($server['url'], [
'timeout' => 10,
'headers' => !empty($server['api_key']) ? [
'Authorization' => 'Bearer ' . $server['api_key'],
] : [],
]);
if (is_wp_error($response)) {
return [
'success' => false,
'message' => $response->get_error_message(),
];
}
$status_code = wp_remote_retrieve_response_code($response);
return [
'success' => $status_code >= 200 && $status_code < 500,
/* translators: %d: HTTP status code */
'message' => sprintf(__('Server responded with status %d', 'royal-mcp'), $status_code),
'status_code' => $status_code,
];
}
private function log_mcp_request($server_name, $endpoint, $request_data, $response_data, $status) {
global $wpdb;
$table_name = $wpdb->prefix . 'royal_mcp_logs';
$wpdb->insert(
$table_name,
[
'mcp_server' => $server_name,
'action' => $endpoint,
'request_data' => json_encode($request_data),
'response_data' => is_string($response_data) ? $response_data : json_encode($response_data),
'status' => $status,
],
['%s', '%s', '%s', '%s', '%s']
);
}
public function broadcast_to_servers($endpoint, $data = []) {
$enabled_servers = $this->get_enabled_servers();
$results = [];
foreach ($enabled_servers as $server) {
$result = $this->send_request($server['name'], 'POST', $endpoint, $data);
$results[$server['name']] = $result;
}
return $results;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
@@ -0,0 +1,26 @@
<?php
namespace Royal_MCP\OAuth;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* PKCE (Proof Key for Code Exchange) utility.
*
* Implements S256 code challenge verification per OAuth 2.1 / RFC 7636.
*/
class PKCE {
/**
* Verify a PKCE code_verifier against a stored code_challenge.
*
* @param string $code_verifier The verifier sent by the client in the token request.
* @param string $code_challenge The challenge stored from the authorization request.
* @return bool True if the verifier matches the challenge.
*/
public static function verify( $code_verifier, $code_challenge ) {
$computed = rtrim( strtr( base64_encode( hash( 'sha256', $code_verifier, true ) ), '+/', '-_' ), '=' );
return hash_equals( $code_challenge, $computed );
}
}
@@ -0,0 +1,497 @@
<?php
namespace Royal_MCP\OAuth;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* OAuth 2.0 Authorization Server for MCP.
*
* Implements the OAuth 2.1 authorization code flow with PKCE
* per the MCP specification (2025-03-26).
*
* Endpoints (served at domain root via rewrite rules):
* - GET /.well-known/oauth-authorization-server → metadata()
* - POST /register → register()
* - GET /authorize → authorize_get()
* - POST /authorize → authorize_post()
* - POST /token → token()
*/
class Server {
/**
* Dispatch an OAuth request based on the query var value.
*
* @param string $action The royal_mcp_oauth query var (metadata|authorize|token|register).
*/
public function dispatch( $action ) {
$request_method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : 'GET';
// Set CORS headers for token and register endpoints (may be called cross-origin).
if ( in_array( $action, [ 'token', 'register', 'metadata', 'protected_resource' ], true ) ) {
header( 'Access-Control-Allow-Origin: *' );
header( 'Access-Control-Allow-Methods: GET, POST, OPTIONS' );
header( 'Access-Control-Allow-Headers: Content-Type, Authorization' );
if ( 'OPTIONS' === $request_method ) {
status_header( 204 );
exit;
}
}
switch ( $action ) {
case 'protected_resource':
$this->protected_resource_metadata();
break;
case 'metadata':
$this->metadata();
break;
case 'register':
$this->register( $request_method );
break;
case 'authorize':
if ( 'POST' === $request_method ) {
$this->authorize_post();
} else {
$this->authorize_get();
}
break;
case 'token':
$this->token( $request_method );
break;
default:
status_header( 404 );
exit;
}
}
/* ------------------------------------------------------------------
* GET /.well-known/oauth-protected-resource (RFC 9728)
* Tells the client which authorization server protects this resource.
* ----------------------------------------------------------------*/
private function protected_resource_metadata() {
$base = home_url();
$metadata = [
'resource' => $base . '/wp-json/royal-mcp/v1',
'authorization_servers' => [ $base ],
'bearer_methods_supported' => [ 'header' ],
'scopes_supported' => [ 'mcp:full' ],
];
$this->json_response( $metadata, 200, [ 'Cache-Control' => 'public, max-age=3600' ] );
}
/* ------------------------------------------------------------------
* GET /.well-known/oauth-authorization-server
* ----------------------------------------------------------------*/
private function metadata() {
$base = home_url();
$metadata = [
'issuer' => $base,
'authorization_endpoint' => $base . '/authorize',
'token_endpoint' => $base . '/token',
'registration_endpoint' => $base . '/register',
'response_types_supported' => [ 'code' ],
'grant_types_supported' => [ 'authorization_code', 'refresh_token' ],
'token_endpoint_auth_methods_supported' => [ 'none', 'client_secret_post' ],
'code_challenge_methods_supported' => [ 'S256' ],
'scopes_supported' => [ 'mcp:full' ],
'service_documentation' => 'https://royalplugins.com/support/royal-mcp/',
];
$this->json_response( $metadata, 200, [ 'Cache-Control' => 'public, max-age=3600' ] );
}
/* ------------------------------------------------------------------
* POST /register — Dynamic Client Registration (RFC 7591)
* ----------------------------------------------------------------*/
private function register( $request_method = 'GET' ) {
if ( 'POST' !== $request_method ) {
$this->json_error( 'invalid_request', 'POST method required.', 405 );
}
// Rate-limit registrations by IP.
$ip = $this->get_client_ip();
$transient_key = 'royal_mcp_reg_rate_' . md5( $ip );
$count = (int) get_transient( $transient_key );
if ( $count >= 10 ) {
$this->json_error( 'rate_limit', 'Too many registration attempts. Try again later.', 429 );
}
set_transient( $transient_key, $count + 1, 60 );
// Parse body.
$body = json_decode( file_get_contents( 'php://input' ), true );
if ( ! is_array( $body ) ) {
$this->json_error( 'invalid_request', 'Invalid JSON body.', 400 );
}
// Validate redirect_uris.
$redirect_uris = isset( $body['redirect_uris'] ) && is_array( $body['redirect_uris'] ) ? $body['redirect_uris'] : [];
foreach ( $redirect_uris as $uri ) {
if ( ! $this->is_valid_redirect_uri( $uri ) ) {
$this->json_error( 'invalid_redirect_uri', 'Redirect URIs must be localhost or HTTPS.', 400 );
}
}
$client = Token_Store::register_client( $body );
$this->json_response( $client, 201 );
}
/* ------------------------------------------------------------------
* GET /authorize — Show consent screen
* ----------------------------------------------------------------*/
private function authorize_get() {
// OAuth authorize endpoint — params come from external MCP client, no WP nonce possible.
// phpcs:disable WordPress.Security.NonceVerification.Recommended
$response_type = isset( $_GET['response_type'] ) ? sanitize_text_field( wp_unslash( $_GET['response_type'] ) ) : '';
$client_id = isset( $_GET['client_id'] ) ? sanitize_text_field( wp_unslash( $_GET['client_id'] ) ) : '';
$redirect_uri = isset( $_GET['redirect_uri'] ) ? sanitize_text_field( wp_unslash( $_GET['redirect_uri'] ) ) : '';
$code_challenge = isset( $_GET['code_challenge'] ) ? sanitize_text_field( wp_unslash( $_GET['code_challenge'] ) ) : '';
$code_challenge_method = isset( $_GET['code_challenge_method'] ) ? sanitize_text_field( wp_unslash( $_GET['code_challenge_method'] ) ) : '';
$state = isset( $_GET['state'] ) ? sanitize_text_field( wp_unslash( $_GET['state'] ) ) : '';
$scope = isset( $_GET['scope'] ) ? sanitize_text_field( wp_unslash( $_GET['scope'] ) ) : 'mcp:full';
// Validate client FIRST — never redirect to unvalidated redirect_uri (OAuth 2.1 §4.1.2.1).
$client = Token_Store::get_client( $client_id );
if ( ! $client ) {
wp_die(
esc_html__( 'Unknown client_id. The application has not been registered.', 'royal-mcp' ),
esc_html__( 'Authorization Error', 'royal-mcp' ),
[ 'response' => 400 ]
);
}
// Validate redirect_uri BEFORE any redirects.
if ( empty( $redirect_uri ) || ! Token_Store::validate_redirect_uri( $redirect_uri, $client ) ) {
wp_die(
esc_html__( 'Invalid redirect_uri.', 'royal-mcp' ),
esc_html__( 'Authorization Error', 'royal-mcp' ),
[ 'response' => 400 ]
);
}
// Now safe to redirect errors to the validated redirect_uri.
if ( 'code' !== $response_type ) {
$this->authorize_error( $redirect_uri, $state, 'unsupported_response_type', 'Only response_type=code is supported.' );
}
// PKCE is required.
if ( empty( $code_challenge ) || 'S256' !== $code_challenge_method ) {
$this->authorize_error( $redirect_uri, $state, 'invalid_request', 'PKCE with code_challenge_method=S256 is required.' );
}
// Ensure user is logged into WordPress.
if ( ! is_user_logged_in() ) {
// Build the full authorize URL with all params to come back after login.
$authorize_url = add_query_arg(
[
'response_type' => $response_type,
'client_id' => $client_id,
'redirect_uri' => $redirect_uri,
'code_challenge' => $code_challenge,
'code_challenge_method' => $code_challenge_method,
'state' => $state,
'scope' => $scope,
],
home_url( '/authorize' )
);
wp_safe_redirect( wp_login_url( $authorize_url ) );
exit;
}
// User is logged in — render consent screen.
$current_user = wp_get_current_user();
$site_name = get_bloginfo( 'name' );
// Pass variables to the template.
$rmcp_oauth = [
'client_name' => $client['client_name'] ?? $client_id,
'client_id' => $client_id,
'redirect_uri' => $redirect_uri,
'code_challenge' => $code_challenge,
'code_challenge_method' => $code_challenge_method,
'state' => $state,
'scope' => $scope,
'user_display_name' => $current_user->display_name,
'site_name' => $site_name,
'nonce' => wp_create_nonce( 'royal_mcp_authorize' ),
];
// Load the consent template.
include ROYAL_MCP_PLUGIN_DIR . 'templates/admin/authorize.php';
exit;
// phpcs:enable WordPress.Security.NonceVerification.Recommended
}
/* ------------------------------------------------------------------
* POST /authorize — Process consent
* ----------------------------------------------------------------*/
private function authorize_post() {
// Verify nonce.
$nonce = isset( $_POST['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ) : '';
if ( ! wp_verify_nonce( $nonce, 'royal_mcp_authorize' ) ) {
wp_die(
esc_html__( 'Security check failed. Please try again.', 'royal-mcp' ),
esc_html__( 'Authorization Error', 'royal-mcp' ),
[ 'response' => 403 ]
);
}
$redirect_uri = isset( $_POST['redirect_uri'] ) ? sanitize_text_field( wp_unslash( $_POST['redirect_uri'] ) ) : '';
$client_id = isset( $_POST['client_id'] ) ? sanitize_text_field( wp_unslash( $_POST['client_id'] ) ) : '';
$code_challenge = isset( $_POST['code_challenge'] ) ? sanitize_text_field( wp_unslash( $_POST['code_challenge'] ) ) : '';
$code_challenge_method = isset( $_POST['code_challenge_method'] ) ? sanitize_text_field( wp_unslash( $_POST['code_challenge_method'] ) ) : '';
$state = isset( $_POST['state'] ) ? sanitize_text_field( wp_unslash( $_POST['state'] ) ) : '';
$scope = isset( $_POST['scope'] ) ? sanitize_text_field( wp_unslash( $_POST['scope'] ) ) : 'mcp:full';
$action = isset( $_POST['authorize_action'] ) ? sanitize_text_field( wp_unslash( $_POST['authorize_action'] ) ) : '';
// User denied.
if ( 'deny' === $action ) {
$this->authorize_error( $redirect_uri, $state, 'access_denied', 'The user denied the authorization request.' );
}
// Validate client still exists.
$client = Token_Store::get_client( $client_id );
if ( ! $client ) {
wp_die( esc_html__( 'Unknown client.', 'royal-mcp' ), '', [ 'response' => 400 ] );
}
// Validate redirect_uri again.
if ( empty( $redirect_uri ) || ! Token_Store::validate_redirect_uri( $redirect_uri, $client ) ) {
wp_die( esc_html__( 'Invalid redirect URI.', 'royal-mcp' ), '', [ 'response' => 400 ] );
}
// Must be logged in.
if ( ! is_user_logged_in() ) {
wp_die( esc_html__( 'Not authenticated.', 'royal-mcp' ), '', [ 'response' => 401 ] );
}
// Generate authorization code.
$code = bin2hex( random_bytes( 32 ) );
Token_Store::store_auth_code( $code, [
'user_id' => get_current_user_id(),
'client_id' => $client_id,
'redirect_uri' => $redirect_uri,
'code_challenge' => $code_challenge,
'code_challenge_method' => $code_challenge_method,
'scope' => $scope,
] );
// Redirect back to the client with the code.
$redirect = add_query_arg(
[
'code' => $code,
'state' => $state,
],
$redirect_uri
);
// phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- OAuth callback URI is external (e.g. claude.ai).
wp_redirect( $redirect );
exit;
}
/* ------------------------------------------------------------------
* POST /token — Token exchange
* ----------------------------------------------------------------*/
private function token( $request_method = 'GET' ) {
// OAuth token endpoint — external MCP clients cannot provide WP nonces.
// phpcs:disable WordPress.Security.NonceVerification.Missing
if ( 'POST' !== $request_method ) {
$this->json_error( 'invalid_request', 'POST method required.', 405 );
}
// Parse form-encoded body (standard OAuth).
$grant_type = isset( $_POST['grant_type'] ) ? sanitize_text_field( wp_unslash( $_POST['grant_type'] ) ) : '';
switch ( $grant_type ) {
case 'authorization_code':
$this->token_authorization_code();
break;
case 'refresh_token':
$this->token_refresh();
break;
default:
$this->json_error( 'unsupported_grant_type', 'Supported grant types: authorization_code, refresh_token.', 400 );
}
}
/**
* Exchange an authorization code for tokens.
*/
private function token_authorization_code() {
$code = isset( $_POST['code'] ) ? sanitize_text_field( wp_unslash( $_POST['code'] ) ) : '';
$redirect_uri = isset( $_POST['redirect_uri'] ) ? sanitize_text_field( wp_unslash( $_POST['redirect_uri'] ) ) : '';
$client_id = isset( $_POST['client_id'] ) ? sanitize_text_field( wp_unslash( $_POST['client_id'] ) ) : '';
$code_verifier = isset( $_POST['code_verifier'] ) ? sanitize_text_field( wp_unslash( $_POST['code_verifier'] ) ) : '';
if ( empty( $code ) || empty( $client_id ) || empty( $code_verifier ) || empty( $redirect_uri ) ) {
$this->json_error( 'invalid_request', 'Missing required parameters: code, client_id, code_verifier, redirect_uri.', 400 );
}
// Consume the code (single-use).
$code_data = Token_Store::consume_auth_code( $code );
if ( ! $code_data ) {
$this->json_error( 'invalid_grant', 'Authorization code is invalid, expired, or already used.', 400 );
}
// Validate client_id.
if ( ! hash_equals( $code_data['client_id'], $client_id ) ) {
$this->json_error( 'invalid_grant', 'client_id mismatch.', 400 );
}
// Validate redirect_uri (must match exactly).
if ( $redirect_uri !== $code_data['redirect_uri'] ) {
$this->json_error( 'invalid_grant', 'redirect_uri mismatch.', 400 );
}
// Verify PKCE.
if ( ! PKCE::verify( $code_verifier, $code_data['code_challenge'] ) ) {
$this->json_error( 'invalid_grant', 'PKCE verification failed.', 400 );
}
// Authenticate confidential clients.
$client = Token_Store::get_client( $client_id );
if ( $client && 'client_secret_post' === ( $client['token_endpoint_auth_method'] ?? 'none' ) ) {
$client_secret = isset( $_POST['client_secret'] ) ? sanitize_text_field( wp_unslash( $_POST['client_secret'] ) ) : '';
if ( empty( $client_secret ) || ! hash_equals( $client['client_secret_hash'], hash( 'sha256', $client_secret ) ) ) {
$this->json_error( 'invalid_client', 'Client authentication failed.', 401 );
}
}
// Issue tokens.
$tokens = Token_Store::create_token_pair( $client_id, $code_data['user_id'], $code_data['scope'] ?? '' );
// Include resource indicator if client sent one (RFC 8707).
$resource = isset( $_POST['resource'] ) ? sanitize_text_field( wp_unslash( $_POST['resource'] ) ) : '';
if ( ! empty( $resource ) ) {
$tokens['resource'] = $resource;
}
$this->json_response( $tokens, 200, [ 'Cache-Control' => 'no-store', 'Pragma' => 'no-cache' ] );
}
/**
* Refresh an access token.
*/
private function token_refresh() {
$refresh_token = isset( $_POST['refresh_token'] ) ? sanitize_text_field( wp_unslash( $_POST['refresh_token'] ) ) : '';
$client_id = isset( $_POST['client_id'] ) ? sanitize_text_field( wp_unslash( $_POST['client_id'] ) ) : '';
if ( empty( $refresh_token ) || empty( $client_id ) ) {
$this->json_error( 'invalid_request', 'Missing required parameters: refresh_token, client_id.', 400 );
}
// Consume the refresh token (rotation — old one is revoked).
$token_data = Token_Store::consume_refresh_token( $refresh_token );
if ( ! $token_data ) {
$this->json_error( 'invalid_grant', 'Refresh token is invalid, expired, or revoked.', 400 );
}
// Validate client_id (timing-safe).
if ( ! hash_equals( $token_data['client_id'], $client_id ) ) {
$this->json_error( 'invalid_grant', 'client_id mismatch.', 400 );
}
// Issue new token pair.
$tokens = Token_Store::create_token_pair( $client_id, (int) $token_data['user_id'], $token_data['scope'] ?? '' );
$this->json_response( $tokens, 200, [ 'Cache-Control' => 'no-store', 'Pragma' => 'no-cache' ] );
// phpcs:enable WordPress.Security.NonceVerification.Missing
}
/* ------------------------------------------------------------------
* Helpers
* ----------------------------------------------------------------*/
/**
* Send a JSON response and exit.
*/
private function json_response( $data, $status = 200, $extra_headers = [] ) {
status_header( $status );
header( 'Content-Type: application/json; charset=utf-8' );
foreach ( $extra_headers as $key => $value ) {
header( $key . ': ' . $value );
}
echo wp_json_encode( $data );
exit;
}
/**
* Send an OAuth error response and exit.
*/
private function json_error( $error, $description, $status = 400 ) {
$this->json_response(
[
'error' => $error,
'error_description' => $description,
],
$status
);
}
/**
* Redirect to the client with an error (authorize endpoint).
*/
private function authorize_error( $redirect_uri, $state, $error, $description ) {
if ( empty( $redirect_uri ) ) {
wp_die( esc_html( $description ), esc_html__( 'Authorization Error', 'royal-mcp' ), [ 'response' => 400 ] );
}
$redirect = add_query_arg(
[
'error' => $error,
'error_description' => $description,
'state' => $state,
],
$redirect_uri
);
// phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- OAuth error redirect to client's registered callback URI.
wp_redirect( $redirect );
exit;
}
/**
* Validate a redirect URI (must be localhost or HTTPS).
*/
private function is_valid_redirect_uri( $uri ) {
$parsed = wp_parse_url( $uri );
if ( ! $parsed || empty( $parsed['scheme'] ) || empty( $parsed['host'] ) ) {
return false;
}
$is_localhost = in_array( $parsed['host'], [ 'localhost', '127.0.0.1', '::1' ], true );
return $is_localhost || 'https' === $parsed['scheme'];
}
/**
* Get the client IP address.
*/
private function get_client_ip() {
if ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
$ips = explode( ',', sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) );
return trim( $ips[0] );
}
return isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '0.0.0.0';
}
}
@@ -0,0 +1,404 @@
<?php
namespace Royal_MCP\OAuth;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* OAuth Token Store.
*
* Handles CRUD for access/refresh tokens, authorization codes,
* and dynamically registered OAuth clients.
*/
class Token_Store {
/** Token lifetimes in seconds. */
const ACCESS_TOKEN_TTL = 3600; // 1 hour
const REFRESH_TOKEN_TTL = 2592000; // 30 days
const AUTH_CODE_TTL = 600; // 10 minutes
/* ------------------------------------------------------------------
* Table helpers
* ----------------------------------------------------------------*/
/**
* Get the tokens table name.
*/
public static function tokens_table() {
global $wpdb;
return $wpdb->prefix . 'royal_mcp_oauth_tokens';
}
/**
* Get the clients table name.
*/
public static function clients_table() {
global $wpdb;
return $wpdb->prefix . 'royal_mcp_oauth_clients';
}
/**
* Create both OAuth tables. Called from plugin activation.
*/
public static function create_tables() {
global $wpdb;
$charset_collate = $wpdb->get_charset_collate();
$tokens_table = self::tokens_table();
$clients_table = self::clients_table();
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
// dbDelta needs each CREATE TABLE as a separate call.
dbDelta( "CREATE TABLE IF NOT EXISTS $tokens_table (
id bigint(20) NOT NULL AUTO_INCREMENT,
token_hash varchar(64) NOT NULL,
token_type varchar(20) NOT NULL,
client_id varchar(255) NOT NULL,
user_id bigint(20) NOT NULL,
scope varchar(255) DEFAULT '',
expires_at datetime NOT NULL,
created_at datetime DEFAULT CURRENT_TIMESTAMP NOT NULL,
revoked tinyint(1) DEFAULT 0 NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY token_hash (token_hash),
KEY client_id (client_id),
KEY user_id (user_id),
KEY expires_at (expires_at)
) $charset_collate;" );
dbDelta( "CREATE TABLE IF NOT EXISTS $clients_table (
id bigint(20) NOT NULL AUTO_INCREMENT,
client_id varchar(255) NOT NULL,
client_secret_hash varchar(64) DEFAULT NULL,
client_name varchar(255) NOT NULL,
redirect_uris text NOT NULL,
grant_types varchar(255) DEFAULT 'authorization_code' NOT NULL,
token_endpoint_auth_method varchar(50) DEFAULT 'none' NOT NULL,
created_at datetime DEFAULT CURRENT_TIMESTAMP NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY client_id (client_id)
) $charset_collate;" );
}
/**
* Drop OAuth tables. Called from uninstall.
*/
public static function drop_tables() {
global $wpdb;
$tokens_table = esc_sql( self::tokens_table() );
$clients_table = esc_sql( self::clients_table() );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->query( "DROP TABLE IF EXISTS `{$tokens_table}`" );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->query( "DROP TABLE IF EXISTS `{$clients_table}`" );
}
/* ------------------------------------------------------------------
* Authorization codes (stored as transients — short-lived)
* ----------------------------------------------------------------*/
/**
* Store an authorization code.
*
* @param string $code The raw authorization code.
* @param array $data Payload: user_id, client_id, redirect_uri, code_challenge, code_challenge_method, scope.
*/
public static function store_auth_code( $code, array $data ) {
$data['created_at'] = time();
set_transient( 'royal_mcp_authcode_' . hash( 'sha256', $code ), $data, self::AUTH_CODE_TTL );
}
/**
* Consume an authorization code (single-use).
*
* @param string $code The raw code presented by the client.
* @return array|false The stored data, or false if invalid/expired.
*/
public static function consume_auth_code( $code ) {
$key = 'royal_mcp_authcode_' . hash( 'sha256', $code );
$data = get_transient( $key );
// Immediately delete — codes are single-use.
delete_transient( $key );
return $data;
}
/* ------------------------------------------------------------------
* Access / Refresh tokens
* ----------------------------------------------------------------*/
/**
* Generate and store a token pair (access + refresh).
*
* @param string $client_id WordPress OAuth client ID.
* @param int $user_id WordPress user ID.
* @param string $scope Space-separated scopes.
* @return array [ 'access_token' => …, 'refresh_token' => …, 'expires_in' => … ]
*/
public static function create_token_pair( $client_id, $user_id, $scope = '' ) {
$access_token = bin2hex( random_bytes( 32 ) );
$refresh_token = bin2hex( random_bytes( 32 ) );
self::store_token( $access_token, 'access', $client_id, $user_id, $scope, self::ACCESS_TOKEN_TTL );
self::store_token( $refresh_token, 'refresh', $client_id, $user_id, $scope, self::REFRESH_TOKEN_TTL );
return [
'access_token' => $access_token,
'token_type' => 'Bearer',
'expires_in' => self::ACCESS_TOKEN_TTL,
'refresh_token' => $refresh_token,
'scope' => $scope,
];
}
/**
* Store a single token (hashed) in the database.
*/
private static function store_token( $raw_token, $type, $client_id, $user_id, $scope, $ttl ) {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->insert(
self::tokens_table(),
[
'token_hash' => hash( 'sha256', $raw_token ),
'token_type' => $type,
'client_id' => $client_id,
'user_id' => $user_id,
'scope' => $scope,
'expires_at' => gmdate( 'Y-m-d H:i:s', time() + $ttl ),
],
[ '%s', '%s', '%s', '%d', '%s', '%s' ]
);
}
/**
* Validate a Bearer token.
*
* @param string $raw_token The raw access token from the Authorization header.
* @return array|false Token row (with user_id, client_id, scope) or false.
*/
public static function validate_token( $raw_token ) {
global $wpdb;
$table = self::tokens_table();
$hash = hash( 'sha256', $raw_token );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from safe helper method.
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM `{$table}` WHERE token_hash = %s AND token_type = 'access' AND revoked = 0 AND expires_at > %s LIMIT 1",
$hash,
gmdate( 'Y-m-d H:i:s' )
),
ARRAY_A
);
return $row ? $row : false;
}
/**
* Validate and consume a refresh token (token rotation).
*
* @param string $raw_refresh_token The raw refresh token.
* @return array|false Token row or false.
*/
public static function consume_refresh_token( $raw_refresh_token ) {
global $wpdb;
$table = self::tokens_table();
$hash = hash( 'sha256', $raw_refresh_token );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from safe helper method.
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM `{$table}` WHERE token_hash = %s AND token_type = 'refresh' AND revoked = 0 AND expires_at > %s LIMIT 1",
$hash,
gmdate( 'Y-m-d H:i:s' )
),
ARRAY_A
);
if ( ! $row ) {
return false;
}
// Revoke the old refresh token (rotation).
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->update(
$table,
[ 'revoked' => 1 ],
[ 'id' => $row['id'] ],
[ '%d' ],
[ '%d' ]
);
return $row;
}
/**
* Revoke all tokens for a client+user combination.
*/
public static function revoke_tokens_for_user( $client_id, $user_id ) {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->update(
self::tokens_table(),
[ 'revoked' => 1 ],
[ 'client_id' => $client_id, 'user_id' => $user_id ],
[ '%d' ],
[ '%s', '%d' ]
);
}
/**
* Delete expired and revoked tokens. Called by scheduled cleanup.
*/
public static function cleanup_expired() {
global $wpdb;
$table = esc_sql( self::tokens_table() );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->query(
$wpdb->prepare(
"DELETE FROM `{$table}` WHERE revoked = 1 OR expires_at < %s",
gmdate( 'Y-m-d H:i:s' )
)
);
}
/* ------------------------------------------------------------------
* Dynamic client registration
* ----------------------------------------------------------------*/
/**
* Register a new OAuth client.
*
* @param array $data Client registration data.
* @return array The stored client record (with generated client_id).
*/
public static function register_client( array $data ) {
global $wpdb;
$client_id = 'rmcp_' . bin2hex( random_bytes( 16 ) );
$client_secret = null;
$client_secret_hash = null;
$auth_method = isset( $data['token_endpoint_auth_method'] ) ? sanitize_text_field( $data['token_endpoint_auth_method'] ) : 'none';
if ( 'client_secret_post' === $auth_method ) {
$client_secret = bin2hex( random_bytes( 32 ) );
$client_secret_hash = hash( 'sha256', $client_secret );
}
$redirect_uris = isset( $data['redirect_uris'] ) && is_array( $data['redirect_uris'] )
? array_map( 'sanitize_url', $data['redirect_uris'] )
: [];
$client_name = isset( $data['client_name'] ) ? sanitize_text_field( $data['client_name'] ) : 'MCP Client';
$grant_types = isset( $data['grant_types'] ) && is_array( $data['grant_types'] )
? sanitize_text_field( implode( ' ', $data['grant_types'] ) )
: 'authorization_code';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->insert(
self::clients_table(),
[
'client_id' => $client_id,
'client_secret_hash' => $client_secret_hash,
'client_name' => $client_name,
'redirect_uris' => wp_json_encode( $redirect_uris ),
'grant_types' => $grant_types,
'token_endpoint_auth_method' => $auth_method,
],
[ '%s', '%s', '%s', '%s', '%s', '%s' ]
);
$result = [
'client_id' => $client_id,
'client_name' => $client_name,
'redirect_uris' => $redirect_uris,
'grant_types' => explode( ' ', $grant_types ),
'token_endpoint_auth_method' => $auth_method,
'response_types' => [ 'code' ],
'client_id_issued_at' => time(),
];
if ( $client_secret ) {
$result['client_secret'] = $client_secret;
}
return $result;
}
/**
* Look up a registered client by client_id.
*
* Checks the database first (dynamic clients), then falls back
* to the static client configured in plugin settings.
*
* @param string $client_id The client ID.
* @return array|false Client row or false.
*/
public static function get_client( $client_id ) {
global $wpdb;
$table = self::clients_table();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from safe helper method.
$row = $wpdb->get_row(
$wpdb->prepare( "SELECT * FROM `{$table}` WHERE client_id = %s LIMIT 1", $client_id ),
ARRAY_A
);
if ( $row ) {
$row['redirect_uris'] = json_decode( $row['redirect_uris'], true ) ?: [];
return $row;
}
// Check static client from settings.
$settings = get_option( 'royal_mcp_settings', [] );
if ( ! empty( $settings['oauth_client_id'] ) && hash_equals( $settings['oauth_client_id'], $client_id ) ) {
return [
'client_id' => $settings['oauth_client_id'],
'client_secret_hash' => ! empty( $settings['oauth_client_secret'] ) ? hash( 'sha256', $settings['oauth_client_secret'] ) : null,
'client_name' => get_bloginfo( 'name' ) . ' (static)',
'redirect_uris' => [], // Static clients accept any localhost/HTTPS redirect.
'grant_types' => 'authorization_code',
'token_endpoint_auth_method' => ! empty( $settings['oauth_client_secret'] ) ? 'client_secret_post' : 'none',
'is_static' => true,
];
}
return false;
}
/**
* Validate a redirect URI against a client's registered URIs.
*
* @param string $redirect_uri The URI to validate.
* @param array $client The client record from get_client().
* @return bool True if allowed.
*/
public static function validate_redirect_uri( $redirect_uri, $client ) {
// Must be localhost (any port) or HTTPS.
$parsed = wp_parse_url( $redirect_uri );
if ( ! $parsed || empty( $parsed['scheme'] ) || empty( $parsed['host'] ) ) {
return false;
}
$is_localhost = in_array( $parsed['host'], [ 'localhost', '127.0.0.1', '::1' ], true );
if ( ! $is_localhost && 'https' !== $parsed['scheme'] ) {
return false;
}
// Static clients (from settings) accept any valid localhost/HTTPS URI.
if ( ! empty( $client['is_static'] ) ) {
return true;
}
// Dynamic clients: exact match required.
$registered = $client['redirect_uris'] ?? [];
if ( empty( $registered ) ) {
return true; // No URIs registered = accept any valid one (matches Claude Desktop behavior).
}
return in_array( $redirect_uri, $registered, true );
}
}
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
@@ -0,0 +1,667 @@
<?php
namespace Royal_MCP\Platform;
if (!defined('ABSPATH')) {
exit;
}
/**
* Platform Registry - Manages LLM platform configurations
*
* Provides pre-configured settings for major AI platforms,
* reducing setup friction for users.
*/
class Registry {
/**
* Get all available platforms
*/
public static function get_platforms() {
return [
'claude' => [
'id' => 'claude',
'label' => 'Claude (Anthropic)',
'icon' => 'anthropic',
'color' => '#6B4C9A',
'description' => 'Anthropic\'s Claude AI - Advanced reasoning and analysis',
'docs_url' => 'https://console.anthropic.com/',
'api_key_url' => 'https://console.anthropic.com/settings/keys',
'endpoint' => 'https://api.anthropic.com',
'auth_type' => 'header',
'auth_header' => 'x-api-key',
'extra_headers' => [
'anthropic-version' => '2023-06-01',
],
'fields' => [
'api_key' => [
'type' => 'password',
'label' => 'API Key',
'required' => true,
'placeholder' => 'sk-ant-...',
'help' => 'Get your API key from the Anthropic Console',
],
'model' => [
'type' => 'select',
'label' => 'Model',
'required' => false,
'default' => 'claude-sonnet-4-20250514',
'options' => [
'claude-sonnet-4-20250514' => 'Claude Sonnet 4 (Latest)',
'claude-opus-4-20250514' => 'Claude Opus 4 (Most Capable)',
'claude-3-5-sonnet-20241022' => 'Claude 3.5 Sonnet',
'claude-3-5-haiku-20241022' => 'Claude 3.5 Haiku (Fast)',
'claude-3-opus-20240229' => 'Claude 3 Opus',
],
],
],
'test_endpoint' => '/v1/messages',
'test_method' => 'POST',
'test_body' => [
'model' => 'claude-3-5-haiku-20241022',
'max_tokens' => 10,
'messages' => [
['role' => 'user', 'content' => 'Hi']
]
],
],
'openai' => [
'id' => 'openai',
'label' => 'OpenAI / ChatGPT',
'icon' => 'openai',
'color' => '#10A37F',
'description' => 'OpenAI\'s GPT models - ChatGPT and GPT-4',
'docs_url' => 'https://platform.openai.com/docs',
'api_key_url' => 'https://platform.openai.com/api-keys',
'endpoint' => 'https://api.openai.com',
'auth_type' => 'bearer',
'auth_header' => 'Authorization',
'fields' => [
'api_key' => [
'type' => 'password',
'label' => 'API Key',
'required' => true,
'placeholder' => 'sk-...',
'help' => 'Get your API key from OpenAI Platform',
],
'model' => [
'type' => 'select',
'label' => 'Model',
'required' => false,
'default' => 'gpt-4o',
'options' => [
'gpt-4o' => 'GPT-4o (Latest, Recommended)',
'gpt-4o-mini' => 'GPT-4o Mini (Fast & Cheap)',
'gpt-4-turbo' => 'GPT-4 Turbo',
'gpt-4' => 'GPT-4',
'gpt-3.5-turbo' => 'GPT-3.5 Turbo',
'o1-preview' => 'o1 Preview (Reasoning)',
'o1-mini' => 'o1 Mini (Reasoning, Fast)',
],
],
'organization_id' => [
'type' => 'text',
'label' => 'Organization ID',
'required' => false,
'placeholder' => 'org-... (optional)',
'help' => 'Only needed if you belong to multiple organizations',
],
],
'test_endpoint' => '/v1/models',
'test_method' => 'GET',
],
'google' => [
'id' => 'google',
'label' => 'Google Gemini',
'icon' => 'google',
'color' => '#4285F4',
'description' => 'Google\'s Gemini AI - Multimodal capabilities',
'docs_url' => 'https://ai.google.dev/docs',
'api_key_url' => 'https://aistudio.google.com/app/apikey',
'endpoint' => 'https://generativelanguage.googleapis.com/v1beta',
'auth_type' => 'query',
'auth_param' => 'key',
'fields' => [
'api_key' => [
'type' => 'password',
'label' => 'API Key',
'required' => true,
'placeholder' => 'AI...',
'help' => 'Get your API key from Google AI Studio',
],
'model' => [
'type' => 'select',
'label' => 'Model',
'required' => false,
'default' => 'gemini-1.5-pro',
'options' => [
'gemini-1.5-pro' => 'Gemini 1.5 Pro (Latest)',
'gemini-1.5-flash' => 'Gemini 1.5 Flash (Fast)',
'gemini-1.0-pro' => 'Gemini 1.0 Pro',
],
],
],
'test_endpoint' => '/models',
'test_method' => 'GET',
],
'ollama' => [
'id' => 'ollama',
'label' => 'Ollama (Local)',
'icon' => 'ollama',
'color' => '#000000',
'description' => 'Run AI models locally on your machine',
'docs_url' => 'https://ollama.ai/',
'api_key_url' => null,
'endpoint' => 'http://localhost:11434',
'auth_type' => 'none',
'fields' => [
'url' => [
'type' => 'url',
'label' => 'Ollama URL',
'required' => true,
'default' => 'http://localhost:11434',
'placeholder' => 'http://localhost:11434',
'help' => 'URL where Ollama is running',
],
'model' => [
'type' => 'text',
'label' => 'Model Name',
'required' => true,
'default' => 'llama3.2',
'placeholder' => 'llama3.2, mistral, codellama, etc.',
'help' => 'Model must be pulled first: ollama pull model-name',
],
],
'test_endpoint' => '/api/tags',
'test_method' => 'GET',
],
'lmstudio' => [
'id' => 'lmstudio',
'label' => 'LM Studio (Local)',
'icon' => 'lmstudio',
'color' => '#6366F1',
'description' => 'Run local models with LM Studio',
'docs_url' => 'https://lmstudio.ai/',
'api_key_url' => null,
'endpoint' => 'http://localhost:1234/v1',
'auth_type' => 'none',
'fields' => [
'url' => [
'type' => 'url',
'label' => 'LM Studio URL',
'required' => true,
'default' => 'http://localhost:1234/v1',
'placeholder' => 'http://localhost:1234/v1',
'help' => 'Start the local server in LM Studio first',
],
],
'test_endpoint' => '/models',
'test_method' => 'GET',
],
'groq' => [
'id' => 'groq',
'label' => 'Groq',
'icon' => 'groq',
'color' => '#F55036',
'description' => 'Ultra-fast inference with Groq LPU',
'docs_url' => 'https://console.groq.com/docs',
'api_key_url' => 'https://console.groq.com/keys',
'endpoint' => 'https://api.groq.com/openai/v1',
'auth_type' => 'bearer',
'auth_header' => 'Authorization',
'fields' => [
'api_key' => [
'type' => 'password',
'label' => 'API Key',
'required' => true,
'placeholder' => 'gsk_...',
'help' => 'Get your API key from Groq Console',
],
'model' => [
'type' => 'select',
'label' => 'Model',
'required' => false,
'default' => 'llama-3.3-70b-versatile',
'options' => [
'llama-3.3-70b-versatile' => 'Llama 3.3 70B (Versatile)',
'llama-3.1-8b-instant' => 'Llama 3.1 8B (Instant)',
'mixtral-8x7b-32768' => 'Mixtral 8x7B',
'gemma2-9b-it' => 'Gemma 2 9B',
],
],
],
'test_endpoint' => '/models',
'test_method' => 'GET',
],
'azure' => [
'id' => 'azure',
'label' => 'Azure OpenAI',
'icon' => 'azure',
'color' => '#0078D4',
'description' => 'OpenAI models on Microsoft Azure',
'docs_url' => 'https://learn.microsoft.com/en-us/azure/ai-services/openai/',
'api_key_url' => 'https://portal.azure.com/',
'endpoint' => '',
'auth_type' => 'header',
'auth_header' => 'api-key',
'fields' => [
'url' => [
'type' => 'url',
'label' => 'Endpoint URL',
'required' => true,
'placeholder' => 'https://your-resource.openai.azure.com',
'help' => 'Your Azure OpenAI resource endpoint',
],
'api_key' => [
'type' => 'password',
'label' => 'API Key',
'required' => true,
'placeholder' => 'Your Azure API key',
'help' => 'Found in Azure Portal > Keys and Endpoint',
],
'deployment' => [
'type' => 'text',
'label' => 'Deployment Name',
'required' => true,
'placeholder' => 'gpt-4-deployment',
'help' => 'The name of your model deployment',
],
'api_version' => [
'type' => 'text',
'label' => 'API Version',
'required' => false,
'default' => '2024-02-15-preview',
'placeholder' => '2024-02-15-preview',
],
],
'test_endpoint' => '/openai/deployments?api-version=2024-02-15-preview',
'test_method' => 'GET',
],
'bedrock' => [
'id' => 'bedrock',
'label' => 'AWS Bedrock',
'icon' => 'aws',
'color' => '#FF9900',
'description' => 'AWS Bedrock - Claude, Llama, and more',
'docs_url' => 'https://docs.aws.amazon.com/bedrock/',
'api_key_url' => 'https://console.aws.amazon.com/iam/',
'endpoint' => '',
'auth_type' => 'aws',
'fields' => [
'region' => [
'type' => 'select',
'label' => 'AWS Region',
'required' => true,
'default' => 'us-east-1',
'options' => [
'us-east-1' => 'US East (N. Virginia)',
'us-west-2' => 'US West (Oregon)',
'eu-west-1' => 'Europe (Ireland)',
'ap-northeast-1' => 'Asia Pacific (Tokyo)',
],
],
'access_key' => [
'type' => 'password',
'label' => 'Access Key ID',
'required' => true,
'placeholder' => 'AKIA...',
],
'secret_key' => [
'type' => 'password',
'label' => 'Secret Access Key',
'required' => true,
'placeholder' => 'Your secret key',
],
'model' => [
'type' => 'select',
'label' => 'Model',
'required' => false,
'default' => 'anthropic.claude-3-sonnet-20240229-v1:0',
'options' => [
'anthropic.claude-3-sonnet-20240229-v1:0' => 'Claude 3 Sonnet',
'anthropic.claude-3-haiku-20240307-v1:0' => 'Claude 3 Haiku',
'meta.llama3-70b-instruct-v1:0' => 'Llama 3 70B',
'amazon.titan-text-express-v1' => 'Amazon Titan Text',
],
],
],
],
'custom' => [
'id' => 'custom',
'label' => 'Custom MCP Server',
'icon' => 'custom',
'color' => '#6B7280',
'description' => 'Configure any MCP-compatible server',
'docs_url' => null,
'api_key_url' => null,
'endpoint' => '',
'auth_type' => 'configurable',
'fields' => [
'name' => [
'type' => 'text',
'label' => 'Server Name',
'required' => true,
'placeholder' => 'My Custom Server',
'help' => 'A friendly name for this server',
],
'url' => [
'type' => 'url',
'label' => 'Server URL',
'required' => true,
'placeholder' => 'https://your-server.com/mcp',
'help' => 'The base URL of your MCP server',
],
'auth_type' => [
'type' => 'select',
'label' => 'Authentication Type',
'required' => false,
'default' => 'bearer',
'options' => [
'none' => 'No Authentication',
'bearer' => 'Bearer Token',
'header' => 'Custom Header',
'basic' => 'Basic Auth',
],
],
'api_key' => [
'type' => 'password',
'label' => 'API Key / Token',
'required' => false,
'placeholder' => 'Your API key or token',
],
'custom_header' => [
'type' => 'text',
'label' => 'Custom Header Name',
'required' => false,
'placeholder' => 'X-API-Key',
'help' => 'Only used with Custom Header auth type',
],
],
],
];
}
/**
* Get a specific platform by ID
*/
public static function get_platform($platform_id) {
$platforms = self::get_platforms();
return $platforms[$platform_id] ?? null;
}
/**
* Get platform IDs for dropdown
*/
public static function get_platform_options() {
$platforms = self::get_platforms();
$options = [];
foreach ($platforms as $id => $platform) {
$options[$id] = $platform['label'];
}
return $options;
}
/**
* Get platform groups for organized display
*/
public static function get_platform_groups() {
return [
'cloud' => [
'label' => 'Cloud AI Providers',
'platforms' => ['claude', 'openai', 'google', 'groq'],
],
'local' => [
'label' => 'Local / Self-Hosted',
'platforms' => ['ollama', 'lmstudio'],
],
'enterprise' => [
'label' => 'Enterprise / Cloud',
'platforms' => ['azure', 'bedrock'],
],
'other' => [
'label' => 'Other',
'platforms' => ['custom'],
],
];
}
/**
* Validate platform configuration
*/
public static function validate_config($platform_id, $config) {
$platform = self::get_platform($platform_id);
if (!$platform) {
return new \WP_Error('invalid_platform', 'Invalid platform selected');
}
$errors = [];
foreach ($platform['fields'] as $field_id => $field) {
if (!empty($field['required']) && empty($config[$field_id])) {
$errors[] = sprintf('%s is required', $field['label']);
}
}
if (!empty($errors)) {
return new \WP_Error('validation_failed', implode(', ', $errors));
}
return true;
}
/**
* Build authentication headers for a platform
*/
public static function get_auth_headers($platform_id, $config) {
$platform = self::get_platform($platform_id);
if (!$platform) {
return [];
}
$headers = [
'Content-Type' => 'application/json',
];
switch ($platform['auth_type']) {
case 'bearer':
if (!empty($config['api_key'])) {
$headers['Authorization'] = 'Bearer ' . $config['api_key'];
}
break;
case 'header':
if (!empty($config['api_key']) && !empty($platform['auth_header'])) {
$headers[$platform['auth_header']] = $config['api_key'];
}
break;
case 'configurable':
if (!empty($config['api_key'])) {
$auth_type = $config['auth_type'] ?? 'bearer';
switch ($auth_type) {
case 'bearer':
$headers['Authorization'] = 'Bearer ' . $config['api_key'];
break;
case 'header':
$header_name = $config['custom_header'] ?? 'X-API-Key';
$headers[$header_name] = $config['api_key'];
break;
case 'basic':
$headers['Authorization'] = 'Basic ' . base64_encode($config['api_key']);
break;
}
}
break;
}
// Add organization ID for OpenAI if provided
if ($platform_id === 'openai' && !empty($config['organization_id'])) {
$headers['OpenAI-Organization'] = $config['organization_id'];
}
return $headers;
}
/**
* Get the endpoint URL for a platform
*/
/**
* Validate that a URL is safe for outbound requests (SSRF protection).
*
* @param string $url The URL to validate.
* @return true|\WP_Error True if safe, WP_Error if not.
*/
public static function validate_external_url( $url ) {
$parsed = wp_parse_url( $url );
if ( empty( $parsed['scheme'] ) || ! in_array( $parsed['scheme'], array( 'http', 'https' ), true ) ) {
return new \WP_Error( 'invalid_url_scheme', __( 'Only HTTP and HTTPS URLs are allowed.', 'royal-mcp' ) );
}
if ( empty( $parsed['host'] ) ) {
return new \WP_Error( 'invalid_url_host', __( 'URL must include a hostname.', 'royal-mcp' ) );
}
$host = $parsed['host'];
// Block localhost and loopback
$blocked = array( 'localhost', '127.0.0.1', '::1', '0.0.0.0' );
if ( in_array( strtolower( $host ), $blocked, true ) ) {
return new \WP_Error( 'blocked_url', __( 'Localhost and loopback addresses are not allowed.', 'royal-mcp' ) );
}
// Block private and reserved IP ranges
if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
if ( ! filter_var( $host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
return new \WP_Error( 'blocked_url', __( 'Private and reserved IP addresses are not allowed.', 'royal-mcp' ) );
}
}
return true;
}
public static function get_endpoint($platform_id, $config) {
$platform = self::get_platform($platform_id);
if (!$platform) {
return '';
}
// Use custom URL if provided, otherwise use default
if (!empty($config['url'])) {
return rtrim($config['url'], '/');
}
return rtrim($platform['endpoint'], '/');
}
/**
* Test connection to a platform
*/
public static function test_connection($platform_id, $config) {
$platform = self::get_platform($platform_id);
if (!$platform) {
return [
'success' => false,
'message' => 'Invalid platform',
];
}
$endpoint = self::get_endpoint($platform_id, $config);
$headers = self::get_auth_headers($platform_id, $config);
// Add extra headers if defined (e.g., anthropic-version for Claude)
if (!empty($platform['extra_headers']) && is_array($platform['extra_headers'])) {
$headers = array_merge($headers, $platform['extra_headers']);
}
if (empty($endpoint)) {
return [
'success' => false,
'message' => 'No endpoint configured',
];
}
$test_url = $endpoint;
if (!empty($platform['test_endpoint'])) {
$test_url .= $platform['test_endpoint'];
}
// SSRF protection: validate URL before making request
$url_check = self::validate_external_url( $test_url );
if ( is_wp_error( $url_check ) ) {
return [
'success' => false,
'message' => $url_check->get_error_message(),
];
}
// Handle query param auth (Google)
if ($platform['auth_type'] === 'query' && !empty($config['api_key'])) {
$test_url = add_query_arg($platform['auth_param'], $config['api_key'], $test_url);
}
// Build request args
$request_args = [
'method' => $platform['test_method'] ?? 'GET',
'headers' => $headers,
'timeout' => 15,
];
// Add body for POST requests if test_body is defined
if (($platform['test_method'] ?? 'GET') === 'POST' && !empty($platform['test_body'])) {
$request_args['body'] = wp_json_encode($platform['test_body']);
}
$response = wp_remote_request($test_url, $request_args);
if (is_wp_error($response)) {
return [
'success' => false,
'message' => $response->get_error_message(),
];
}
$status_code = wp_remote_retrieve_response_code($response);
if ($status_code >= 200 && $status_code < 300) {
return [
'success' => true,
'message' => 'Connection successful!',
'status_code' => $status_code,
];
} elseif ($status_code === 401 || $status_code === 403) {
return [
'success' => false,
'message' => 'Authentication failed. Please check your API key.',
'status_code' => $status_code,
];
} else {
$body = wp_remote_retrieve_body($response);
$error_detail = '';
if (!empty($body)) {
$decoded = json_decode($body, true);
if (isset($decoded['error']['message'])) {
$error_detail = ': ' . $decoded['error']['message'];
}
}
return [
'success' => false,
'message' => sprintf('Server responded with status %d%s', $status_code, $error_detail),
'status_code' => $status_code,
];
}
}
}
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
@@ -0,0 +1,2 @@
<?php
// Silence is golden.