Atomic Edge Proof of Concept automated generator using AI diff analysis
Published : June 27, 2026

CVE-2026-54842: Royal MCP – Secure AI Connector for Claude, ChatGPT & Gemini <= 1.4.25 Missing Authorization PoC, Patch Analysis & Rule

Plugin royal-mcp
Severity Medium (CVSS 4.3)
CWE 862
Vulnerable Version 1.4.25
Patched Version 1.4.26
Disclosed June 17, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-54842:
The Royal MCP – Secure AI Connector for Claude, ChatGPT & Gemini plugin for WordPress, versions up to and including 1.4.25, contains a missing authorization vulnerability. This allows authenticated attackers with subscriber-level access to access and manipulate sensitive data through MCP (Model Context Protocol) tools without appropriate capability checks.

Root Cause:
The vulnerability stems from multiple MCP integration classes that register and execute tools without verifying user capabilities. In the ACF integration (royal-mcp/includes/Integrations/ACF.php), the `acf_get_field_value`, `acf_get_field_objects`, and `acf_update_field_value` cases in the `execute_tool` method lacked any capability checks before lines 108-210. Similarly, the ForgeCache integration (royal-mcp/includes/Integrations/ForgeCache.php) had no permission checks in the `fc_clear_cache`, `fc_get_cache_stats`, and `fc_purge_url` tool cases. The GuardPress integration (royal-mcp/includes/Integrations/GuardPress.php) and RoyalLedger integration (royal-mcp/includes/Integrations/RoyalLedger.php) also lacked authorization gating on all their tool handlers. The core issue is that tool execution functions did not call `current_user_can()` before performing operations, allowing any authenticated user to invoke these privileged actions.

Exploitation:
An authenticated attacker with subscriber-level access can exploit this by sending crafted requests to the plugin’s MCP tool execution endpoint. The attacker would call the `acf_get_field_value` tool with a `post_id` parameter pointing to a private or protected post to read its ACF field data. They could call `fc_clear_cache` to flush the entire page cache, causing a denial of service through performance degradation. For Royal Ledger, they could call `rl_get_costs`, `rl_create_cost`, `rl_get_renewals`, or `rl_get_keys` to access or modify financial data and license key metadata. The attack vector is an AJAX or REST API request targeting the MCP tool executor, with the `name` parameter set to the vulnerable tool name and `args` containing the necessary parameters.

Patch Analysis:
The patch adds capability checks using `current_user_can()` at the beginning of each vulnerable tool case. In ACF.php, lines 111-114 add `current_user_can(‘read_post’, $post_id)` for read operations and `current_user_can(‘edit_post’, $post_id)` for write operations. ForgeCache.php adds `current_user_can(‘manage_options’)` for cache clearing and stats (lines 131-133, 140-142) and `current_user_can(‘edit_post’, $post_id)` for URL purging (lines 155-157). GuardPress.php adds a single `manage_options` check at line 90 before the switch statement. RoyalLedger.php adds `manage_options` checks before each tool case (lines 111-113, 129-131, 158-160, 189-192). These checks prevent unauthorized users from executing privileged operations, matching WordPress’s built-in role and capability system.

Impact:
Successful exploitation allows an authenticated attacker with only subscriber-level access to read ACF field values from any post, including private and protected content, potentially exposing sensitive data. They can clear the site’s page cache, causing performance degradation and a denial-of-service condition. Access to GuardPress tools exposes security state information, including failed login attempts and blocked IPs, aiding further attacks. Royal Ledger access reveals financial records and license key metadata. The CVSS score of 4.3 reflects the authenticated nature of the attack but underestimates the cumulative impact of combined tool access.

Differential between vulnerable and patched code

Below is a differential between the unpatched vulnerable code and the patched update, for reference.

Code Diff
--- 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'];

             //

Frequently Asked Questions

How Atomic Edge Works

Simple Setup. Powerful Security.

Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.

Get Started

Trusted by Developers & Organizations

Trusted by Developers
Blac&kMcDonaldCovenant House TorontoAlzheimer Society CanadaUniversity of TorontoHarvard Medical School