Initial geladen: WP App Portal
This commit is contained in:
@@ -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.
|
||||
+176
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
+432
@@ -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.
|
||||
Reference in New Issue
Block a user