Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/royal-mcp/includes/Integrations/ACF.php
+++ b/royal-mcp/includes/Integrations/ACF.php
@@ -108,6 +108,11 @@
if ( ! get_post( $post_id ) ) {
throw new Exception( 'Post not found for ID ' . esc_html( (string) $post_id ) );
}
+ // 1.4.26 — ACF field values live in post meta; gate behind the
+ // parent post's read cap (private posts' ACF data is not public).
+ if ( ! current_user_can( 'read_post', $post_id ) ) {
+ throw new Exception( 'You do not have permission to read ACF fields on this post.' );
+ }
$field_object = get_field_object( $field_name, $post_id, true, true );
if ( ! $field_object ) {
return [
@@ -136,6 +141,9 @@
if ( ! get_post( $post_id ) ) {
throw new Exception( 'Post not found for ID ' . esc_html( (string) $post_id ) );
}
+ if ( ! current_user_can( 'read_post', $post_id ) ) {
+ throw new Exception( 'You do not have permission to read ACF fields on this post.' );
+ }
$objects = get_field_objects( $post_id, true, true );
if ( ! $objects ) {
return [
@@ -168,6 +176,11 @@
if ( ! get_post( $post_id ) ) {
throw new Exception( 'Post not found for ID ' . esc_html( (string) $post_id ) );
}
+ // 1.4.26 — writing ACF fields modifies the post; require the
+ // parent post's edit cap.
+ if ( ! current_user_can( 'edit_post', $post_id ) ) {
+ throw new Exception( 'You do not have permission to edit ACF fields on this post.' );
+ }
if ( ! array_key_exists( 'value', $args ) ) {
throw new Exception( 'value is required (pass null to clear the field)' );
}
@@ -186,6 +199,11 @@
];
case 'acf_get_field_groups':
+ // 1.4.26 — discovery of registered field groups is editor-tier
+ // metadata (which custom fields exist on which post types).
+ if ( ! current_user_can( 'edit_posts' ) ) {
+ throw new Exception( 'You do not have permission to discover ACF field groups.' );
+ }
$filter = [];
if ( ! empty( $args['post_type'] ) ) {
$filter['post_type'] = sanitize_key( $args['post_type'] );
--- a/royal-mcp/includes/Integrations/ForgeCache.php
+++ b/royal-mcp/includes/Integrations/ForgeCache.php
@@ -1,117 +1,131 @@
-<?php
-namespace Royal_MCPIntegrations;
-
-if ( ! defined( 'ABSPATH' ) ) {
- exit;
-}
-
-/**
- * ForgeCache MCP Integration
- *
- * Registers MCP tools for the ForgeCache page caching plugin.
- * Only loaded when ForgeCache is active.
- */
-class ForgeCache {
-
- /**
- * Check if ForgeCache is available.
- */
- public static function is_available() {
- return class_exists( 'ForgeCache_Cache' );
- }
-
- /**
- * Get tool definitions for MCP tools/list response.
- */
- public static function get_tools() {
- if ( ! self::is_available() ) {
- return [];
- }
-
- return [
- [
- 'name' => 'fc_clear_cache',
- 'description' => 'Clear the entire ForgeCache page cache. Use after a major site update, content migration, or when troubleshooting stale content.',
- 'inputSchema' => [
- 'type' => 'object',
- 'properties' => new stdClass(),
- ],
- ],
- [
- 'name' => 'fc_get_cache_stats',
- 'description' => 'Get ForgeCache statistics: total cached files, total size on disk, oldest and newest cached entries.',
- 'inputSchema' => [
- 'type' => 'object',
- 'properties' => new stdClass(),
- ],
- ],
- [
- 'name' => 'fc_purge_url',
- 'description' => 'Purge the ForgeCache entry for a single URL on this site. Resolves the URL to a WordPress post or page and clears its cached HTML.',
- 'inputSchema' => [
- 'type' => 'object',
- 'properties' => [
- 'url' => [ 'type' => 'string', 'description' => 'Full URL on this site (e.g. https://yoursite.com/about/)' ],
- ],
- 'required' => [ 'url' ],
- ],
- ],
- ];
- }
-
- /**
- * Execute a ForgeCache 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( 'ForgeCache is not active' );
- }
-
- switch ( $name ) {
- case 'fc_clear_cache':
- ForgeCache_Cache::clear_all_cache_static();
- return [
- 'success' => true,
- 'message' => 'ForgeCache page cache cleared.',
- ];
-
- case 'fc_get_cache_stats':
- $stats = ForgeCache_Cache::get_cache_stats();
- return [
- 'total_files' => (int) ( $stats['total_files'] ?? 0 ),
- 'total_size_bytes' => (int) ( $stats['total_size'] ?? 0 ),
- 'total_size_human' => size_format( (int) ( $stats['total_size'] ?? 0 ) ),
- 'oldest_file' => isset( $stats['oldest_file'] ) && $stats['oldest_file'] ? gmdate( 'Y-m-d H:i:s', (int) $stats['oldest_file'] ) : null,
- 'newest_file' => isset( $stats['newest_file'] ) && $stats['newest_file'] ? gmdate( 'Y-m-d H:i:s', (int) $stats['newest_file'] ) : null,
- ];
-
- case 'fc_purge_url':
- $url = esc_url_raw( $args['url'] ?? '' );
- if ( empty( $url ) ) {
- throw new Exception( 'url is required' );
- }
- $post_id = url_to_postid( $url );
- if ( ! $post_id ) {
- throw new Exception( 'Could not resolve URL to a WordPress post or page on this site: ' . esc_html( $url ) );
- }
- $cache = ForgeCache_Cache::instance();
- if ( method_exists( $cache, 'clear_post_cache' ) ) {
- $cache->clear_post_cache( $post_id );
- }
- return [
- 'success' => true,
- 'url' => $url,
- 'post_id' => $post_id,
- 'message' => 'Cache cleared for post ID ' . $post_id,
- ];
-
- default:
- throw new Exception( 'Unknown ForgeCache tool: ' . esc_html( $name ) );
- }
- }
-}
+<?php
+namespace Royal_MCPIntegrations;
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+/**
+ * ForgeCache MCP Integration
+ *
+ * Registers MCP tools for the ForgeCache page caching plugin.
+ * Only loaded when ForgeCache is active.
+ */
+class ForgeCache {
+
+ /**
+ * Check if ForgeCache is available.
+ */
+ public static function is_available() {
+ return class_exists( 'ForgeCache_Cache' );
+ }
+
+ /**
+ * Get tool definitions for MCP tools/list response.
+ */
+ public static function get_tools() {
+ if ( ! self::is_available() ) {
+ return [];
+ }
+
+ return [
+ [
+ 'name' => 'fc_clear_cache',
+ 'description' => 'Clear the entire ForgeCache page cache. Use after a major site update, content migration, or when troubleshooting stale content.',
+ 'inputSchema' => [
+ 'type' => 'object',
+ 'properties' => new stdClass(),
+ ],
+ ],
+ [
+ 'name' => 'fc_get_cache_stats',
+ 'description' => 'Get ForgeCache statistics: total cached files, total size on disk, oldest and newest cached entries.',
+ 'inputSchema' => [
+ 'type' => 'object',
+ 'properties' => new stdClass(),
+ ],
+ ],
+ [
+ 'name' => 'fc_purge_url',
+ 'description' => 'Purge the ForgeCache entry for a single URL on this site. Resolves the URL to a WordPress post or page and clears its cached HTML.',
+ 'inputSchema' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'url' => [ 'type' => 'string', 'description' => 'Full URL on this site (e.g. https://yoursite.com/about/)' ],
+ ],
+ 'required' => [ 'url' ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Execute a ForgeCache 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( 'ForgeCache is not active' );
+ }
+
+ switch ( $name ) {
+ case 'fc_clear_cache':
+ // 1.4.26 — cache management is admin-tier; flushing site cache
+ // is destructive (forces re-generation across the whole site).
+ if ( ! current_user_can( 'manage_options' ) ) {
+ throw new Exception( 'You do not have permission to clear the ForgeCache page cache.' );
+ }
+ ForgeCache_Cache::clear_all_cache_static();
+ return [
+ 'success' => true,
+ 'message' => 'ForgeCache page cache cleared.',
+ ];
+
+ case 'fc_get_cache_stats':
+ if ( ! current_user_can( 'manage_options' ) ) {
+ throw new Exception( 'You do not have permission to view ForgeCache stats.' );
+ }
+ $stats = ForgeCache_Cache::get_cache_stats();
+ return [
+ 'total_files' => (int) ( $stats['total_files'] ?? 0 ),
+ 'total_size_bytes' => (int) ( $stats['total_size'] ?? 0 ),
+ 'total_size_human' => size_format( (int) ( $stats['total_size'] ?? 0 ) ),
+ 'oldest_file' => isset( $stats['oldest_file'] ) && $stats['oldest_file'] ? gmdate( 'Y-m-d H:i:s', (int) $stats['oldest_file'] ) : null,
+ 'newest_file' => isset( $stats['newest_file'] ) && $stats['newest_file'] ? gmdate( 'Y-m-d H:i:s', (int) $stats['newest_file'] ) : null,
+ ];
+
+ case 'fc_purge_url':
+ $url = esc_url_raw( $args['url'] ?? '' );
+ if ( empty( $url ) ) {
+ throw new Exception( 'url is required' );
+ }
+ $post_id = url_to_postid( $url );
+ if ( ! $post_id ) {
+ throw new Exception( 'Could not resolve URL to a WordPress post or page on this site: ' . esc_html( $url ) );
+ }
+ // 1.4.26 — purging the cache for a specific post requires
+ // edit_post on the target (so a Subscriber can't purge an
+ // admin's draft cache).
+ if ( ! current_user_can( 'edit_post', $post_id ) ) {
+ throw new Exception( 'You do not have permission to purge the cache for this post.' );
+ }
+ $cache = ForgeCache_Cache::instance();
+ if ( method_exists( $cache, 'clear_post_cache' ) ) {
+ $cache->clear_post_cache( $post_id );
+ }
+ return [
+ 'success' => true,
+ 'url' => $url,
+ 'post_id' => $post_id,
+ 'message' => 'Cache cleared for post ID ' . $post_id,
+ ];
+
+ default:
+ throw new Exception( 'Unknown ForgeCache tool: ' . esc_html( $name ) );
+ }
+ }
+}
--- a/royal-mcp/includes/Integrations/GuardPress.php
+++ b/royal-mcp/includes/Integrations/GuardPress.php
@@ -84,6 +84,15 @@
$guardpress = GuardPress::get_instance();
+ // 1.4.26 — all GuardPress tools are admin-tier. Security state
+ // (failed-login lists, blocked IPs, audit logs, vulnerability scan
+ // results) is sensitive — exposing it to a Subscriber tells an
+ // attacker which surfaces the site is already weak on. manage_options
+ // matches GuardPress's own admin-screen gating.
+ if ( ! current_user_can( 'manage_options' ) ) {
+ throw new Exception( 'You do not have permission to use GuardPress security tools.' );
+ }
+
switch ( $name ) {
case 'gp_get_security_status':
if ( ! method_exists( 'GuardPress_Settings', 'get_security_score' ) ) {
--- a/royal-mcp/includes/Integrations/RoyalLedger.php
+++ b/royal-mcp/includes/Integrations/RoyalLedger.php
@@ -1,230 +1,248 @@
-<?php
-namespace Royal_MCPIntegrations;
-
-if ( ! defined( 'ABSPATH' ) ) {
- exit;
-}
-
-/**
- * Royal Ledger MCP Integration
- *
- * Registers MCP tools for the Royal Ledger cost-tracker and license-vault plugin.
- * Only loaded when Royal Ledger is active.
- *
- * SECURITY NOTE: License key VALUES are never exposed through MCP. The rl_get_keys
- * tool returns key names, masked previews, and metadata only — never the decrypted
- * key. Decrypting a stored key requires manually visiting the Royal Ledger admin.
- */
-class RoyalLedger {
-
- /**
- * Check if Royal Ledger is available.
- */
- public static function is_available() {
- return class_exists( 'RLEDGER_Items' );
- }
-
- /**
- * Get tool definitions for MCP tools/list response.
- */
- public static function get_tools() {
- if ( ! self::is_available() ) {
- return [];
- }
-
- return [
- [
- 'name' => 'rl_get_costs',
- 'description' => 'List Royal Ledger cost items (premium plugins, hosting, domains, CDN, SaaS subscriptions tracked by the user).',
- 'inputSchema' => [
- 'type' => 'object',
- 'properties' => [
- 'category' => [ 'type' => 'string', 'description' => 'Optional category filter: plugins, themes, hosting, domains, saas, other' ],
- 'status' => [ 'type' => 'string', 'description' => 'Optional status filter: active, paused, expired (default: active)' ],
- 'limit' => [ 'type' => 'integer', 'description' => 'Max items to return (default 50)' ],
- ],
- ],
- ],
- [
- 'name' => 'rl_create_cost',
- 'description' => 'Add a new tracked cost item to Royal Ledger. Use when the user mentions a new subscription, hosting renewal, premium plugin purchase, etc.',
- 'inputSchema' => [
- 'type' => 'object',
- 'properties' => [
- 'name' => [ 'type' => 'string', 'description' => 'Item name, e.g. "WPForms Pro" or "SiteGround GoGeek"' ],
- 'category' => [ 'type' => 'string', 'enum' => [ 'plugins', 'themes', 'hosting', 'domains', 'saas', 'other' ] ],
- 'cost' => [ 'type' => 'number', 'description' => 'Cost amount per billing cycle' ],
- 'currency' => [ 'type' => 'string', 'description' => 'ISO 4217 currency code (default: USD)' ],
- 'billing_cycle' => [ 'type' => 'string', 'enum' => [ 'monthly', 'quarterly', 'annual', 'biennial', 'one-time', 'custom' ], 'description' => 'How often the cost recurs' ],
- 'renewal_date' => [ 'type' => 'string', 'description' => 'Next renewal date in YYYY-MM-DD format' ],
- 'url' => [ 'type' => 'string', 'description' => 'Vendor or product URL (optional)' ],
- 'notes' => [ 'type' => 'string', 'description' => 'Free-form notes (optional)' ],
- ],
- 'required' => [ 'name', 'category', 'cost' ],
- ],
- ],
- [
- 'name' => 'rl_get_renewals',
- 'description' => 'Get upcoming Royal Ledger renewals within N days. Useful for "what subscriptions am I about to be charged for?" queries.',
- 'inputSchema' => [
- 'type' => 'object',
- 'properties' => [
- 'limit' => [ 'type' => 'integer', 'description' => 'Max renewals to return (default 10)' ],
- ],
- ],
- ],
- [
- 'name' => 'rl_get_keys',
- 'description' => 'List license keys stored in the Royal Ledger vault. Returns key name, associated cost item, masked preview (first 4 + last 4 chars), and expiry date. RAW DECRYPTED KEYS ARE NEVER RETURNED — that requires logging into the admin.',
- 'inputSchema' => [
- 'type' => 'object',
- 'properties' => [
- 'item_id' => [ 'type' => 'integer', 'description' => 'Optional cost item ID to filter keys by' ],
- 'limit' => [ 'type' => 'integer', 'description' => 'Max keys to return (default 50)' ],
- ],
- ],
- ],
- ];
- }
-
- /**
- * Execute a Royal Ledger 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( 'Royal Ledger is not active' );
- }
-
- switch ( $name ) {
- case 'rl_get_costs':
- $query = [
- 'limit' => min( intval( $args['limit'] ?? 50 ), 200 ),
- 'status' => sanitize_text_field( $args['status'] ?? 'active' ),
- ];
- if ( ! empty( $args['category'] ) ) {
- $query['category'] = sanitize_text_field( $args['category'] );
- }
- $items = RLEDGER_Items::get_all( $query );
- return array_map( [ __CLASS__, 'format_cost_item' ], $items );
-
- case 'rl_create_cost':
- $name_arg = sanitize_text_field( $args['name'] ?? '' );
- if ( empty( $name_arg ) ) {
- throw new Exception( 'name is required' );
- }
- $category = sanitize_text_field( $args['category'] ?? 'other' );
- $valid = [ 'plugins', 'themes', 'hosting', 'domains', 'saas', 'other' ];
- if ( ! in_array( $category, $valid, true ) ) {
- throw new Exception( 'Invalid category. Allowed: ' . esc_html( implode( ', ', $valid ) ) );
- }
- $data = [
- 'name' => $name_arg,
- 'category' => $category,
- 'cost' => floatval( $args['cost'] ?? 0 ),
- 'currency' => sanitize_text_field( $args['currency'] ?? 'USD' ),
- 'billing_cycle' => sanitize_text_field( $args['billing_cycle'] ?? 'annual' ),
- 'status' => 'active',
- ];
- if ( ! empty( $args['renewal_date'] ) ) {
- $data['renewal_date'] = sanitize_text_field( $args['renewal_date'] );
- }
- if ( ! empty( $args['url'] ) ) {
- $data['url'] = esc_url_raw( $args['url'] );
- }
- if ( ! empty( $args['notes'] ) ) {
- $data['notes'] = sanitize_textarea_field( $args['notes'] );
- }
- $item_id = RLEDGER_Items::create( $data );
- if ( ! $item_id ) {
- throw new Exception( 'Failed to create cost item' );
- }
- $created = RLEDGER_Items::get( $item_id );
- return self::format_cost_item( $created );
-
- case 'rl_get_renewals':
- $limit = min( intval( $args['limit'] ?? 10 ), 100 );
- $items = RLEDGER_Items::get_upcoming_renewals( $limit );
- return array_map(
- function ( $item ) {
- $days_until = $item->renewal_date ? (int) ( ( strtotime( $item->renewal_date ) - time() ) / DAY_IN_SECONDS ) : null;
- return [
- 'id' => (int) $item->id,
- 'name' => $item->name,
- 'category' => $item->category,
- 'cost' => (float) $item->cost,
- 'currency' => $item->currency,
- 'billing_cycle' => $item->billing_cycle,
- 'renewal_date' => $item->renewal_date,
- 'days_until' => $days_until,
- 'url' => $item->url,
- ];
- },
- $items
- );
-
- case 'rl_get_keys':
- if ( ! class_exists( 'RLEDGER_Keys' ) ) {
- throw new Exception( 'RLEDGER_Keys class not loaded' );
- }
- $query = [ 'limit' => min( intval( $args['limit'] ?? 50 ), 200 ) ];
- if ( ! empty( $args['item_id'] ) ) {
- $query['item_id'] = intval( $args['item_id'] );
- }
- $keys = RLEDGER_Keys::get_all( $query );
- return array_map(
- function ( $key ) {
- // Get masked preview ONLY — decrypt internally, mask, then discard the decrypted value.
- $preview = '';
- if ( method_exists( 'RLEDGER_Keys', 'get_decrypted' ) && method_exists( 'RLEDGER_Keys', 'mask_key' ) ) {
- $decrypted_obj = RLEDGER_Keys::get_decrypted( $key->id );
- if ( $decrypted_obj && ! empty( $decrypted_obj->license_key ) && is_string( $decrypted_obj->license_key ) ) {
- $preview = RLEDGER_Keys::mask_key( $decrypted_obj->license_key );
- }
- }
- return [
- 'id' => (int) $key->id,
- 'item_id' => (int) $key->item_id,
- 'item_name' => $key->item_name ?? '',
- 'key_name' => $key->key_name,
- 'masked_preview' => $preview,
- 'expiry_date' => $key->expiry_date,
- 'created_at' => $key->created_at,
- 'note' => 'Decrypted key value is never exposed through MCP. To view, log into wp-admin > Royal Ledger > License Keys.',
- ];
- },
- $keys
- );
-
- default:
- throw new Exception( 'Unknown Royal Ledger tool: ' . esc_html( $name ) );
- }
- }
-
- /**
- * Format a cost item for response.
- */
- private static function format_cost_item( $item ) {
- if ( ! $item ) {
- return null;
- }
- $days_until = $item->renewal_date ? (int) ( ( strtotime( $item->renewal_date ) - time() ) / DAY_IN_SECONDS ) : null;
- return [
- 'id' => (int) $item->id,
- 'name' => $item->name,
- 'category' => $item->category,
- 'cost' => (float) $item->cost,
- 'currency' => $item->currency,
- 'billing_cycle' => $item->billing_cycle,
- 'renewal_date' => $item->renewal_date,
- 'days_until' => $days_until,
- 'status' => $item->status,
- 'url' => $item->url,
- 'notes' => $item->notes,
- ];
- }
-}
+<?php
+namespace Royal_MCPIntegrations;
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+/**
+ * Royal Ledger MCP Integration
+ *
+ * Registers MCP tools for the Royal Ledger cost-tracker and license-vault plugin.
+ * Only loaded when Royal Ledger is active.
+ *
+ * SECURITY NOTE: License key VALUES are never exposed through MCP. The rl_get_keys
+ * tool returns key names, masked previews, and metadata only — never the decrypted
+ * key. Decrypting a stored key requires manually visiting the Royal Ledger admin.
+ */
+class RoyalLedger {
+
+ /**
+ * Check if Royal Ledger is available.
+ */
+ public static function is_available() {
+ return class_exists( 'RLEDGER_Items' );
+ }
+
+ /**
+ * Get tool definitions for MCP tools/list response.
+ */
+ public static function get_tools() {
+ if ( ! self::is_available() ) {
+ return [];
+ }
+
+ return [
+ [
+ 'name' => 'rl_get_costs',
+ 'description' => 'List Royal Ledger cost items (premium plugins, hosting, domains, CDN, SaaS subscriptions tracked by the user).',
+ 'inputSchema' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'category' => [ 'type' => 'string', 'description' => 'Optional category filter: plugins, themes, hosting, domains, saas, other' ],
+ 'status' => [ 'type' => 'string', 'description' => 'Optional status filter: active, paused, expired (default: active)' ],
+ 'limit' => [ 'type' => 'integer', 'description' => 'Max items to return (default 50)' ],
+ ],
+ ],
+ ],
+ [
+ 'name' => 'rl_create_cost',
+ 'description' => 'Add a new tracked cost item to Royal Ledger. Use when the user mentions a new subscription, hosting renewal, premium plugin purchase, etc.',
+ 'inputSchema' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'name' => [ 'type' => 'string', 'description' => 'Item name, e.g. "WPForms Pro" or "SiteGround GoGeek"' ],
+ 'category' => [ 'type' => 'string', 'enum' => [ 'plugins', 'themes', 'hosting', 'domains', 'saas', 'other' ] ],
+ 'cost' => [ 'type' => 'number', 'description' => 'Cost amount per billing cycle' ],
+ 'currency' => [ 'type' => 'string', 'description' => 'ISO 4217 currency code (default: USD)' ],
+ 'billing_cycle' => [ 'type' => 'string', 'enum' => [ 'monthly', 'quarterly', 'annual', 'biennial', 'one-time', 'custom' ], 'description' => 'How often the cost recurs' ],
+ 'renewal_date' => [ 'type' => 'string', 'description' => 'Next renewal date in YYYY-MM-DD format' ],
+ 'url' => [ 'type' => 'string', 'description' => 'Vendor or product URL (optional)' ],
+ 'notes' => [ 'type' => 'string', 'description' => 'Free-form notes (optional)' ],
+ ],
+ 'required' => [ 'name', 'category', 'cost' ],
+ ],
+ ],
+ [
+ 'name' => 'rl_get_renewals',
+ 'description' => 'Get upcoming Royal Ledger renewals within N days. Useful for "what subscriptions am I about to be charged for?" queries.',
+ 'inputSchema' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'limit' => [ 'type' => 'integer', 'description' => 'Max renewals to return (default 10)' ],
+ ],
+ ],
+ ],
+ [
+ 'name' => 'rl_get_keys',
+ 'description' => 'List license keys stored in the Royal Ledger vault. Returns key name, associated cost item, masked preview (first 4 + last 4 chars), and expiry date. RAW DECRYPTED KEYS ARE NEVER RETURNED — that requires logging into the admin.',
+ 'inputSchema' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'item_id' => [ 'type' => 'integer', 'description' => 'Optional cost item ID to filter keys by' ],
+ 'limit' => [ 'type' => 'integer', 'description' => 'Max keys to return (default 50)' ],
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Execute a Royal Ledger 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( 'Royal Ledger is not active' );
+ }
+
+ switch ( $name ) {
+ case 'rl_get_costs':
+ // 1.4.26 — Royal Ledger holds financial bookkeeping (costs,
+ // renewals, licensed-products). manage_options matches the
+ // plugin's own admin-screen gating.
+ if ( ! current_user_can( 'manage_options' ) ) {
+ throw new Exception( 'You do not have permission to view Royal Ledger cost data.' );
+ }
+ $query = [
+ 'limit' => min( intval( $args['limit'] ?? 50 ), 200 ),
+ 'status' => sanitize_text_field( $args['status'] ?? 'active' ),
+ ];
+ if ( ! empty( $args['category'] ) ) {
+ $query['category'] = sanitize_text_field( $args['category'] );
+ }
+ $items = RLEDGER_Items::get_all( $query );
+ return array_map( [ __CLASS__, 'format_cost_item' ], $items );
+
+ case 'rl_create_cost':
+ if ( ! current_user_can( 'manage_options' ) ) {
+ throw new Exception( 'You do not have permission to create Royal Ledger cost entries.' );
+ }
+ $name_arg = sanitize_text_field( $args['name'] ?? '' );
+ if ( empty( $name_arg ) ) {
+ throw new Exception( 'name is required' );
+ }
+ $category = sanitize_text_field( $args['category'] ?? 'other' );
+ $valid = [ 'plugins', 'themes', 'hosting', 'domains', 'saas', 'other' ];
+ if ( ! in_array( $category, $valid, true ) ) {
+ throw new Exception( 'Invalid category. Allowed: ' . esc_html( implode( ', ', $valid ) ) );
+ }
+ $data = [
+ 'name' => $name_arg,
+ 'category' => $category,
+ 'cost' => floatval( $args['cost'] ?? 0 ),
+ 'currency' => sanitize_text_field( $args['currency'] ?? 'USD' ),
+ 'billing_cycle' => sanitize_text_field( $args['billing_cycle'] ?? 'annual' ),
+ 'status' => 'active',
+ ];
+ if ( ! empty( $args['renewal_date'] ) ) {
+ $data['renewal_date'] = sanitize_text_field( $args['renewal_date'] );
+ }
+ if ( ! empty( $args['url'] ) ) {
+ $data['url'] = esc_url_raw( $args['url'] );
+ }
+ if ( ! empty( $args['notes'] ) ) {
+ $data['notes'] = sanitize_textarea_field( $args['notes'] );
+ }
+ $item_id = RLEDGER_Items::create( $data );
+ if ( ! $item_id ) {
+ throw new Exception( 'Failed to create cost item' );
+ }
+ $created = RLEDGER_Items::get( $item_id );
+ return self::format_cost_item( $created );
+
+ case 'rl_get_renewals':
+ if ( ! current_user_can( 'manage_options' ) ) {
+ throw new Exception( 'You do not have permission to view Royal Ledger renewal data.' );
+ }
+ $limit = min( intval( $args['limit'] ?? 10 ), 100 );
+ $items = RLEDGER_Items::get_upcoming_renewals( $limit );
+ return array_map(
+ function ( $item ) {
+ $days_until = $item->renewal_date ? (int) ( ( strtotime( $item->renewal_date ) - time() ) / DAY_IN_SECONDS ) : null;
+ return [
+ 'id' => (int) $item->id,
+ 'name' => $item->name,
+ 'category' => $item->category,
+ 'cost' => (float) $item->cost,
+ 'currency' => $item->currency,
+ 'billing_cycle' => $item->billing_cycle,
+ 'renewal_date' => $item->renewal_date,
+ 'days_until' => $days_until,
+ 'url' => $item->url,
+ ];
+ },
+ $items
+ );
+
+ case 'rl_get_keys':
+ // 1.4.26 — license-key listings (even masked) are admin-only.
+ // The masked preview still reveals the existence + prefix of
+ // every license key on the site.
+ if ( ! current_user_can( 'manage_options' ) ) {
+ throw new Exception( 'You do not have permission to view Royal Ledger license keys.' );
+ }
+ if ( ! class_exists( 'RLEDGER_Keys' ) ) {
+ throw new Exception( 'RLEDGER_Keys class not loaded' );
+ }
+ $query = [ 'limit' => min( intval( $args['limit'] ?? 50 ), 200 ) ];
+ if ( ! empty( $args['item_id'] ) ) {
+ $query['item_id'] = intval( $args['item_id'] );
+ }
+ $keys = RLEDGER_Keys::get_all( $query );
+ return array_map(
+ function ( $key ) {
+ // Get masked preview ONLY — decrypt internally, mask, then discard the decrypted value.
+ $preview = '';
+ if ( method_exists( 'RLEDGER_Keys', 'get_decrypted' ) && method_exists( 'RLEDGER_Keys', 'mask_key' ) ) {
+ $decrypted_obj = RLEDGER_Keys::get_decrypted( $key->id );
+ if ( $decrypted_obj && ! empty( $decrypted_obj->license_key ) && is_string( $decrypted_obj->license_key ) ) {
+ $preview = RLEDGER_Keys::mask_key( $decrypted_obj->license_key );
+ }
+ }
+ return [
+ 'id' => (int) $key->id,
+ 'item_id' => (int) $key->item_id,
+ 'item_name' => $key->item_name ?? '',
+ 'key_name' => $key->key_name,
+ 'masked_preview' => $preview,
+ 'expiry_date' => $key->expiry_date,
+ 'created_at' => $key->created_at,
+ 'note' => 'Decrypted key value is never exposed through MCP. To view, log into wp-admin > Royal Ledger > License Keys.',
+ ];
+ },
+ $keys
+ );
+
+ default:
+ throw new Exception( 'Unknown Royal Ledger tool: ' . esc_html( $name ) );
+ }
+ }
+
+ /**
+ * Format a cost item for response.
+ */
+ private static function format_cost_item( $item ) {
+ if ( ! $item ) {
+ return null;
+ }
+ $days_until = $item->renewal_date ? (int) ( ( strtotime( $item->renewal_date ) - time() ) / DAY_IN_SECONDS ) : null;
+ return [
+ 'id' => (int) $item->id,
+ 'name' => $item->name,
+ 'category' => $item->category,
+ 'cost' => (float) $item->cost,
+ 'currency' => $item->currency,
+ 'billing_cycle' => $item->billing_cycle,
+ 'renewal_date' => $item->renewal_date,
+ 'days_until' => $days_until,
+ 'status' => $item->status,
+ 'url' => $item->url,
+ 'notes' => $item->notes,
+ ];
+ }
+}
--- a/royal-mcp/includes/Integrations/RoyalLinks.php
+++ b/royal-mcp/includes/Integrations/RoyalLinks.php
@@ -1,190 +1,206 @@
-<?php
-namespace Royal_MCPIntegrations;
-
-if ( ! defined( 'ABSPATH' ) ) {
- exit;
-}
-
-/**
- * Royal Links MCP Integration
- *
- * Registers MCP tools for the Royal Links affiliate-link / URL-shortener / cloaker plugin.
- * Only loaded when Royal Links is active.
- */
-class RoyalLinks {
-
- /**
- * Check if Royal Links is available.
- */
- public static function is_available() {
- return class_exists( 'Royal_Links_Post_Type' ) || post_type_exists( 'royal_link' );
- }
-
- /**
- * Get tool definitions for MCP tools/list response.
- */
- public static function get_tools() {
- if ( ! self::is_available() ) {
- return [];
- }
-
- return [
- [
- 'name' => 'rlinks_get_links',
- 'description' => 'List Royal Links short URLs with destination, slug, click count, and category.',
- 'inputSchema' => [
- 'type' => 'object',
- 'properties' => [
- 'limit' => [ 'type' => 'integer', 'description' => 'Max links to return (default 25, max 100)' ],
- 'category' => [ 'type' => 'string', 'description' => 'Optional category slug to filter by' ],
- 'search' => [ 'type' => 'string', 'description' => 'Search term to match against title or destination URL' ],
- ],
- ],
- ],
- [
- 'name' => 'rlinks_create_link',
- 'description' => 'Create a new Royal Links short URL. Returns the public short URL on this site that redirects to the destination.',
- 'inputSchema' => [
- 'type' => 'object',
- 'properties' => [
- 'title' => [ 'type' => 'string', 'description' => 'Internal label for the link (e.g. "Affiliate – WPForms Pro")' ],
- 'destination_url' => [ 'type' => 'string', 'description' => 'The URL the short link should redirect to' ],
- 'slug' => [ 'type' => 'string', 'description' => 'Custom slug (optional — auto-generated from title if omitted)' ],
- 'redirect_type' => [ 'type' => 'string', 'enum' => [ '301', '302', '307' ], 'description' => 'HTTP redirect type (default: 301 permanent)' ],
- 'nofollow' => [ 'type' => 'boolean', 'description' => 'Add rel="nofollow" (default: true)' ],
- 'sponsored' => [ 'type' => 'boolean', 'description' => 'Add rel="sponsored" for affiliate links (default: false)' ],
- 'new_tab' => [ 'type' => 'boolean', 'description' => 'Open in new tab (default: true)' ],
- ],
- 'required' => [ 'title', 'destination_url' ],
- ],
- ],
- [
- 'name' => 'rlinks_get_link_stats',
- 'description' => 'Get click analytics for a single Royal Link: total clicks, unique clicks, top countries, top referrers, browser/device breakdown over a period.',
- 'inputSchema' => [
- 'type' => 'object',
- 'properties' => [
- 'link_id' => [ 'type' => 'integer', 'description' => 'Royal Link post ID' ],
- 'period' => [ 'type' => 'string', 'enum' => [ '7days', '30days', '90days', '12months', 'all' ], 'description' => 'Time period (default: 30days)' ],
- ],
- 'required' => [ 'link_id' ],
- ],
- ],
- ];
- }
-
- /**
- * Execute a Royal Links MCP tool.
- */
- public static function execute_tool( $name, $args ) {
- if ( ! self::is_available() ) {
- throw new Exception( 'Royal Links is not active' );
- }
-
- switch ( $name ) {
- case 'rlinks_get_links':
- $query_args = [
- 'post_type' => 'royal_link',
- 'post_status' => 'publish',
- 'posts_per_page' => min( intval( $args['limit'] ?? 25 ), 100 ),
- 'orderby' => 'date',
- 'order' => 'DESC',
- ];
- if ( ! empty( $args['search'] ) ) {
- $query_args['s'] = sanitize_text_field( $args['search'] );
- }
- if ( ! empty( $args['category'] ) ) {
- // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- limited by posts_per_page (max 100); standard WP taxonomy filter pattern.
- $query_args['tax_query'] = [
- [
- 'taxonomy' => 'royal_link_category',
- 'field' => 'slug',
- 'terms' => sanitize_text_field( $args['category'] ),
- ],
- ];
- }
- $posts = get_posts( $query_args );
- return array_map( [ __CLASS__, 'format_link' ], $posts );
-
- case 'rlinks_create_link':
- if ( ! class_exists( 'Royal_Links_Post_Type' ) ) {
- throw new Exception( 'Royal_Links_Post_Type class not loaded' );
- }
- $create_args = [
- 'title' => sanitize_text_field( $args['title'] ?? '' ),
- 'destination_url' => esc_url_raw( $args['destination_url'] ?? '' ),
- ];
- if ( ! empty( $args['slug'] ) ) {
- $create_args['slug'] = sanitize_title( $args['slug'] );
- }
- if ( ! empty( $args['redirect_type'] ) ) {
- $create_args['redirect_type'] = sanitize_text_field( $args['redirect_type'] );
- }
- if ( isset( $args['nofollow'] ) ) {
- $create_args['nofollow'] = (bool) $args['nofollow'];
- }
- if ( isset( $args['sponsored'] ) ) {
- $create_args['sponsored'] = (bool) $args['sponsored'];
- }
- if ( isset( $args['new_tab'] ) ) {
- $create_args['new_tab'] = (bool) $args['new_tab'];
- }
- $result = Royal_Links_Post_Type::create_link( $create_args );
- if ( is_wp_error( $result ) ) {
- throw new Exception( esc_html( $result->get_error_message() ) );
- }
- $post = get_post( (int) $result );
- return self::format_link( $post );
-
- case 'rlinks_get_link_stats':
- if ( ! class_exists( 'Royal_Links_Tracker' ) ) {
- throw new Exception( 'Royal_Links_Tracker class not loaded' );
- }
- $link_id = intval( $args['link_id'] ?? 0 );
- if ( $link_id <= 0 ) {
- throw new Exception( 'link_id is required' );
- }
- $post = get_post( $link_id );
- if ( ! $post || $post->post_type !== 'royal_link' ) {
- throw new Exception( 'Royal Link not found for ID ' . esc_html( (string) $link_id ) );
- }
- $period = sanitize_text_field( $args['period'] ?? '30days' );
- $stats = Royal_Links_Tracker::get_link_stats( $link_id, $period );
- return [
- 'link_id' => $link_id,
- 'title' => $post->post_title,
- 'period' => $period,
- 'stats' => $stats,
- ];
-
- default:
- throw new Exception( 'Unknown Royal Links tool: ' . esc_html( $name ) );
- }
- }
-
- /**
- * Format a royal_link post for response.
- */
- private static function format_link( $post ) {
- if ( ! $post ) {
- return null;
- }
- $slug = get_post_meta( $post->ID, '_royal_links_slug', true );
- $destination = get_post_meta( $post->ID, '_royal_links_destination_url', true );
- $total_hits = (int) get_post_meta( $post->ID, '_royal_links_total_hits', true );
- $base = trailingslashit( home_url() );
- $prefix = get_option( 'royal_links_url_prefix', 'go' );
- $short_url = $base . trim( $prefix, '/' ) . '/' . $slug;
-
- return [
- 'id' => (int) $post->ID,
- 'title' => $post->post_title,
- 'slug' => $slug,
- 'short_url' => $short_url,
- 'destination_url' => $destination,
- 'redirect_type' => get_post_meta( $post->ID, '_royal_links_redirect_type', true ),
- 'total_clicks' => $total_hits,
- 'date_created' => $post->post_date,
- ];
- }
-}
+<?php
+namespace Royal_MCPIntegrations;
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+/**
+ * Royal Links MCP Integration
+ *
+ * Registers MCP tools for the Royal Links affiliate-link / URL-shortener / cloaker plugin.
+ * Only loaded when Royal Links is active.
+ */
+class RoyalLinks {
+
+ /**
+ * Check if Royal Links is available.
+ */
+ public static function is_available() {
+ return class_exists( 'Royal_Links_Post_Type' ) || post_type_exists( 'royal_link' );
+ }
+
+ /**
+ * Get tool definitions for MCP tools/list response.
+ */
+ public static function get_tools() {
+ if ( ! self::is_available() ) {
+ return [];
+ }
+
+ return [
+ [
+ 'name' => 'rlinks_get_links',
+ 'description' => 'List Royal Links short URLs with destination, slug, click count, and category.',
+ 'inputSchema' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'limit' => [ 'type' => 'integer', 'description' => 'Max links to return (default 25, max 100)' ],
+ 'category' => [ 'type' => 'string', 'description' => 'Optional category slug to filter by' ],
+ 'search' => [ 'type' => 'string', 'description' => 'Search term to match against title or destination URL' ],
+ ],
+ ],
+ ],
+ [
+ 'name' => 'rlinks_create_link',
+ 'description' => 'Create a new Royal Links short URL. Returns the public short URL on this site that redirects to the destination.',
+ 'inputSchema' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'title' => [ 'type' => 'string', 'description' => 'Internal label for the link (e.g. "Affiliate – WPForms Pro")' ],
+ 'destination_url' => [ 'type' => 'string', 'description' => 'The URL the short link should redirect to' ],
+ 'slug' => [ 'type' => 'string', 'description' => 'Custom slug (optional — auto-generated from title if omitted)' ],
+ 'redirect_type' => [ 'type' => 'string', 'enum' => [ '301', '302', '307' ], 'description' => 'HTTP redirect type (default: 301 permanent)' ],
+ 'nofollow' => [ 'type' => 'boolean', 'description' => 'Add rel="nofollow" (default: true)' ],
+ 'sponsored' => [ 'type' => 'boolean', 'description' => 'Add rel="sponsored" for affiliate links (default: false)' ],
+ 'new_tab' => [ 'type' => 'boolean', 'description' => 'Open in new tab (default: true)' ],
+ ],
+ 'required' => [ 'title', 'destination_url' ],
+ ],
+ ],
+ [
+ 'name' => 'rlinks_get_link_stats',
+ 'description' => 'Get click analytics for a single Royal Link: total clicks, unique clicks, top countries, top referrers, browser/device breakdown over a period.',
+ 'inputSchema' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'link_id' => [ 'type' => 'integer', 'description' => 'Royal Link post ID' ],
+ 'period' => [ 'type' => 'string', 'enum' => [ '7days', '30days', '90days', '12months', 'all' ], 'description' => 'Time period (default: 30days)' ],
+ ],
+ 'required' => [ 'link_id' ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Execute a Royal Links MCP tool.
+ */
+ public static function execute_tool( $name, $args ) {
+ if ( ! self::is_available() ) {
+ throw new Exception( 'Royal Links is not active' );
+ }
+
+ switch ( $name ) {
+ case 'rlinks_get_links':
+ // 1.4.26 — Royal Links use the royal_link CPT; map to its
+ // edit_posts cap so listings respect what the role can see.
+ if ( ! current_user_can( 'edit_posts' ) ) {
+ throw new Exception( 'You do not have permission to list Royal Links.' );
+ }
+ $query_args = [
+ 'post_type' => 'royal_link',
+ 'post_status' => 'publish',
+ 'posts_per_page' => min( intval( $args['limit'] ?? 25 ), 100 ),
+ 'orderby' => 'date',
+ 'order' => 'DESC',
+ ];
+ if ( ! empty( $args['search'] ) ) {
+ $query_args['s'] = sanitize_text_field( $args['search'] );
+ }
+ if ( ! empty( $args['category'] ) ) {
+ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- limited by posts_per_page (max 100); standard WP taxonomy filter pattern.
+ $query_args['tax_query'] = [
+ [
+ 'taxonomy' => 'royal_link_category',
+ 'field' => 'slug',
+ 'terms' => sanitize_text_field( $args['category'] ),
+ ],
+ ];
+ }
+ $posts = get_posts( $query_args );
+ return array_map( [ __CLASS__, 'format_link' ], $posts );
+
+ case 'rlinks_create_link':
+ // 1.4.26 — creating a redirect modifies how visitors leave the
+ // site; require publish_posts to mirror the create-post pattern.
+ if ( ! current_user_can( 'publish_posts' ) ) {
+ throw new Exception( 'You do not have permission to create Royal Links.' );
+ }
+ if ( ! class_exists( 'Royal_Links_Post_Type' ) ) {
+ throw new Exception( 'Royal_Links_Post_Type class not loaded' );
+ }
+ $create_args = [
+ 'title' => sanitize_text_field( $args['title'] ?? '' ),
+ 'destination_url' => esc_url_raw( $args['destination_url'] ?? '' ),
+ ];
+ if ( ! empty( $args['slug'] ) ) {
+ $create_args['slug'] = sanitize_title( $args['slug'] );
+ }
+ if ( ! empty( $args['redirect_type'] ) ) {
+ $create_args['redirect_type'] = sanitize_text_field( $args['redirect_type'] );
+ }
+ if ( isset( $args['nofollow'] ) ) {
+ $create_args['nofollow'] = (bool) $args['nofollow'];
+ }
+ if ( isset( $args['sponsored'] ) ) {
+ $create_args['sponsored'] = (bool) $args['sponsored'];
+ }
+ if ( isset( $args['new_tab'] ) ) {
+ $create_args['new_tab'] = (bool) $args['new_tab'];
+ }
+ $result = Royal_Links_Post_Type::create_link( $create_args );
+ if ( is_wp_error( $result ) ) {
+ throw new Exception( esc_html( $result->get_error_message() ) );
+ }
+ $post = get_post( (int) $result );
+ return self::format_link( $post );
+
+ case 'rlinks_get_link_stats':
+ if ( ! class_exists( 'Royal_Links_Tracker' ) ) {
+ throw new Exception( 'Royal_Links_Tracker class not loaded' );
+ }
+ $link_id = intval( $args['link_id'] ?? 0 );
+ if ( $link_id <= 0 ) {
+ throw new Exception( 'link_id is required' );
+ }
+ $post = get_post( $link_id );
+ if ( ! $post || $post->post_type !== 'royal_link' ) {
+ throw new Exception( 'Royal Link not found for ID ' . esc_html( (string) $link_id ) );
+ }
+ // 1.4.26 — stats can include click counts + referrers; gate
+ // behind the link's own edit_post cap so a Subscriber can't
+ // read another author's link analytics.
+ if ( ! current_user_can( 'edit_post', $link_id ) ) {
+ throw new Exception( 'You do not have permission to view stats for this Royal Link.' );
+ }
+ $period = sanitize_text_field( $args['period'] ?? '30days' );
+ $stats = Royal_Links_Tracker::get_link_stats( $link_id, $period );
+ return [
+ 'link_id' => $link_id,
+ 'title' => $post->post_title,
+ 'period' => $period,
+ 'stats' => $stats,
+ ];
+
+ default:
+ throw new Exception( 'Unknown Royal Links tool: ' . esc_html( $name ) );
+ }
+ }
+
+ /**
+ * Format a royal_link post for response.
+ */
+ private static function format_link( $post ) {
+ if ( ! $post ) {
+ return null;
+ }
+ $slug = get_post_meta( $post->ID, '_royal_links_slug', true );
+ $destination = get_post_meta( $post->ID, '_royal_links_destination_url', true );
+ $total_hits = (int) get_post_meta( $post->ID, '_royal_links_total_hits', true );
+ $base = trailingslashit( home_url() );
+ $prefix = get_option( 'royal_links_url_prefix', 'go' );
+ $short_url = $base . trim( $prefix, '/' ) . '/' . $slug;
+
+ return [
+ 'id' => (int) $post->ID,
+ 'title' => $post->post_title,
+ 'slug' => $slug,
+ 'short_url' => $short_url,
+ 'destination_url' => $destination,
+ 'redirect_type' => get_post_meta( $post->ID, '_royal_links_redirect_type', true ),
+ 'total_clicks' => $total_hits,
+ 'date_created' => $post->post_date,
+ ];
+ }
+}
--- a/royal-mcp/includes/Integrations/SiteVault.php
+++ b/royal-mcp/includes/Integrations/SiteVault.php
@@ -88,6 +88,13 @@
$manager = RB_Backup_Manager::instance();
+ // 1.4.26 — all SiteVault tools are admin-tier. Backups can contain the
+ // entire site (database + uploads + plugins). Even read-only listings
+ // expose backup contents, schedules, and storage destinations.
+ if ( ! current_user_can( 'manage_options' ) ) {
+ throw new Exception( 'You do not have permission to use SiteVault tools.' );
+ }
+
switch ( $name ) {
case 'sv_get_backups':
$query_args = [
--- a/royal-mcp/includes/Integrations/WooCommerce.php
+++ b/royal-mcp/includes/Integrations/WooCommerce.php
@@ -479,6 +479,15 @@
throw new Exception( 'WooCommerce is not active' );
}
+ // 1.4.26 — every WC tool gates behind manage_woocommerce. This is the
+ // umbrella cap WC's own admin screens require: admins + Shop Manager
+ // role have it; Customer, Subscriber, Contributor, and Editor do NOT.
+ // Per-action additions (publish_products, delete_others_shop_orders,
+ // etc.) layer on top below where the action is destructive.
+ if ( ! current_user_can( 'manage_woocommerce' ) ) {
+ throw new Exception( 'You do not have permission to use WooCommerce tools.' );
+ }
+
switch ( $name ) {
case 'wc_get_products':
$query_args = [
--- a/royal-mcp/includes/MCP/Server.php
+++ b/royal-mcp/includes/MCP/Server.php
@@ -1031,6 +1031,14 @@
switch ($name) {
// ==================== POSTS ====================
case 'wp_get_posts':
+ // 1.4.26 — per-tool cap check (WPScan / Erwan Le Rousseau report,
+ // CVSS 8.1). Pre-1.4.26 a Subscriber OAuth token could pass a
+ // non-public `status` (draft/private/trash) and receive
+ // admin-owned content. Require `read` to call at all, and
+ // strip restricted statuses unless the user can read them.
+ if (!current_user_can('read')) {
+ throw new Exception('You do not have permission to list posts.');
+ }
$query_args = [
'numberposts' => min(intval($args['per_page'] ?? 10), 100),
's' => sanitize_text_field($args['search'] ?? ''),
@@ -1041,7 +1049,29 @@
if (!$pto || !$pto->public) throw new Exception('Invalid or non-public post type: ' . esc_html($pt));
$query_args['post_type'] = $pt;
}
- if (!empty($args['status'])) $query_args['post_status'] = sanitize_text_field($args['status']);
+ if (!empty($args['status'])) {
+ $requested_status = sanitize_text_field($args['status']);
+ // Allowlist of public WP post statuses (defaults to ['publish'];
+ // honors any custom statuses registered as public). Anything
+ // else — including 'any', 'private', 'draft', 'pending',
+ // 'future', 'trash', unknown values, or typos — requires
+ // read_private_posts for the relevant post type. Fail closed
+ // on unexpected values. Erwan Le Rousseau's follow-up finding
+ // on the original 1.4.26 patch: the prior denylist did not
+ // match `status=any`, which let a low-privileged token
+ // enumerate title + excerpt of private and draft posts.
+ $public_statuses = get_post_stati(['public' => true]);
+ if (!in_array($requested_status, $public_statuses, true)) {
+ $pto_for_caps = !empty($args['post_type']) ? get_post_type_object(sanitize_text_field($args['post_type'])) : get_post_type_object('post');
+ $needed_cap = $pto_for_caps && !empty($pto_for_caps->cap->read_private_posts)
+ ? $pto_for_caps->cap->read_private_posts
+ : 'read_private_posts';
+ if (!current_user_can($needed_cap)) {
+ throw new Exception('You do not have permission to list ' . esc_html($requested_status) . ' posts.');
+ }
+ }
+ $query_args['post_status'] = $requested_status;
+ }
$posts = get_posts($query_args);
return array_map(function($p) {
return [
@@ -1058,6 +1088,11 @@
case 'wp_get_post':
$post = get_post(intval($args['id']));
if (!$post) throw new Exception('Post not found');
+ // 1.4.26 — per-post read check. read_post via map_meta_cap
+ // resolves to read_private_posts for non-public statuses.
+ if (!current_user_can('read_post', $post->ID)) {
+ throw new Exception('You do not have permission to read this post.');
+ }
return [
'id' => $post->ID,
'title' => $post->post_title,
@@ -1075,6 +1110,21 @@
$post_type = sanitize_text_field($args['post_type'] ?? 'post');
$pto = get_post_type_object($post_type);
if (!$pto || !$pto->public) throw new Exception('Invalid or non-public post type: ' . esc_html($post_type));
+ // 1.4.26 — per-post-type edit + publish caps. Pre-1.4.26 a
+ // Subscriber OAuth token could create-as-self including
+ // status=publish. The per-PT cap object maps `edit_posts` to
+ // the correct cap for custom post types (e.g. `edit_pages`).
+ $create_cap = !empty($pto->cap->edit_posts) ? $pto->cap->edit_posts : 'edit_posts';
+ if (!current_user_can($create_cap)) {
+ throw new Exception('You do not have permission to create ' . esc_html($post_type) . ' posts.');
+ }
+ $requested_status = isset($args['status']) ? sanitize_text_field($args['status']) : 'draft';
+ if ('publish' === $requested_status) {
+ $publish_cap = !empty($pto->cap->publish_posts) ? $pto->cap->publish_posts : 'publish_posts';
+ if (!current_user_can($publish_cap)) {
+ throw new Exception('You do not have permission to publish ' . esc_html($post_type) . ' posts.');
+ }
+ }
// Pre-validate featured_media so we don't create an orphan post if the ID is bad.
if (isset($args['featured_media']) && intval($args['featured_media']) > 0) {
$fm = get_post(intval($args['featured_media']));
@@ -1116,6 +1166,13 @@
case 'wp_update_post':
$post_id = intval($args['id']);
+ // 1.4.26 — object-level edit_post resolves to edit_others_posts
+ // when the target isn't owned by the current user, and to the
+ // PT-specific cap (edit_page etc.) automatically via map_meta_cap.
+ if ($post_id <= 0 || !get_post($post_id)) throw new Exception('Post not found.');
+ if (!current_user_can('edit_post', $post_id)) {
+ throw new Exception('You do not have permission to edit this post.');
+ }
// Pre-validate featured_media before mutating the post.
if (isset($args['featured_media']) && intval($args['featured_media']) > 0) {
$fm = get_post(intval($args['featured_media']));
@@ -1144,17 +1201,31 @@
return ['id' => $post_id, 'message' => 'Post updated successfully'];
case 'wp_delete_post':
+ $post_id = intval($args['id']);
+ if ($post_id <= 0 || !get_post($post_id)) throw new Exception('Post not found.');
+ // 1.4.26 — object-level delete_post via map_meta_cap resolves
+ // to delete_others_posts when the target isn't owned by the
+ // current user.
+ if (!current_user_can('delete_post', $post_id)) {
+ throw new Exception('You do not have permission to delete this post.');
+ }
$force = !empty($args['force']);
- $result = wp_delete_post(intval($args['id']), $force);
+ $result = wp_delete_post($post_id, $force);
if (!$result) throw new Exception('Failed to delete post');
return ['message' => $force ? 'Post permanently deleted' : 'Post moved to trash'];
case 'wp_count_posts':
+ if (!current_user_can('read')) {
+ throw new Exception('You do not have permission to view post counts.');
+ }
$type = sanitize_text_field($args['post_type'] ?? 'post');
$counts = wp_count_posts($type);
return (array) $counts;
case 'wp_get_post_types':
+ if (!current_user_can('read')) {
+ throw new Exception('You do not have permission to list post types.');
+ }
$types = get_post_types(['public' => true], 'objects');
return array_values(array_map(function($pt) {
return [
@@ -1168,6 +1239,9 @@
}, $types));
case 'wp_get_taxonomies':
+ if (!current_user_can('read')) {
+ throw new Exception('You do not have permission to list taxonomies.');
+ }
$taxonomies = get_taxonomies(['public' => true], 'objects');
return array_values(array_map(function($tax) {
// 1.4.12 — `slug` added as a clearer alias for the taxonomy
@@ -1189,6 +1263,9 @@
// ==================== PAGES ====================
case 'wp_get_pages':
+ if (!current_user_can('read')) {
+ throw new Exception('You do not have permission to list pages.');
+ }
$page_args = ['number' => min(intval($args['per_page'] ?? 10), 100)];
if (!empty($args['parent'])) $page_args['parent'] = intval($args['parent']);
$pages = get_pages($page_args);
@@ -1205,6 +1282,9 @@
case 'wp_get_page':
$page = get_post(intval($args['id']));
if (!$page || $page->post_type !== 'page') throw new Exception('Page not found');
+ if (!current_user_can('read_post', $page->ID)) {
+ throw new Exception('You do not have permission to read this page.');
+ }
return [
'id' => $page->ID,
'title' => $page->post_title,
@@ -1215,11 +1295,18 @@
];
case 'wp_create_page':
+ if (!current_user_can('edit_pages')) {
+ throw new Exception('You do not have permission to create pages.');
+ }
+ $page_status = in_array($args['status'] ?? 'draft', ['publish', 'draft']) ? $args['status'] : 'draft';
+ if ('publish' === $page_status && !current_user_can('publish_pages')) {
+ throw new Exception('You do not have permission to publish pages.');
+ }
// 1.4.21 — see wp_create_post above (issue #15).
$page_data = [
'post_title' => sanitize_text_field($args['title']),
'post_content' => wp_slash($args['content']),
- 'post_status' => in_array($args['status'] ?? 'draft', ['publish', 'draft']) ? $args['status'] : 'draft',
+ 'post_status' => $page_status,
'post_type' => 'page',
];
if (!empty($args['parent'])) $page_data['post_parent'] = intval($args['parent']);
@@ -1228,23 +1315,38 @@
return ['id' => $page_id, 'message' => 'Page created successfully', 'url' => get_permalink($page_id)];
case 'wp_update_page':
- $data = ['ID' => intval($args['id'])];
+ $page_id = intval($args['id']);
+ $existing_page = $page_id > 0 ? get_post($page_id) : null;
+ if (!$existing_page || $existing_page->post_type !== 'page') throw new Exception('Page not found.');
+ if (!current_user_can('edit_post', $page_id)) {
+ throw new Exception('You do not have permission to edit this page.');
+ }
+ $data = ['ID' => $page_id];
if (isset($args['title'])) $data['post_title'] = sanitize_text_field($args['title']);
// 1.4.21 — see wp_create_post above (issue #15).
if (isset($args['content'])) $data['post_content'] = wp_slash($args['content']);
if (isset($args['status'])) $data['post_status'] = sanitize_text_field($args['status']);
$result = wp_update_post($data);
if (is_wp_error($result)) throw new Exception(esc_html($result->get_error_message()));
- return ['id' => $args['id'], 'message' => 'Page updated successfully'];
+ return ['id' => $page_id, 'message' => 'Page updated successfully'];
case 'wp_delete_page':
+ $page_id = intval($args['id']);
+ $existing_page = $page_id > 0 ? get_post($page_id) : null;
+ if (!$existing_page || $existing_page->post_type !== 'page') throw new Exception('Page not found.');
+ if (!current_user_can('delete_post', $page_id)) {
+ throw new Exception('You do not have permission to delete this page.');
+ }
$force = !empty($args['force']);
- $result = wp_delete_post(intval($args['id']), $force);
+ $result = wp_delete_post($page_id, $force);
if (!$result) throw new Exception('Failed to delete page');
return ['message' => $force ? 'Page permanently deleted' : 'Page moved to trash'];
//